mirror of
https://github.com/Horizontal42/summarum.git
synced 2026-11-10 16:51:59 +03:00
Compare commits
No commits in common. "main" and "v1.2.5" have entirely different histories.
45 changed files with 518 additions and 2094 deletions
58
.github/workflows/release.yml
vendored
58
.github/workflows/release.yml
vendored
|
|
@ -14,40 +14,18 @@ jobs:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
# tauri-action's find-or-create-release check races across parallel
|
# tauri-action's find-or-create-release check races across parallel
|
||||||
# matrix jobs and can create two separate release objects for the
|
# matrix jobs and can create two separate release objects for the
|
||||||
# same tag, each missing the other's assets — force serial builds.
|
# same tag, each missing the other's assets — force serial builds
|
||||||
# Every platform added below shares that release object, so they all
|
|
||||||
# have to stay in this one serialized matrix.
|
|
||||||
max-parallel: 1
|
max-parallel: 1
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- os: windows-latest
|
- target: x86_64-pc-windows-msvc
|
||||||
target: x86_64-pc-windows-msvc
|
|
||||||
arch: x64
|
arch: x64
|
||||||
- os: windows-latest
|
- target: aarch64-pc-windows-msvc
|
||||||
target: aarch64-pc-windows-msvc
|
|
||||||
arch: arm64
|
arch: arm64
|
||||||
# 22.04 is the oldest runner with webkit2gtk-4.1 (what Tauri 2
|
runs-on: windows-latest
|
||||||
# needs); building on it keeps the glibc floor as low as possible
|
|
||||||
- os: ubuntu-22.04
|
|
||||||
target: x86_64-unknown-linux-gnu
|
|
||||||
arch: x64
|
|
||||||
- os: macos-latest
|
|
||||||
target: aarch64-apple-darwin
|
|
||||||
arch: arm64
|
|
||||||
- os: macos-latest
|
|
||||||
target: x86_64-apple-darwin
|
|
||||||
arch: x64
|
|
||||||
runs-on: ${{ matrix.os }}
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- if: runner.os == 'Linux'
|
|
||||||
run: |
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install -y build-essential curl wget file \
|
|
||||||
libwebkit2gtk-4.1-dev librsvg2-dev libssl-dev libxdo-dev \
|
|
||||||
libayatana-appindicator3-dev patchelf
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
|
|
@ -64,19 +42,9 @@ jobs:
|
||||||
|
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
|
|
||||||
# TAURI_SIGNING_* below is the updater's minisign keypair, not OS code
|
|
||||||
# signing — there is no Apple certificate, so macOS builds are only
|
|
||||||
# ad-hoc signed (bundle.macOS.signingIdentity "-") and Gatekeeper still
|
|
||||||
# asks the user to allow them on first launch.
|
|
||||||
#
|
|
||||||
# RELEASE_TOKEN (fine-grained PAT, Contents: read/write) stands in for
|
|
||||||
# the built-in GITHUB_TOKEN: the Actions-native token started getting
|
|
||||||
# "Resource not accessible by integration" on release creation for this
|
|
||||||
# repo — confirmed not a permissions:/repo-settings/tag-protection issue,
|
|
||||||
# a personal token hits the same Releases API fine.
|
|
||||||
- uses: tauri-apps/tauri-action@v0
|
- uses: tauri-apps/tauri-action@v0
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||||
with:
|
with:
|
||||||
|
|
@ -95,27 +63,17 @@ jobs:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- env:
|
- env:
|
||||||
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
prev=$(git tag --sort=-v:refname | grep -v "^${{ github.ref_name }}$" | head -1)
|
prev=$(git tag --sort=-v:refname | grep -v "^${{ github.ref_name }}$" | head -1)
|
||||||
# release-checklist version bumps (bump version to X / bump cargo
|
|
||||||
# version to X / update tauri.conf version to X / sync Cargo.lock
|
|
||||||
# or package-lock.json with X) are internal bookkeeping, not
|
|
||||||
# user-facing changes — never worth a changelog line.
|
|
||||||
noise='^- (bump( cargo)? version to|update tauri\.conf version to|sync (Cargo\.lock|package-lock\.json) with) [0-9]+\.[0-9]+\.[0-9]+$'
|
|
||||||
filter() {
|
|
||||||
grep -vE "$noise" \
|
|
||||||
| sed -E 's/ \(#[0-9]+\)$//' \
|
|
||||||
| perl -CSD -pe 's/^- \p{Emoji_Presentation}\x{FE0F}?\s*/- /'
|
|
||||||
}
|
|
||||||
{
|
{
|
||||||
echo "## What's new"
|
echo "## What's new"
|
||||||
if [ -n "$prev" ]; then
|
if [ -n "$prev" ]; then
|
||||||
git log "$prev..${{ github.ref_name }}" --pretty=format:"- %s" --no-merges | filter
|
git log "$prev..${{ github.ref_name }}" --pretty=format:"- %s" --no-merges | grep -v "^- bump version"
|
||||||
echo ""
|
echo ""
|
||||||
echo "**Full Changelog**: https://github.com/${{ github.repository }}/compare/$prev...${{ github.ref_name }}"
|
echo "**Full Changelog**: https://github.com/${{ github.repository }}/compare/$prev...${{ github.ref_name }}"
|
||||||
else
|
else
|
||||||
git log "${{ github.ref_name }}" --pretty=format:"- %s" --no-merges | filter
|
git log "${{ github.ref_name }}" --pretty=format:"- %s" --no-merges | grep -v "^- bump version"
|
||||||
fi
|
fi
|
||||||
} > notes.md
|
} > notes.md
|
||||||
gh release edit "${{ github.ref_name }}" --notes-file notes.md --draft=false
|
gh release edit "${{ github.ref_name }}" --notes-file notes.md --draft=false
|
||||||
|
|
|
||||||
|
|
@ -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,13 @@ src/
|
||||||
editor.ts CodeMirror 6 wiring: highlight, results overlay, copy
|
editor.ts CodeMirror 6 wiring: highlight, results overlay, copy
|
||||||
search.ts cross-sheet search overlay (text + result-value queries)
|
search.ts cross-sheet search overlay (text + result-value queries)
|
||||||
export.ts renders the sheet + results to a PNG (clipboard/file)
|
export.ts renders the sheet + results to a PNG (clipboard/file)
|
||||||
sparkline.ts SVG sparkline renderer for `chart` results
|
|
||||||
app.css themes (light/dark via CSS variables)
|
app.css themes (light/dark via CSS variables)
|
||||||
main.ts app bootstrap: documents, settings, status bar, dnd
|
main.ts app bootstrap: documents, settings, status bar, dnd
|
||||||
workspace.ts cross-sheet references: exports cache, cycle detection
|
|
||||||
storage.ts Tauri commands / localStorage fallback
|
storage.ts Tauri commands / localStorage fallback
|
||||||
extensions.ts runs user .js files in a QuickJS/WASM sandbox, bridged
|
extensions.ts runs user .js files in a QuickJS/WASM sandbox, bridged
|
||||||
to the engine's numi.* API
|
to the engine's numi.* API
|
||||||
i18n.ts interface strings (en/ru)
|
i18n.ts interface strings (en/ru)
|
||||||
updater.ts checks tauri-plugin-updater, installs + relaunches
|
updater.ts checks tauri-plugin-updater, installs + relaunches
|
||||||
logger.ts thin console wrapper (info/warn/error)
|
|
||||||
src-tauri/
|
src-tauri/
|
||||||
src/main.rs tray, hide-to-tray, storage commands, rates fetching
|
src/main.rs tray, hide-to-tray, storage commands, rates fetching
|
||||||
(open.er-api.com + CoinGecko + Yahoo Finance v8 chart)
|
(open.er-api.com + CoinGecko + Yahoo Finance v8 chart)
|
||||||
|
|
@ -218,40 +215,6 @@ recursing forever.
|
||||||
deleted sheets become `.numi` files under `backups/deleted/` and are pruned
|
deleted sheets become `.numi` files under `backups/deleted/` and are pruned
|
||||||
by age. Saving is debounced at 400 ms.
|
by age. Saving is debounced at 400 ms.
|
||||||
|
|
||||||
## Platforms and packaging
|
|
||||||
|
|
||||||
- One codebase, three desktop targets. `tauri.conf.json` holds everything
|
|
||||||
shared; the per-platform bits live in `tauri.windows.conf.json`,
|
|
||||||
`tauri.linux.conf.json` and `tauri.macos.conf.json`, which Tauri merges over
|
|
||||||
the base config (JSON Merge Patch) according to the target being built. Each
|
|
||||||
one only sets `bundle.targets` and that platform's bundler options — NSIS on
|
|
||||||
Windows, deb/rpm/AppImage on Linux, .app/.dmg on macOS.
|
|
||||||
- `release.yml` builds five artifacts on three runner families: Windows x64 and
|
|
||||||
arm64, Linux x64 (`ubuntu-22.04` — the oldest runner carrying webkit2gtk-4.1,
|
|
||||||
which keeps the glibc floor low), macOS arm64 and x64. Two separate macOS
|
|
||||||
targets rather than `universal-apple-darwin`: it matches how Windows already
|
|
||||||
ships per-architecture, halves each download, and gives the updater the
|
|
||||||
`darwin-aarch64`/`darwin-x86_64` keys it looks up by default.
|
|
||||||
- The whole matrix stays `max-parallel: 1`. Every job hands its assets to the
|
|
||||||
*same* GitHub Release object, and tauri-action's find-or-create check races if
|
|
||||||
two jobs reach it at once — adding platforms did not change that constraint,
|
|
||||||
it made it apply to more jobs.
|
|
||||||
- No OS code signing anywhere. Windows ships unsigned; macOS is *ad-hoc* signed
|
|
||||||
(`bundle.macOS.signingIdentity: "-"`), which needs no Apple certificate but is
|
|
||||||
required for Apple Silicon to run a downloaded binary at all — without it the
|
|
||||||
arm64 build is rejected as damaged rather than merely warned about. Gatekeeper
|
|
||||||
still asks the user to allow the app on first launch either way.
|
|
||||||
- `bundle.fileAssociations` covers `.numi`/`.sum` on Windows (NSIS registry
|
|
||||||
entries) and macOS (`CFBundleDocumentTypes`). On Linux it writes nothing
|
|
||||||
useful: registering an extension there also needs a shared-mime-info XML that
|
|
||||||
Tauri does not generate, so `.numi` files are opened by dragging them into the
|
|
||||||
window rather than from the file manager.
|
|
||||||
- `icons/icon.icns` is emitted by `scripts/gen-icon.ts` alongside the PNGs and
|
|
||||||
the `.ico`, hand-rolled the same dependency-free way: `icns` magic + total
|
|
||||||
length, then one `[OSType][length][PNG]` chunk per size, using the modern
|
|
||||||
PNG-payload tags `iconutil` produces (`icp4`/`icp5`, `ic07`–`ic14`). Rerun it
|
|
||||||
with `npm run icons` after touching the artwork.
|
|
||||||
|
|
||||||
## Auto-update
|
## Auto-update
|
||||||
|
|
||||||
- `updater.ts` calls `tauri-plugin-updater`'s `check()` on boot; if a signed
|
- `updater.ts` calls `tauri-plugin-updater`'s `check()` on boot; if a signed
|
||||||
|
|
@ -279,9 +242,7 @@ recursing forever.
|
||||||
flipping it while the app is running takes effect immediately); the manual
|
flipping it while the app is running takes effect immediately); the manual
|
||||||
button ignores the toggle and always works.
|
button ignores the toggle and always works.
|
||||||
- The update endpoint is `latest.json` published alongside each GitHub
|
- The update endpoint is `latest.json` published alongside each GitHub
|
||||||
Release. `release.yml` signs every platform's updater artifact with a
|
Release. `release.yml` signs both architectures with a minisign keypair:
|
||||||
minisign keypair (this is Tauri's own update signing, unrelated to OS code
|
|
||||||
signing):
|
|
||||||
the public half is embedded in `tauri.conf.json`; the private half + its
|
the public half is embedded in `tauri.conf.json`; the private half + its
|
||||||
password live only as `TAURI_SIGNING_PRIVATE_KEY(_PASSWORD)` repo secrets
|
password live only as `TAURI_SIGNING_PRIVATE_KEY(_PASSWORD)` repo secrets
|
||||||
(write-only — there is no durable copy in the repo). Losing that key means
|
(write-only — there is no durable copy in the repo). Losing that key means
|
||||||
|
|
@ -303,7 +264,7 @@ All files below live in `%APPDATA%/app.summarum.calc` (or the user-chosen folder
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
`npm test` runs 373 vitest cases across 21 files: `src/engine/*.test.ts` covers every
|
`npm test` runs 259 vitest cases: `src/engine/*.test.ts` covers every
|
||||||
expression class, both languages, deterministic injected rates, goal seek,
|
expression class, both languages, deterministic injected rates, goal seek,
|
||||||
historical rates (injected), date-format literals/display (dedicated engines
|
historical rates (injected), date-format literals/display (dedicated engines
|
||||||
with an explicit `dateFormat`, since the default "system" format depends on
|
with an explicit `dateFormat`, since the default "system" format depends on
|
||||||
|
|
|
||||||
|
|
@ -24,16 +24,13 @@ src/
|
||||||
editor.ts обвязка CodeMirror 6: подсветка, оверлей результатов, копирование
|
editor.ts обвязка CodeMirror 6: подсветка, оверлей результатов, копирование
|
||||||
search.ts оверлей поиска по всем листам (текст + запросы по значению)
|
search.ts оверлей поиска по всем листам (текст + запросы по значению)
|
||||||
export.ts рендерит лист + результаты в PNG (буфер обмена/файл)
|
export.ts рендерит лист + результаты в PNG (буфер обмена/файл)
|
||||||
sparkline.ts рендер SVG-спарклайна для результатов `chart`
|
|
||||||
app.css темы (светлая/тёмная через CSS-переменные)
|
app.css темы (светлая/тёмная через CSS-переменные)
|
||||||
main.ts запуск приложения: документы, настройки, строка статуса, dnd
|
main.ts запуск приложения: документы, настройки, строка статуса, dnd
|
||||||
workspace.ts межлистовые ссылки: кэш экспортов, обнаружение циклов
|
|
||||||
storage.ts команды Tauri / запасной вариант на localStorage
|
storage.ts команды Tauri / запасной вариант на localStorage
|
||||||
extensions.ts запускает пользовательские .js-файлы в песочнице
|
extensions.ts запускает пользовательские .js-файлы в песочнице
|
||||||
QuickJS/WASM, мост к API numi.* движка
|
QuickJS/WASM, мост к API numi.* движка
|
||||||
i18n.ts строки интерфейса (en/ru)
|
i18n.ts строки интерфейса (en/ru)
|
||||||
updater.ts проверка tauri-plugin-updater, установка + перезапуск
|
updater.ts проверка tauri-plugin-updater, установка + перезапуск
|
||||||
logger.ts тонкая обёртка над console (info/warn/error)
|
|
||||||
src-tauri/
|
src-tauri/
|
||||||
src/main.rs трей, скрытие в трей, команды хранилища, загрузка курсов
|
src/main.rs трей, скрытие в трей, команды хранилища, загрузка курсов
|
||||||
(open.er-api.com + CoinGecko + Yahoo Finance v8 chart)
|
(open.er-api.com + CoinGecko + Yahoo Finance v8 chart)
|
||||||
|
|
@ -220,40 +217,6 @@ src-tauri/
|
||||||
удалённые листы становятся `.numi`-файлами в `backups/deleted/` и удаляются по возрасту.
|
удалённые листы становятся `.numi`-файлами в `backups/deleted/` и удаляются по возрасту.
|
||||||
Сохранение откладывается на 400 мс.
|
Сохранение откладывается на 400 мс.
|
||||||
|
|
||||||
## Платформы и упаковка
|
|
||||||
|
|
||||||
- Одна кодовая база, три десктопных платформы. Всё общее лежит в
|
|
||||||
`tauri.conf.json`, платформенные куски — в `tauri.windows.conf.json`,
|
|
||||||
`tauri.linux.conf.json` и `tauri.macos.conf.json`, которые Tauri накладывает
|
|
||||||
поверх базового конфига (JSON Merge Patch) в зависимости от собираемой цели.
|
|
||||||
Каждый задаёт только `bundle.targets` и опции своего упаковщика: NSIS на
|
|
||||||
Windows, deb/rpm/AppImage на Linux, .app/.dmg на macOS.
|
|
||||||
- `release.yml` собирает пять артефактов на трёх видах раннеров: Windows x64 и
|
|
||||||
arm64, Linux x64 (`ubuntu-22.04` — самый старый раннер с webkit2gtk-4.1, что
|
|
||||||
держит планку glibc низко), macOS arm64 и x64. Две отдельные macOS-цели вместо
|
|
||||||
`universal-apple-darwin`: так же, как Windows уже собирается по архитектурам,
|
|
||||||
вдвое меньше вес загрузки, и апдейтер получает ключи `darwin-aarch64` /
|
|
||||||
`darwin-x86_64`, которые ищет по умолчанию.
|
|
||||||
- Вся матрица остаётся `max-parallel: 1`. Все джобы отдают артефакты в *один и
|
|
||||||
тот же* объект GitHub Release, а проверка find-or-create в tauri-action гонит,
|
|
||||||
если две джобы доходят до неё одновременно — добавление платформ это
|
|
||||||
ограничение не сняло, а распространило на большее число джоб.
|
|
||||||
- Подписи ОС нет нигде. Windows выходит неподписанным; macOS подписан *ad-hoc*
|
|
||||||
(`bundle.macOS.signingIdentity: "-"`) — сертификат Apple для этого не нужен, но
|
|
||||||
без него Apple Silicon вообще откажется запускать скачанный бинарник (сочтёт
|
|
||||||
повреждённым, а не просто предупредит). Gatekeeper в любом случае попросит
|
|
||||||
пользователя разрешить приложение при первом запуске.
|
|
||||||
- `bundle.fileAssociations` покрывает `.numi`/`.sum` на Windows (записи в реестр
|
|
||||||
из NSIS) и macOS (`CFBundleDocumentTypes`). На Linux он не даёт ничего
|
|
||||||
полезного: там для регистрации расширения нужен ещё XML для shared-mime-info,
|
|
||||||
который Tauri не генерирует, так что `.numi` открываются перетаскиванием в
|
|
||||||
окно, а не из файлового менеджера.
|
|
||||||
- `icons/icon.icns` создаёт `scripts/gen-icon.ts` рядом с PNG и `.ico`, вручную
|
|
||||||
и без зависимостей: магия `icns` + общая длина, дальше по чанку
|
|
||||||
`[OSType][длина][PNG]` на размер, с современными PNG-тегами, которые выдаёт
|
|
||||||
`iconutil` (`icp4`/`icp5`, `ic07`–`ic14`). После правки картинки перегенерить
|
|
||||||
через `npm run icons`.
|
|
||||||
|
|
||||||
## Автообновление
|
## Автообновление
|
||||||
|
|
||||||
- `updater.ts` вызывает `check()` из `tauri-plugin-updater` при запуске; если
|
- `updater.ts` вызывает `check()` из `tauri-plugin-updater` при запуске; если
|
||||||
|
|
@ -282,9 +245,7 @@ src-tauri/
|
||||||
тике интервала, так что переключение прямо во время работы срабатывает
|
тике интервала, так что переключение прямо во время работы срабатывает
|
||||||
сразу); ручная кнопка тумблер игнорирует и работает всегда.
|
сразу); ручная кнопка тумблер игнорирует и работает всегда.
|
||||||
- Эндпоинт обновлений — `latest.json`, публикуемый вместе с каждым GitHub
|
- Эндпоинт обновлений — `latest.json`, публикуемый вместе с каждым GitHub
|
||||||
Release. `release.yml` подписывает артефакт обновления каждой платформы парой
|
Release. `release.yml` подписывает обе архитектуры парой ключей minisign:
|
||||||
ключей minisign (это собственная подпись обновлений Tauri, к подписи кода в ОС
|
|
||||||
отношения не имеет):
|
|
||||||
публичная половина зашита в `tauri.conf.json`; приватная половина + пароль
|
публичная половина зашита в `tauri.conf.json`; приватная половина + пароль
|
||||||
живут только как секреты репозитория `TAURI_SIGNING_PRIVATE_KEY(_PASSWORD)`
|
живут только как секреты репозитория `TAURI_SIGNING_PRIVATE_KEY(_PASSWORD)`
|
||||||
(write-only — в репозитории нет постоянной копии). Потеря этого ключа
|
(write-only — в репозитории нет постоянной копии). Потеря этого ключа
|
||||||
|
|
@ -307,7 +268,7 @@ src-tauri/
|
||||||
|
|
||||||
## Тесты
|
## Тесты
|
||||||
|
|
||||||
`npm test` запускает 373 vitest-кейса в 21 файле: `src/engine/*.test.ts` покрывает все
|
`npm test` запускает 259 vitest-кейсов: `src/engine/*.test.ts` покрывает все
|
||||||
классы выражений, оба языка, детерминированные инжектированные курсы, поиск
|
классы выражений, оба языка, детерминированные инжектированные курсы, поиск
|
||||||
неизвестного, исторические курсы (инжектированные), литералы и отображение
|
неизвестного, исторические курсы (инжектированные), литералы и отображение
|
||||||
форматов дат (отдельные движки с явным `dateFormat` — формат «system» по
|
форматов дат (отдельные движки с явным `dateFormat` — формат «system» по
|
||||||
|
|
|
||||||
40
README.md
40
README.md
|
|
@ -29,29 +29,16 @@ Works in **English and Russian** at the same time: `5 метров в см`, `20
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
Grab the build for your system from [Releases](../../releases). The whole app
|
Grab the latest `Summarum_*_x64-setup.exe` (or `arm64-setup.exe` on Windows
|
||||||
is about 2 MB and checks for updates on its own after that.
|
on ARM) from [Releases](../../releases), run it, done. The whole app is
|
||||||
|
about 2 MB. It checks for updates on its own after that.
|
||||||
- **Windows** — `Summarum_*_x64-setup.exe` (or `arm64-setup.exe` on Windows on
|
|
||||||
ARM). Run it, done.
|
|
||||||
- **Linux** — `.AppImage` (`chmod +x`, then run) or the `.deb`/`.rpm` if you'd
|
|
||||||
rather install it properly. Self-updating only works on the AppImage; deb and
|
|
||||||
rpm are updated by downloading the next one.
|
|
||||||
- **macOS** — `.dmg`, Apple Silicon or Intel. These builds are not signed with
|
|
||||||
an Apple Developer certificate, so the first launch has to be a right-click →
|
|
||||||
**Open** rather than a double-click (or run
|
|
||||||
`xattr -cr /Applications/Summarum.app` once in a terminal). Every launch after
|
|
||||||
that is normal.
|
|
||||||
|
|
||||||
Requirements:
|
Requirements:
|
||||||
|
|
||||||
- **Windows 10 (1803+) or Windows 11**, 64-bit, plus the
|
- **Windows 10 (1803+) or Windows 11**, 64-bit.
|
||||||
**[WebView2 Runtime](https://developer.microsoft.com/en-us/microsoft-edge/webview2/)** —
|
- **[WebView2 Runtime](https://developer.microsoft.com/en-us/microsoft-edge/webview2/)** —
|
||||||
already part of Windows 11 and most updated Windows 10 systems; if it's
|
already part of Windows 11 and most updated Windows 10 systems; if it's
|
||||||
missing, the installer downloads it automatically.
|
missing, the installer downloads it automatically.
|
||||||
- **Linux** with `webkit2gtk-4.1` (Ubuntu 22.04+, Debian 12+, Fedora 36+) and
|
|
||||||
`libayatana-appindicator3` for the tray icon.
|
|
||||||
- **macOS 10.13** or newer.
|
|
||||||
- Internet connection is only needed for live rates and market data — everything
|
- Internet connection is only needed for live rates and market data — everything
|
||||||
else works offline (rates fall back to a cached/bundled snapshot).
|
else works offline (rates fall back to a cached/bundled snapshot).
|
||||||
|
|
||||||
|
|
@ -169,7 +156,7 @@ see [ARCHITECTURE.md](ARCHITECTURE.md).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
npm test # engine tests (373 cases)
|
npm test # engine tests (259 cases)
|
||||||
npm run tauri dev # run the app
|
npm run tauri dev # run the app
|
||||||
npm run tauri build # build the installer
|
npm run tauri build # build the installer
|
||||||
```
|
```
|
||||||
|
|
@ -178,28 +165,19 @@ To build from source you need:
|
||||||
|
|
||||||
- [Node.js](https://nodejs.org/) 22.6 or newer (`npm run icons` relies on
|
- [Node.js](https://nodejs.org/) 22.6 or newer (`npm run icons` relies on
|
||||||
Node's built-in TypeScript support)
|
Node's built-in TypeScript support)
|
||||||
- [Rust](https://rustup.rs/), stable
|
- [Rust](https://rustup.rs/) (stable, MSVC toolchain)
|
||||||
- On Windows: the MSVC toolchain plus
|
- [Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/)
|
||||||
[Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/)
|
|
||||||
with the "Desktop development with C++" workload (the MSVC linker)
|
with the "Desktop development with C++" workload (the MSVC linker)
|
||||||
- On Linux: `libwebkit2gtk-4.1-dev`, `libayatana-appindicator3-dev`,
|
|
||||||
`librsvg2-dev`, `libxdo-dev`, `libssl-dev`, `patchelf` and a C toolchain
|
|
||||||
- On macOS: Xcode Command Line Tools
|
|
||||||
- See the [Tauri prerequisites](https://tauri.app/start/prerequisites/) page
|
- See the [Tauri prerequisites](https://tauri.app/start/prerequisites/) page
|
||||||
if anything refuses to compile
|
if anything refuses to compile
|
||||||
|
|
||||||
`npm run tauri build` produces whatever the host platform bundles — NSIS on
|
|
||||||
Windows, deb/rpm/AppImage on Linux, .app/.dmg on macOS. Cross-compiling
|
|
||||||
between the three is not supported; the release workflow builds each on its
|
|
||||||
own runner.
|
|
||||||
|
|
||||||
Stack: [Tauri 2](https://tauri.app) + TypeScript + CodeMirror 6.
|
Stack: [Tauri 2](https://tauri.app) + TypeScript + CodeMirror 6.
|
||||||
|
|
||||||
## Credits
|
## Credits
|
||||||
|
|
||||||
Inspired by the excellent [Numi](https://numi.app) by Dmitry Nikolaev —
|
Inspired by the excellent [Numi](https://numi.app) by Dmitry Nikolaev —
|
||||||
if you're on macOS, buy it. Summarum is an independent project, built from
|
if you're on macOS, buy it. Summarum is an independent project, built from
|
||||||
scratch for Windows, Linux and macOS.
|
scratch for Windows.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|
|
||||||
36
README.ru.md
36
README.ru.md
|
|
@ -27,28 +27,14 @@ AAPL в USD → $213.55
|
||||||
|
|
||||||
## Установка
|
## Установка
|
||||||
|
|
||||||
Скачай сборку под свою систему из [Releases](../../releases). Приложение весит
|
Скачай последний `Summarum_*_x64-setup.exe` (или `arm64-setup.exe` для Windows on ARM) из [Releases](../../releases), запусти — готово. Весь установщик около 2 МБ. Дальше обновления проверяются сами.
|
||||||
около 2 МБ, дальше обновления проверяются сами.
|
|
||||||
|
|
||||||
- **Windows** — `Summarum_*_x64-setup.exe` (или `arm64-setup.exe` для Windows on
|
|
||||||
ARM). Запусти — готово.
|
|
||||||
- **Linux** — `.AppImage` (`chmod +x` и запускай) либо `.deb`/`.rpm`, если хочется
|
|
||||||
поставить нормальным пакетом. Самообновление работает только у AppImage, deb и
|
|
||||||
rpm обновляются скачиванием следующей версии.
|
|
||||||
- **macOS** — `.dmg` для Apple Silicon или Intel. Сборки не подписаны сертификатом
|
|
||||||
Apple Developer, поэтому первый запуск — правый клик → **Открыть**, а не двойной
|
|
||||||
клик (либо один раз выполнить `xattr -cr /Applications/Summarum.app` в терминале).
|
|
||||||
Дальше запускается как обычно.
|
|
||||||
|
|
||||||
Требования:
|
Требования:
|
||||||
|
|
||||||
- **Windows 10 (1803+) или Windows 11**, 64-бит, и
|
- **Windows 10 (1803+) или Windows 11**, 64-бит.
|
||||||
**[WebView2 Runtime](https://developer.microsoft.com/en-us/microsoft-edge/webview2/)** —
|
- **[WebView2 Runtime](https://developer.microsoft.com/en-us/microsoft-edge/webview2/)** —
|
||||||
уже встроен в Windows 11 и большинство обновлённых Windows 10; если отсутствует,
|
уже встроен в Windows 11 и большинство обновлённых Windows 10; если отсутствует,
|
||||||
установщик скачает его автоматически.
|
установщик скачает его автоматически.
|
||||||
- **Linux** с `webkit2gtk-4.1` (Ubuntu 22.04+, Debian 12+, Fedora 36+) и
|
|
||||||
`libayatana-appindicator3` для иконки в трее.
|
|
||||||
- **macOS 10.13** или новее.
|
|
||||||
- Интернет нужен только для живых курсов и рыночных данных — всё остальное работает офлайн
|
- Интернет нужен только для живых курсов и рыночных данных — всё остальное работает офлайн
|
||||||
(курсы берутся из кэша или встроенного снимка).
|
(курсы берутся из кэша или встроенного снимка).
|
||||||
|
|
||||||
|
|
@ -165,7 +151,7 @@ numi.addFunction({ id: "гипот", phrases: "гипот" },
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
npm test # тесты движка (373 кейса)
|
npm test # тесты движка (259 кейсов)
|
||||||
npm run tauri dev # запустить приложение
|
npm run tauri dev # запустить приложение
|
||||||
npm run tauri build # собрать установщик
|
npm run tauri build # собрать установщик
|
||||||
```
|
```
|
||||||
|
|
@ -173,25 +159,17 @@ npm run tauri build # собрать установщик
|
||||||
Для сборки нужны:
|
Для сборки нужны:
|
||||||
|
|
||||||
- [Node.js](https://nodejs.org/) 22.6 или новее
|
- [Node.js](https://nodejs.org/) 22.6 или новее
|
||||||
- [Rust](https://rustup.rs/), stable
|
- [Rust](https://rustup.rs/) (stable, MSVC toolchain)
|
||||||
- На Windows: toolchain MSVC и
|
- [Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/)
|
||||||
[Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/)
|
|
||||||
с компонентом «Desktop development with C++» (нужен линковщик MSVC)
|
с компонентом «Desktop development with C++» (нужен линковщик MSVC)
|
||||||
- На Linux: `libwebkit2gtk-4.1-dev`, `libayatana-appindicator3-dev`,
|
|
||||||
`librsvg2-dev`, `libxdo-dev`, `libssl-dev`, `patchelf` и компилятор C
|
|
||||||
- На macOS: Xcode Command Line Tools
|
|
||||||
- Если что-то не собирается — см. [Tauri prerequisites](https://tauri.app/start/prerequisites/)
|
- Если что-то не собирается — см. [Tauri prerequisites](https://tauri.app/start/prerequisites/)
|
||||||
|
|
||||||
`npm run tauri build` собирает то, что умеет хост-платформа: NSIS на Windows,
|
|
||||||
deb/rpm/AppImage на Linux, .app/.dmg на macOS. Кросс-компиляция между ними не
|
|
||||||
поддерживается — релизный workflow собирает каждую платформу на своём раннере.
|
|
||||||
|
|
||||||
Стек: [Tauri 2](https://tauri.app) + TypeScript + CodeMirror 6.
|
Стек: [Tauri 2](https://tauri.app) + TypeScript + CodeMirror 6.
|
||||||
|
|
||||||
## Благодарность
|
## Благодарность
|
||||||
|
|
||||||
Вдохновлён [Numi](https://numi.app) от Дмитрия Николаева —
|
Вдохновлён [Numi](https://numi.app) от Дмитрия Николаева —
|
||||||
если ты на macOS, купи его. Summarum — независимый проект, написанный с нуля для Windows, Linux и macOS.
|
если ты на macOS, купи его. Summarum — независимый проект, написанный с нуля для Windows.
|
||||||
|
|
||||||
## Лицензия
|
## Лицензия
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div id="statusbar">
|
<div id="statusbar">
|
||||||
<span id="rates-info" title=""></span>
|
<span id="rates-info" title=""></span>
|
||||||
<span id="market-info" title="" class="d-none"></span>
|
<span id="market-info" title="" style="display:none"></span>
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
<div id="export-wrap">
|
<div id="export-wrap">
|
||||||
<button id="export-btn" title="Export" aria-label="Export">⤓</button>
|
<button id="export-btn" title="Export" aria-label="Export">⤓</button>
|
||||||
|
|
|
||||||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "summarum",
|
"name": "summarum",
|
||||||
"version": "1.2.9",
|
"version": "1.2.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "summarum",
|
"name": "summarum",
|
||||||
"version": "1.2.9",
|
"version": "1.2.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/autocomplete": "^6.18.0",
|
"@codemirror/autocomplete": "^6.18.0",
|
||||||
"@codemirror/commands": "^6.8.0",
|
"@codemirror/commands": "^6.8.0",
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
{
|
{
|
||||||
"name": "summarum",
|
"name": "summarum",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.2.9",
|
"version": "1.2.5",
|
||||||
"description": "Summarum — natural language calculator notebook",
|
"description": "Summarum — natural language calculator notebook for Windows",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|
|
||||||
|
|
@ -113,7 +113,7 @@ function draw(size: number): Buffer {
|
||||||
|
|
||||||
fs.mkdirSync(outDir, { recursive: true });
|
fs.mkdirSync(outDir, { recursive: true });
|
||||||
const pngs = new Map<number, Buffer>();
|
const pngs = new Map<number, Buffer>();
|
||||||
for (const size of [16, 32, 48, 64, 128, 256, 512, 1024]) {
|
for (const size of [16, 32, 48, 64, 128, 256, 512]) {
|
||||||
pngs.set(size, encodePng(size, draw(size)));
|
pngs.set(size, encodePng(size, draw(size)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -154,38 +154,3 @@ function buildIco(sizes: number[]): Buffer {
|
||||||
|
|
||||||
fs.writeFileSync(path.join(outDir, "icon.ico"), buildIco([16, 32, 48, 64, 128, 256]));
|
fs.writeFileSync(path.join(outDir, "icon.ico"), buildIco([16, 32, 48, 64, 128, 256]));
|
||||||
console.log("icon.ico (6 sizes)");
|
console.log("icon.ico (6 sizes)");
|
||||||
|
|
||||||
// .icns: "icns" magic + total length, then [OSType][length incl. header][PNG].
|
|
||||||
// Both lengths are big-endian and count their own 8-byte header. The tag set
|
|
||||||
// mirrors what `iconutil` emits for a modern .iconset — every ic*/icp* entry
|
|
||||||
// below carries a plain PNG payload (10.7+), so no JPEG 2000 encoder is needed.
|
|
||||||
const ICNS_ENTRIES: Array<[string, number]> = [
|
|
||||||
["icp4", 16],
|
|
||||||
["icp5", 32],
|
|
||||||
["ic11", 32], // 16@2x
|
|
||||||
["ic12", 64], // 32@2x
|
|
||||||
["ic07", 128],
|
|
||||||
["ic13", 256], // 128@2x
|
|
||||||
["ic08", 256],
|
|
||||||
["ic14", 512], // 256@2x
|
|
||||||
["ic09", 512],
|
|
||||||
["ic10", 1024], // 512@2x
|
|
||||||
];
|
|
||||||
|
|
||||||
function buildIcns(): Buffer {
|
|
||||||
const parts = ICNS_ENTRIES.map(([tag, size]) => {
|
|
||||||
const data = pngs.get(size)!;
|
|
||||||
const head = Buffer.alloc(8);
|
|
||||||
head.write(tag, 0, 4, "ascii");
|
|
||||||
head.writeUInt32BE(8 + data.length, 4);
|
|
||||||
return Buffer.concat([head, data]);
|
|
||||||
});
|
|
||||||
const body = Buffer.concat(parts);
|
|
||||||
const header = Buffer.alloc(8);
|
|
||||||
header.write("icns", 0, 4, "ascii");
|
|
||||||
header.writeUInt32BE(8 + body.length, 4);
|
|
||||||
return Buffer.concat([header, body]);
|
|
||||||
}
|
|
||||||
|
|
||||||
fs.writeFileSync(path.join(outDir, "icon.icns"), buildIcns());
|
|
||||||
console.log(`icon.icns (${ICNS_ENTRIES.length} entries)`);
|
|
||||||
|
|
|
||||||
20
src-tauri/Cargo.lock
generated
20
src-tauri/Cargo.lock
generated
|
|
@ -3038,7 +3038,6 @@ dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-util",
|
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
|
|
@ -3058,14 +3057,12 @@ dependencies = [
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
"tokio-util",
|
|
||||||
"tower",
|
"tower",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"url",
|
"url",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
"wasm-streams 0.4.2",
|
|
||||||
"web-sys",
|
"web-sys",
|
||||||
"webpki-roots",
|
"webpki-roots",
|
||||||
]
|
]
|
||||||
|
|
@ -3105,7 +3102,7 @@ dependencies = [
|
||||||
"url",
|
"url",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
"wasm-streams 0.5.0",
|
"wasm-streams",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -3727,7 +3724,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "summarum"
|
name = "summarum"
|
||||||
version = "1.2.9"
|
version = "1.2.5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"futures",
|
"futures",
|
||||||
|
|
@ -4927,19 +4924,6 @@ dependencies = [
|
||||||
"wasmparser",
|
"wasmparser",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wasm-streams"
|
|
||||||
version = "0.4.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
|
|
||||||
dependencies = [
|
|
||||||
"futures-util",
|
|
||||||
"js-sys",
|
|
||||||
"wasm-bindgen",
|
|
||||||
"wasm-bindgen-futures",
|
|
||||||
"web-sys",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-streams"
|
name = "wasm-streams"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
[package]
|
[package]
|
||||||
name = "summarum"
|
name = "summarum"
|
||||||
version = "1.2.9"
|
version = "1.2.5"
|
||||||
description = "Summarum — natural language calculator notebook"
|
description = "Summarum — natural language calculator notebook for Windows"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
|
|
@ -18,7 +18,7 @@ tauri-plugin-updater = "2"
|
||||||
tauri-plugin-process = "2"
|
tauri-plugin-process = "2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
futures = "0.3.32"
|
futures = "0.3.32"
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -1,9 +1,6 @@
|
||||||
// Summarum — Tauri shell: tray, hide-to-tray window, app-data storage,
|
// Summarum — Tauri shell: tray, hide-to-tray window, app-data storage,
|
||||||
// currency rates fetching with an on-disk cache, extension script loading.
|
// currency rates fetching with an on-disk cache, extension script loading.
|
||||||
#![cfg_attr(
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
all(not(debug_assertions), target_os = "windows"),
|
|
||||||
windows_subsystem = "windows"
|
|
||||||
)]
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
@ -46,25 +43,7 @@ struct DataDirGate {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn canon_or_raw(p: &Path) -> PathBuf {
|
fn canon_or_raw(p: &Path) -> PathBuf {
|
||||||
let mut normalized = PathBuf::new();
|
fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
|
||||||
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
|
/// Exact match against an authorized folder, compared canonicalized so a
|
||||||
|
|
@ -216,9 +195,6 @@ fn base64_decode(s: &str) -> Result<Vec<u8>, String> {
|
||||||
})
|
})
|
||||||
.collect::<Result<_, _>>()?;
|
.collect::<Result<_, _>>()?;
|
||||||
for chunk in bytes.chunks(4) {
|
for chunk in bytes.chunks(4) {
|
||||||
if chunk.len() < 2 {
|
|
||||||
return Err("invalid base64 padding".into());
|
|
||||||
}
|
|
||||||
let b0 = chunk[0];
|
let b0 = chunk[0];
|
||||||
let b1 = *chunk.get(1).unwrap_or(&0);
|
let b1 = *chunk.get(1).unwrap_or(&0);
|
||||||
let b2 = *chunk.get(2).unwrap_or(&0);
|
let b2 = *chunk.get(2).unwrap_or(&0);
|
||||||
|
|
@ -234,12 +210,9 @@ fn base64_decode(s: &str) -> Result<Vec<u8>, String> {
|
||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
static TMP_COUNTER: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
|
||||||
|
|
||||||
/// write via a temp file + rename so a crash mid-write cannot corrupt the target
|
/// write via a temp file + rename so a crash mid-write cannot corrupt the target
|
||||||
fn write_atomic_bytes(path: &Path, contents: &[u8]) -> Result<(), String> {
|
fn write_atomic_bytes(path: &Path, contents: &[u8]) -> Result<(), String> {
|
||||||
let count = TMP_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
let tmp = path.with_extension("tmp");
|
||||||
let tmp = path.with_extension(format!("tmp.{}", count));
|
|
||||||
fs::write(&tmp, contents).map_err(|e| e.to_string())?;
|
fs::write(&tmp, contents).map_err(|e| e.to_string())?;
|
||||||
fs::rename(&tmp, path).map_err(|e| e.to_string())
|
fs::rename(&tmp, path).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
@ -338,24 +311,6 @@ async fn read_rates_cache(app: &AppHandle) -> Option<RatesCache> {
|
||||||
serde_json::from_str(&raw).ok()
|
serde_json::from_str(&raw).ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
const RATES_RESPONSE_LIMIT: usize = 5 * 1024 * 1024;
|
|
||||||
|
|
||||||
/// Reads the body as a stream and aborts as soon as it exceeds `limit`,
|
|
||||||
/// instead of buffering an unbounded body in memory before checking its size.
|
|
||||||
async fn read_body_capped(resp: reqwest::Response, limit: usize) -> Result<Vec<u8>, String> {
|
|
||||||
use futures::StreamExt;
|
|
||||||
let mut stream = resp.bytes_stream();
|
|
||||||
let mut buf = Vec::new();
|
|
||||||
while let Some(chunk) = stream.next().await {
|
|
||||||
let chunk = chunk.map_err(|e| e.to_string())?;
|
|
||||||
buf.extend_from_slice(&chunk);
|
|
||||||
if buf.len() > limit {
|
|
||||||
return Err("response too large".into());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(buf)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn fetch_rates_online() -> Result<RatesPayload, String> {
|
async fn fetch_rates_online() -> Result<RatesPayload, String> {
|
||||||
let client = HTTP_CLIENT.get_or_init(|| {
|
let client = HTTP_CLIENT.get_or_init(|| {
|
||||||
reqwest::Client::builder()
|
reqwest::Client::builder()
|
||||||
|
|
@ -364,15 +319,15 @@ async fn fetch_rates_online() -> Result<RatesPayload, String> {
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
});
|
});
|
||||||
|
|
||||||
let resp = client
|
let body: serde_json::Value = client
|
||||||
.get("https://open.er-api.com/v6/latest/USD")
|
.get("https://open.er-api.com/v6/latest/USD")
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
let bytes = read_body_capped(resp, RATES_RESPONSE_LIMIT).await?;
|
|
||||||
let body: serde_json::Value = serde_json::from_slice(&bytes).map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
if body["result"] != "success" {
|
if body["result"] != "success" {
|
||||||
return Err("rates api error".into());
|
return Err("rates api error".into());
|
||||||
}
|
}
|
||||||
|
|
@ -385,13 +340,11 @@ async fn fetch_rates_online() -> Result<RatesPayload, String> {
|
||||||
ids.join(",")
|
ids.join(",")
|
||||||
);
|
);
|
||||||
if let Ok(resp) = client.get(&url).send().await {
|
if let Ok(resp) = client.get(&url).send().await {
|
||||||
if let Ok(bytes) = read_body_capped(resp, RATES_RESPONSE_LIMIT).await {
|
if let Ok(json) = resp.json::<serde_json::Value>().await {
|
||||||
if let Ok(json) = serde_json::from_slice::<serde_json::Value>(&bytes) {
|
for (code, id) in CRYPTO {
|
||||||
for (code, id) in CRYPTO {
|
if let Some(price) = json[id]["usd"].as_f64() {
|
||||||
if let Some(price) = json[id]["usd"].as_f64() {
|
if price > 0.0 {
|
||||||
if price > 0.0 {
|
rates.insert((*code).into(), serde_json::json!(1.0 / price));
|
||||||
rates.insert((*code).into(), serde_json::json!(1.0 / price));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -583,10 +536,14 @@ async fn fetch_historical_rates(
|
||||||
// api.frankfurter.app 301-redirects here now, and the old domain is
|
// api.frankfurter.app 301-redirects here now, and the old domain is
|
||||||
// flaky over reqwest/rustls-tls — go straight to the stable host
|
// flaky over reqwest/rustls-tls — go straight to the stable host
|
||||||
let url = format!("https://api.frankfurter.dev/v1/{}?from=USD", date);
|
let url = format!("https://api.frankfurter.dev/v1/{}?from=USD", date);
|
||||||
let resp = client.get(&url).send().await.map_err(|e| e.to_string())?;
|
let body: serde_json::Value = client
|
||||||
|
.get(&url)
|
||||||
let bytes = read_body_capped(resp, RATES_RESPONSE_LIMIT).await?;
|
.send()
|
||||||
let body: serde_json::Value = serde_json::from_slice(&bytes).map_err(|e| e.to_string())?;
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
let rates = parse_rate_map(body["rates"].as_object().ok_or("no rates in response")?);
|
let rates = parse_rate_map(body["rates"].as_object().ok_or("no rates in response")?);
|
||||||
if let Ok(serialized) = serde_json::to_string(&rates) {
|
if let Ok(serialized) = serde_json::to_string(&rates) {
|
||||||
write_atomic_async(cache_path, serialized).await;
|
write_atomic_async(cache_path, serialized).await;
|
||||||
|
|
@ -638,17 +595,14 @@ async fn fetch_historical_rates_batch(
|
||||||
futures.push(async move {
|
futures.push(async move {
|
||||||
let mut rate_map = None;
|
let mut rate_map = None;
|
||||||
if let Ok(resp) = client_clone.get(&url).send().await {
|
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) = resp.json::<serde_json::Value>().await {
|
||||||
if let Ok(body) = serde_json::from_slice::<serde_json::Value>(&bytes) {
|
if let Some(rates_obj) = body["rates"].as_object() {
|
||||||
if let Some(rates_obj) = body["rates"].as_object() {
|
let daily_rates = parse_rate_map(rates_obj);
|
||||||
let daily_rates = parse_rate_map(rates_obj);
|
let cache_path = data_dir(&app_clone).join(format!("rates-{}.json", date));
|
||||||
let cache_path =
|
if let Ok(serialized) = serde_json::to_string(&daily_rates) {
|
||||||
data_dir(&app_clone).join(format!("rates-{}.json", date));
|
write_atomic_async(cache_path, serialized).await;
|
||||||
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 = Some((date, daily_rates));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -817,14 +771,17 @@ fn migrate_data_dir(
|
||||||
let canon_new = fs::canonicalize(&new).map_err(|e| e.to_string())?;
|
let canon_new = fs::canonicalize(&new).map_err(|e| e.to_string())?;
|
||||||
let gate = app.state::<DataDirGate>();
|
let gate = app.state::<DataDirGate>();
|
||||||
{
|
{
|
||||||
let picked = gate.picked.lock().unwrap();
|
let mut picked = gate.picked.lock().unwrap();
|
||||||
if picked.as_deref() != Some(canon_new.as_path()) {
|
if picked.as_deref() != Some(canon_new.as_path()) {
|
||||||
return Err("target folder was not chosen by the user".into());
|
return Err("target folder was not chosen by the user".into());
|
||||||
}
|
}
|
||||||
|
*picked = None;
|
||||||
}
|
}
|
||||||
|
// the target is usable from here on; the source stays usable too until the
|
||||||
|
// move is known to have finished
|
||||||
|
authorize_data_dir(&app, &canon_new);
|
||||||
let canon_old = fs::canonicalize(&old).unwrap_or_else(|_| old.clone());
|
let canon_old = fs::canonicalize(&old).unwrap_or_else(|_| old.clone());
|
||||||
if canon_old == canon_new || strategy == "use_existing" {
|
if canon_old == canon_new || strategy == "use_existing" {
|
||||||
gate.picked.lock().unwrap().take();
|
|
||||||
return commit_data_dir(&app, &canon_new);
|
return commit_data_dir(&app, &canon_new);
|
||||||
}
|
}
|
||||||
let old_docs = old.join("documents.json");
|
let old_docs = old.join("documents.json");
|
||||||
|
|
@ -847,7 +804,6 @@ fn migrate_data_dir(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
gate.picked.lock().unwrap().take();
|
|
||||||
// originals removed only after everything is actually copied
|
// originals removed only after everything is actually copied
|
||||||
if old_docs.exists() {
|
if old_docs.exists() {
|
||||||
fs::remove_file(&old_docs).ok();
|
fs::remove_file(&old_docs).ok();
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Summarum",
|
"productName": "Summarum",
|
||||||
"version": "1.2.9",
|
"version": "1.2.5",
|
||||||
"identifier": "app.summarum.calc",
|
"identifier": "app.summarum.calc",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
|
|
@ -23,19 +23,19 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
"csp": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self'; img-src 'self' data:; font-src 'self' data:; connect-src ipc: http://ipc.localhost https://open.er-api.com https://api.coingecko.com https://query1.finance.yahoo.com https://query2.finance.yahoo.com https://api.frankfurter.dev"
|
"csp": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src ipc: http://ipc.localhost https://open.er-api.com https://api.coingecko.com https://query1.finance.yahoo.com https://query2.finance.yahoo.com https://api.frankfurter.dev"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
|
"targets": ["nsis"],
|
||||||
"createUpdaterArtifacts": true,
|
"createUpdaterArtifacts": true,
|
||||||
"icon": [
|
"icon": [
|
||||||
"icons/32x32.png",
|
"icons/32x32.png",
|
||||||
"icons/128x128.png",
|
"icons/128x128.png",
|
||||||
"icons/128x128@2x.png",
|
"icons/128x128@2x.png",
|
||||||
"icons/icon.png",
|
"icons/icon.png",
|
||||||
"icons/icon.ico",
|
"icons/icon.ico"
|
||||||
"icons/icon.icns"
|
|
||||||
],
|
],
|
||||||
"fileAssociations": [
|
"fileAssociations": [
|
||||||
{
|
{
|
||||||
|
|
@ -44,7 +44,13 @@
|
||||||
"description": "Summarum calculation sheet",
|
"description": "Summarum calculation sheet",
|
||||||
"role": "Editor"
|
"role": "Editor"
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"windows": {
|
||||||
|
"nsis": {
|
||||||
|
"installMode": "currentUser",
|
||||||
|
"languages": ["English", "Russian"]
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"plugins": {
|
"plugins": {
|
||||||
"updater": {
|
"updater": {
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -13,10 +13,6 @@ function calc(expr: string): string | null {
|
||||||
return r[r.length - 1].text;
|
return r[r.length - 1].text;
|
||||||
}
|
}
|
||||||
|
|
||||||
function evaluateText(doc: string): (string | null)[] {
|
|
||||||
return eng.evaluateDocument(doc).map((x) => x.text);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("edge cases", () => {
|
describe("edge cases", () => {
|
||||||
it("russian mixed sentence", () => {
|
it("russian mixed sentence", () => {
|
||||||
expect(calc("потратил 20$ и ещё 5$")).toBe("$25");
|
expect(calc("потратил 20$ и ещё 5$")).toBe("$25");
|
||||||
|
|
@ -49,11 +45,11 @@ describe("edge cases", () => {
|
||||||
expect(calc("hello world")).toBe(null);
|
expect(calc("hello world")).toBe(null);
|
||||||
});
|
});
|
||||||
it("sum stops at header", () => {
|
it("sum stops at header", () => {
|
||||||
const r = evaluateText("5\n# section\n10\n20\nsum");
|
const r = eng.evaluateDocument("5\n# section\n10\n20\nsum").map((x) => x.text);
|
||||||
expect(r).toEqual(["5", null, "10", "20", "30"]);
|
expect(r).toEqual(["5", null, "10", "20", "30"]);
|
||||||
});
|
});
|
||||||
it("variables with units survive math", () => {
|
it("variables with units survive math", () => {
|
||||||
const r = evaluateText("rent = $500\nfood = $200\nrent + food");
|
const r = eng.evaluateDocument("rent = $500\nfood = $200\nrent + food").map((x) => x.text);
|
||||||
expect(r).toEqual(["$500", "$200", "$700"]);
|
expect(r).toEqual(["$500", "$200", "$700"]);
|
||||||
});
|
});
|
||||||
it("big numbers with grouping", () => {
|
it("big numbers with grouping", () => {
|
||||||
|
|
@ -149,7 +145,7 @@ describe("crypto", () => {
|
||||||
|
|
||||||
describe("regressions", () => {
|
describe("regressions", () => {
|
||||||
it("a broken line does not kill the document (BigInt from NaN)", () => {
|
it("a broken line does not kill the document (BigInt from NaN)", () => {
|
||||||
const r = evaluateText("2+2\nasin(5) & 3\n3+3");
|
const r = eng.evaluateDocument("2+2\nasin(5) & 3\n3+3").map((x) => x.text);
|
||||||
expect(r).toEqual(["4", null, "6"]);
|
expect(r).toEqual(["4", null, "6"]);
|
||||||
});
|
});
|
||||||
it("non-finite results are suppressed", () => {
|
it("non-finite results are suppressed", () => {
|
||||||
|
|
@ -182,8 +178,8 @@ describe("regressions", () => {
|
||||||
expect(calc("1,234 + 0")).toBe("1,234"); // exactly 3 digits = thousands
|
expect(calc("1,234 + 0")).toBe("1,234"); // exactly 3 digits = thousands
|
||||||
});
|
});
|
||||||
it("single-letter unit names can be variables", () => {
|
it("single-letter unit names can be variables", () => {
|
||||||
expect(evaluateText("m = 5\nm * 2")).toEqual(["5", "10"]);
|
expect(eng.evaluateDocument("m = 5\nm * 2").map((x) => x.text)).toEqual(["5", "10"]);
|
||||||
expect(evaluateText("t = 100\nt + 20%")).toEqual(["100", "120"]);
|
expect(eng.evaluateDocument("t = 100\nt + 20%").map((x) => x.text)).toEqual(["100", "120"]);
|
||||||
});
|
});
|
||||||
it("tiny conversion results show significant digits, not 0", () => {
|
it("tiny conversion results show significant digits, not 0", () => {
|
||||||
expect(calc("1 mm in km")).toBe("0.000001 km");
|
expect(calc("1 mm in km")).toBe("0.000001 km");
|
||||||
|
|
@ -203,7 +199,7 @@ describe("regressions", () => {
|
||||||
expect(calc("4 ^ 50%")).toBe("2");
|
expect(calc("4 ^ 50%")).toBe("2");
|
||||||
});
|
});
|
||||||
it("avg skips incompatible lines in the denominator", () => {
|
it("avg skips incompatible lines in the denominator", () => {
|
||||||
expect(evaluateText("10 kg\n20 kg\n5 hours\navg")).toEqual(["10 kg", "20 kg", "5 h", "15 kg"]);
|
expect(eng.evaluateDocument("10 kg\n20 kg\n5 hours\navg").map((x) => x.text)).toEqual(["10 kg", "20 kg", "5 h", "15 kg"]);
|
||||||
});
|
});
|
||||||
it("calendar month add clamps to month end", () => {
|
it("calendar month add clamps to month end", () => {
|
||||||
const doc = eng.evaluateDocument("today + 1 month");
|
const doc = eng.evaluateDocument("today + 1 month");
|
||||||
|
|
@ -290,7 +286,7 @@ describe("review fixes", () => {
|
||||||
describe("regression 2026-08-12", () => {
|
describe("regression 2026-08-12", () => {
|
||||||
it("a deeply nested-parens line nulls out just that line, not the whole document", () => {
|
it("a deeply nested-parens line nulls out just that line, not the whole document", () => {
|
||||||
const deep = "(".repeat(50000) + "1" + ")".repeat(50000);
|
const deep = "(".repeat(50000) + "1" + ")".repeat(50000);
|
||||||
const r = evaluateText(`1+1\n${deep}\n2+2`);
|
const r = eng.evaluateDocument(`1+1\n${deep}\n2+2`).map((x) => x.text);
|
||||||
expect(r[0]).toBe("2");
|
expect(r[0]).toBe("2");
|
||||||
expect(r[1]).toBeNull();
|
expect(r[1]).toBeNull();
|
||||||
expect(r[2]).toBe("4");
|
expect(r[2]).toBe("4");
|
||||||
|
|
@ -301,9 +297,9 @@ describe("regression 2026-08-12", () => {
|
||||||
expect(calc("5 мин")).toBe("5 min");
|
expect(calc("5 мин")).toBe("5 min");
|
||||||
});
|
});
|
||||||
it("standalone 'min'/'max' aggregate lines still work", () => {
|
it("standalone 'min'/'max' aggregate lines still work", () => {
|
||||||
const r = evaluateText("10\n20\n5\nmin");
|
const r = eng.evaluateDocument("10\n20\n5\nmin").map((x) => x.text);
|
||||||
expect(r).toEqual(["10", "20", "5", "5"]);
|
expect(r).toEqual(["10", "20", "5", "5"]);
|
||||||
const r2 = evaluateText("10\n20\n5\nmax");
|
const r2 = eng.evaluateDocument("10\n20\n5\nmax").map((x) => x.text);
|
||||||
expect(r2).toEqual(["10", "20", "5", "20"]);
|
expect(r2).toEqual(["10", "20", "5", "20"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -218,7 +218,7 @@ describe("numeral systems", () => {
|
||||||
describe("variables and document context", () => {
|
describe("variables and document context", () => {
|
||||||
it("sequence error recovery", () => {
|
it("sequence error recovery", () => {
|
||||||
eng.addFunction({ id: "throw_err", phrases: "throw_err" }, () => {
|
eng.addFunction({ id: "throw_err", phrases: "throw_err" }, () => {
|
||||||
throw new EvalError("test error");
|
throw new Error("test error");
|
||||||
});
|
});
|
||||||
expect(calc("throw_err(1) 2 hours")).toBe("2 h");
|
expect(calc("throw_err(1) 2 hours")).toBe("2 h");
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,9 @@
|
||||||
// Walks the AST with the document context (variables, line results for
|
// Walks the AST with the document context (variables, line results for
|
||||||
// sum/avg/prev) and produces unit-aware values.
|
// sum/avg/prev) and produces unit-aware values.
|
||||||
import { Decimal, DEC_ZERO, DEC_ONE, EvalError, PI, Quantity, Unit, Value, XRefError, qty, pct } from "./types";
|
import { Decimal, EvalError, PI, Quantity, Unit, Value, XRefError, qty, pct } from "./types";
|
||||||
import { Node, ConvTarget } from "./parser";
|
import { Node, ConvTarget } from "./parser";
|
||||||
import { Registry } from "./registry";
|
import { Registry } from "./registry";
|
||||||
import {
|
import { resolveZone, startOfToday, addToDate, isCalendarUnit } from "./datetime";
|
||||||
resolveZone,
|
|
||||||
startOfToday,
|
|
||||||
addToDate,
|
|
||||||
isCalendarUnit,
|
|
||||||
} from "./datetime";
|
|
||||||
|
|
||||||
export interface LineCtx {
|
export interface LineCtx {
|
||||||
/** values of lines above (null = no result) */
|
/** values of lines above (null = no result) */
|
||||||
|
|
@ -30,9 +25,7 @@ export interface EvalCtx {
|
||||||
resolveXRef?: XRefResolver;
|
resolveXRef?: XRefResolver;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type XRefResolution =
|
export type XRefResolution = { ok: true; value: Value } | { ok: false; reason: string };
|
||||||
| { ok: true; value: Value }
|
|
||||||
| { ok: false; reason: string };
|
|
||||||
export type XRefResolver = (sheet: string, key: string) => XRefResolution;
|
export type XRefResolver = (sheet: string, key: string) => XRefResolution;
|
||||||
|
|
||||||
const E = new Decimal("2.71828182845904523536028747135266249775724709369995");
|
const E = new Decimal("2.71828182845904523536028747135266249775724709369995");
|
||||||
|
|
@ -64,12 +57,7 @@ export function evaluate(node: Node, ctx: EvalCtx): Value {
|
||||||
case "curr":
|
case "curr":
|
||||||
return evaluateUnitMod(node, ctx);
|
return evaluateUnitMod(node, ctx);
|
||||||
case "bin":
|
case "bin":
|
||||||
return evalBin(
|
return evalBin(node.op, evaluate(node.l, ctx), evaluate(node.r, ctx), ctx);
|
||||||
node.op,
|
|
||||||
evaluate(node.l, ctx),
|
|
||||||
evaluate(node.r, ctx),
|
|
||||||
ctx,
|
|
||||||
);
|
|
||||||
case "bit":
|
case "bit":
|
||||||
return evalBit(node.op, evaluate(node.l, ctx), evaluate(node.r, ctx));
|
return evalBit(node.op, evaluate(node.l, ctx), evaluate(node.r, ctx));
|
||||||
case "pctop":
|
case "pctop":
|
||||||
|
|
@ -77,11 +65,7 @@ export function evaluate(node: Node, ctx: EvalCtx): Value {
|
||||||
case "conv":
|
case "conv":
|
||||||
return evalConv(evaluate(node.x, ctx), node.target, ctx);
|
return evalConv(evaluate(node.x, ctx), node.target, ctx);
|
||||||
case "call":
|
case "call":
|
||||||
return evalCall(
|
return evalCall(node.name, node.args.map((a) => evaluate(a, ctx)), ctx);
|
||||||
node.name,
|
|
||||||
node.args.map((a) => evaluate(a, ctx)),
|
|
||||||
ctx,
|
|
||||||
);
|
|
||||||
case "seq":
|
case "seq":
|
||||||
return evaluateSeq(node, ctx);
|
return evaluateSeq(node, ctx);
|
||||||
}
|
}
|
||||||
|
|
@ -128,8 +112,7 @@ export function fromBase(base: Decimal, unit: Unit): Decimal {
|
||||||
|
|
||||||
export function convertQ(q: Quantity, unit: Unit): Quantity {
|
export function convertQ(q: Quantity, unit: Unit): Quantity {
|
||||||
if (!q.unit) return { ...q, unit };
|
if (!q.unit) return { ...q, unit };
|
||||||
if (q.unit.dimension !== unit.dimension)
|
if (q.unit.dimension !== unit.dimension) throw new EvalError("dimension mismatch");
|
||||||
throw new EvalError("dimension mismatch");
|
|
||||||
return qty(fromBase(toBase(q), unit), unit);
|
return qty(fromBase(toBase(q), unit), unit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -139,52 +122,36 @@ function addQ(a: Quantity, b: Quantity): Quantity {
|
||||||
|
|
||||||
function numericAdd(a: Quantity, b: Quantity, sign: 1 | -1): Quantity {
|
function numericAdd(a: Quantity, b: Quantity, sign: 1 | -1): Quantity {
|
||||||
if (a.unit && b.unit) {
|
if (a.unit && b.unit) {
|
||||||
if (a.unit.dimension !== b.unit.dimension)
|
if (a.unit.dimension !== b.unit.dimension) throw new EvalError("dimension mismatch");
|
||||||
throw new EvalError("dimension mismatch");
|
|
||||||
if (a.unit.offset || b.unit.offset) {
|
if (a.unit.offset || b.unit.offset) {
|
||||||
// temperatures: operate in the left unit's scale
|
// temperatures: operate in the left unit's scale
|
||||||
const bInA = convertQ(b, a.unit);
|
const bInA = convertQ(b, a.unit);
|
||||||
return qty(
|
return qty(sign === 1 ? a.value.add(bInA.value) : a.value.sub(bInA.value), a.unit);
|
||||||
sign === 1 ? a.value.add(bInA.value) : a.value.sub(bInA.value),
|
|
||||||
a.unit,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
const base =
|
const base = sign === 1 ? toBase(a).add(toBase(b)) : toBase(a).sub(toBase(b));
|
||||||
sign === 1 ? toBase(a).add(toBase(b)) : toBase(a).sub(toBase(b));
|
|
||||||
return qty(fromBase(base, a.unit), a.unit);
|
return qty(fromBase(base, a.unit), a.unit);
|
||||||
}
|
}
|
||||||
const unit = a.unit ?? b.unit ?? null;
|
const unit = a.unit ?? b.unit ?? null;
|
||||||
return qty(sign === 1 ? a.value.add(b.value) : a.value.sub(b.value), unit);
|
return qty(sign === 1 ? a.value.add(b.value) : a.value.sub(b.value), unit);
|
||||||
}
|
}
|
||||||
|
|
||||||
function evalPercentArith(
|
function evalPercentArith(op: "plus" | "minus" | "mul" | "div" | "mod" | "pow", l: Value, r: Value): Value {
|
||||||
op: "plus" | "minus" | "mul" | "div" | "mod" | "pow",
|
|
||||||
l: Value,
|
|
||||||
r: Value,
|
|
||||||
): Value {
|
|
||||||
if (l.kind === "percent" && r.kind === "percent") {
|
if (l.kind === "percent" && r.kind === "percent") {
|
||||||
switch (op) {
|
switch (op) {
|
||||||
case "plus":
|
case "plus": return pct(l.value.add(r.value));
|
||||||
return pct(l.value.add(r.value));
|
case "minus": return pct(l.value.sub(r.value));
|
||||||
case "minus":
|
case "mul": return pct(l.value.mul(r.value).div(100));
|
||||||
return pct(l.value.sub(r.value));
|
case "div": return qty(l.value.div(r.value));
|
||||||
case "mul":
|
default: throw new EvalError("bad percent op");
|
||||||
return pct(l.value.mul(r.value).div(100));
|
|
||||||
case "div":
|
|
||||||
return qty(l.value.div(r.value));
|
|
||||||
default:
|
|
||||||
throw new EvalError("bad percent op");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const q = (l.kind === "quantity" ? l : r) as Quantity;
|
const q = (l.kind === "quantity" ? l : r) as Quantity;
|
||||||
const p = (
|
const p = (l.kind === "percent" ? l : r as { kind: "percent"; value: Decimal }).value;
|
||||||
l.kind === "percent" ? l : (r as { kind: "percent"; value: Decimal })
|
|
||||||
).value;
|
|
||||||
switch (op) {
|
switch (op) {
|
||||||
case "plus": return qty(q.value.mul(DEC_ONE.add(p.div(100))), q.unit);
|
case "plus": return qty(q.value.mul(new Decimal(1).add(p.div(100))), q.unit);
|
||||||
case "minus":
|
case "minus":
|
||||||
if (l.kind === "percent") throw new EvalError("percent minus number");
|
if (l.kind === "percent") throw new EvalError("percent minus number");
|
||||||
return qty(q.value.mul(DEC_ONE.sub(p.div(100))), q.unit);
|
return qty(q.value.mul(new Decimal(1).sub(p.div(100))), q.unit);
|
||||||
case "mul": return qty(q.value.mul(p).div(100), q.unit);
|
case "mul": return qty(q.value.mul(p).div(100), q.unit);
|
||||||
case "div":
|
case "div":
|
||||||
if (l.kind === "percent") throw new EvalError("percent div number");
|
if (l.kind === "percent") throw new EvalError("percent div number");
|
||||||
|
|
@ -194,22 +161,14 @@ function evalPercentArith(
|
||||||
return l.kind === "percent"
|
return l.kind === "percent"
|
||||||
? qty(p.div(100).pow(q.value))
|
? qty(p.div(100).pow(q.value))
|
||||||
: qty(q.value.pow(p.div(100)), q.unit);
|
: qty(q.value.pow(p.div(100)), q.unit);
|
||||||
default:
|
default: throw new EvalError("bad percent op");
|
||||||
throw new EvalError("bad percent op");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function evalQuantityArith(
|
function evalQuantityArith(op: "plus" | "minus" | "mul" | "div" | "mod" | "pow", a: Quantity, b: Quantity, ctx: EvalCtx): Value {
|
||||||
op: "plus" | "minus" | "mul" | "div" | "mod" | "pow",
|
|
||||||
a: Quantity,
|
|
||||||
b: Quantity,
|
|
||||||
ctx: EvalCtx,
|
|
||||||
): Value {
|
|
||||||
switch (op) {
|
switch (op) {
|
||||||
case "plus":
|
case "plus": return numericAdd(a, b, 1);
|
||||||
return numericAdd(a, b, 1);
|
case "minus": return numericAdd(a, b, -1);
|
||||||
case "minus":
|
|
||||||
return numericAdd(a, b, -1);
|
|
||||||
case "mul": {
|
case "mul": {
|
||||||
if (a.unit && b.unit) {
|
if (a.unit && b.unit) {
|
||||||
const da = a.unit.dimension;
|
const da = a.unit.dimension;
|
||||||
|
|
@ -224,8 +183,7 @@ function evalQuantityArith(
|
||||||
if (u) return qty(baseProduct.div(u.ratio), u);
|
if (u) return qty(baseProduct.div(u.ratio), u);
|
||||||
}
|
}
|
||||||
// same dimension: "2 kg * 500 g" scales in the left unit, not 2*500
|
// same dimension: "2 kg * 500 g" scales in the left unit, not 2*500
|
||||||
if (da === db)
|
if (da === db) return qty(a.value.mul(convertQ(b, a.unit).value), a.unit);
|
||||||
return qty(a.value.mul(convertQ(b, a.unit).value), a.unit);
|
|
||||||
return qty(a.value.mul(b.value), a.unit);
|
return qty(a.value.mul(b.value), a.unit);
|
||||||
}
|
}
|
||||||
return qty(a.value.mul(b.value), a.unit ?? b.unit ?? null);
|
return qty(a.value.mul(b.value), a.unit ?? b.unit ?? null);
|
||||||
|
|
@ -244,28 +202,16 @@ function evalQuantityArith(
|
||||||
case "pow": {
|
case "pow": {
|
||||||
const result = a.value.pow(b.value);
|
const result = a.value.pow(b.value);
|
||||||
if (a.unit?.dimension === "length" && !b.unit) {
|
if (a.unit?.dimension === "length" && !b.unit) {
|
||||||
const id = b.value.eq(2)
|
const id = b.value.eq(2) ? `sq:${a.unit.id}` : b.value.eq(3) ? `cb:${a.unit.id}` : null;
|
||||||
? `sq:${a.unit.id}`
|
|
||||||
: b.value.eq(3)
|
|
||||||
? `cb:${a.unit.id}`
|
|
||||||
: null;
|
|
||||||
const u = id ? ctx.reg.unitsById.get(id) : null;
|
const u = id ? ctx.reg.unitsById.get(id) : null;
|
||||||
if (u) return qty(result, u);
|
if (u) return qty(result, u);
|
||||||
}
|
}
|
||||||
return qty(
|
return qty(result, b.unit ? null : a.unit && b.value.eq(1) ? a.unit : null);
|
||||||
result,
|
|
||||||
b.unit ? null : a.unit && b.value.eq(1) ? a.unit : null,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function evalBin(
|
function evalBin(op: "plus" | "minus" | "mul" | "div" | "mod" | "pow", l: Value, r: Value, ctx: EvalCtx): Value {
|
||||||
op: "plus" | "minus" | "mul" | "div" | "mod" | "pow",
|
|
||||||
l: Value,
|
|
||||||
r: Value,
|
|
||||||
ctx: EvalCtx,
|
|
||||||
): Value {
|
|
||||||
if (l.kind === "date" || r.kind === "date") {
|
if (l.kind === "date" || r.kind === "date") {
|
||||||
return evalDateArith(op, l, r);
|
return evalDateArith(op, l, r);
|
||||||
}
|
}
|
||||||
|
|
@ -278,33 +224,19 @@ function evalBin(
|
||||||
}
|
}
|
||||||
|
|
||||||
function evalBit(op: string, l: Value, r: Value): Value {
|
function evalBit(op: string, l: Value, r: Value): Value {
|
||||||
if (l.kind !== "quantity" || r.kind !== "quantity")
|
if (l.kind !== "quantity" || r.kind !== "quantity") throw new EvalError("bitwise needs integers");
|
||||||
throw new EvalError("bitwise needs integers");
|
if (!l.value.isFinite() || !r.value.isFinite()) throw new EvalError("bitwise needs finite integers");
|
||||||
if (!l.value.isFinite() || !r.value.isFinite())
|
|
||||||
throw new EvalError("bitwise needs finite integers");
|
|
||||||
const a = BigInt(l.value.toDecimalPlaces(0).toFixed(0));
|
const a = BigInt(l.value.toDecimalPlaces(0).toFixed(0));
|
||||||
const b = BigInt(r.value.toDecimalPlaces(0).toFixed(0));
|
const b = BigInt(r.value.toDecimalPlaces(0).toFixed(0));
|
||||||
if ((op === "shl" || op === "shr") && (b < 0n || b > 256n))
|
if ((op === "shl" || op === "shr") && (b < 0n || b > 256n)) throw new EvalError("bad shift");
|
||||||
throw new EvalError("bad shift");
|
|
||||||
let out: bigint;
|
let out: bigint;
|
||||||
switch (op) {
|
switch (op) {
|
||||||
case "band":
|
case "band": out = a & b; break;
|
||||||
out = a & b;
|
case "bor": out = a | b; break;
|
||||||
break;
|
case "bxor": out = a ^ b; break;
|
||||||
case "bor":
|
case "shl": out = a << b; break;
|
||||||
out = a | b;
|
case "shr": out = a >> b; break;
|
||||||
break;
|
default: throw new EvalError("bad bit op");
|
||||||
case "bxor":
|
|
||||||
out = a ^ b;
|
|
||||||
break;
|
|
||||||
case "shl":
|
|
||||||
out = a << b;
|
|
||||||
break;
|
|
||||||
case "shr":
|
|
||||||
out = a >> b;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
throw new EvalError("bad bit op");
|
|
||||||
}
|
}
|
||||||
const repr = l.repr !== "decimal" ? l.repr : r.repr;
|
const repr = l.repr !== "decimal" ? l.repr : r.repr;
|
||||||
return qty(new Decimal(out.toString()), null, repr);
|
return qty(new Decimal(out.toString()), null, repr);
|
||||||
|
|
@ -325,8 +257,8 @@ function evalPctOp(op: string, l: Value, r: Value): Value {
|
||||||
const p = asPercentValue(l);
|
const p = asPercentValue(l);
|
||||||
const factor =
|
const factor =
|
||||||
op === "of" ? p.div(100)
|
op === "of" ? p.div(100)
|
||||||
: op === "off" ? DEC_ONE.sub(p.div(100))
|
: op === "off" ? new Decimal(1).sub(p.div(100))
|
||||||
: DEC_ONE.add(p.div(100));
|
: new Decimal(1).add(p.div(100));
|
||||||
// a percent of a percent stays a percent: 50% of 50% = 25%
|
// a percent of a percent stays a percent: 50% of 50% = 25%
|
||||||
if (r.kind === "percent") return pct(r.value.mul(factor));
|
if (r.kind === "percent") return pct(r.value.mul(factor));
|
||||||
return qty(rv.mul(factor), unit);
|
return qty(rv.mul(factor), unit);
|
||||||
|
|
@ -336,21 +268,20 @@ function evalPctOp(op: string, l: Value, r: Value): Value {
|
||||||
if (rv.isZero()) throw new EvalError("division by zero");
|
if (rv.isZero()) throw new EvalError("division by zero");
|
||||||
switch (op) {
|
switch (op) {
|
||||||
case "as_pct_of": return pct(lv.div(rv).mul(100));
|
case "as_pct_of": return pct(lv.div(rv).mul(100));
|
||||||
case "as_pct_off": return pct(DEC_ONE.sub(lv.div(rv)).mul(100));
|
case "as_pct_off": return pct(new Decimal(1).sub(lv.div(rv)).mul(100));
|
||||||
case "as_pct_on": return pct(lv.div(rv).sub(1).mul(100));
|
case "as_pct_on": return pct(lv.div(rv).sub(1).mul(100));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// "20% of what is 30"
|
// "20% of what is 30"
|
||||||
const p = asPercentValue(l);
|
const p = asPercentValue(l);
|
||||||
switch (op) {
|
switch (op) {
|
||||||
case "of_what_is":
|
case "of_what_is": return qty(rv.div(p.div(100)), unit);
|
||||||
return qty(rv.div(p.div(100)), unit);
|
|
||||||
case "off_what_is": {
|
case "off_what_is": {
|
||||||
const d = DEC_ONE.sub(p.div(100));
|
const d = new Decimal(1).sub(p.div(100));
|
||||||
if (d.isZero()) throw new EvalError("division by zero");
|
if (d.isZero()) throw new EvalError("division by zero");
|
||||||
return qty(rv.div(d), unit);
|
return qty(rv.div(d), unit);
|
||||||
}
|
}
|
||||||
case "on_what_is": return qty(rv.div(DEC_ONE.add(p.div(100))), unit);
|
case "on_what_is": return qty(rv.div(new Decimal(1).add(p.div(100))), unit);
|
||||||
}
|
}
|
||||||
throw new EvalError(`bad percent op ${op}`);
|
throw new EvalError(`bad percent op ${op}`);
|
||||||
}
|
}
|
||||||
|
|
@ -359,8 +290,7 @@ function evalConv(v: Value, target: ConvTarget, ctx: EvalCtx): Value {
|
||||||
switch (target.type) {
|
switch (target.type) {
|
||||||
case "unit": {
|
case "unit": {
|
||||||
if (v.kind === "percent") return pct(v.value); // "x in %" — keep
|
if (v.kind === "percent") return pct(v.value); // "x in %" — keep
|
||||||
if (v.kind !== "quantity")
|
if (v.kind !== "quantity") throw new EvalError("cannot convert date to unit");
|
||||||
throw new EvalError("cannot convert date to unit");
|
|
||||||
if (!v.unit) return qty(v.value, target.unit);
|
if (!v.unit) return qty(v.value, target.unit);
|
||||||
return convertQ(v, target.unit);
|
return convertQ(v, target.unit);
|
||||||
}
|
}
|
||||||
|
|
@ -369,17 +299,13 @@ function evalConv(v: Value, target: ConvTarget, ctx: EvalCtx): Value {
|
||||||
if (target.onDate) {
|
if (target.onDate) {
|
||||||
const dateRates = ctx.historicalRates?.get(target.onDate);
|
const dateRates = ctx.historicalRates?.get(target.onDate);
|
||||||
const rate = dateRates?.get(target.code);
|
const rate = dateRates?.get(target.code);
|
||||||
if (rate === undefined)
|
if (rate === undefined) throw new EvalError(`no historical rate for ${target.code} on ${target.onDate}`);
|
||||||
throw new EvalError(
|
|
||||||
`no historical rate for ${target.code} on ${target.onDate}`,
|
|
||||||
);
|
|
||||||
unit = ctx.reg.makeCurrencyUnitFromRate(target.code, new Decimal(rate));
|
unit = ctx.reg.makeCurrencyUnitFromRate(target.code, new Decimal(rate));
|
||||||
} else {
|
} else {
|
||||||
unit = ctx.reg.makeCurrencyUnit(target.code);
|
unit = ctx.reg.makeCurrencyUnit(target.code);
|
||||||
if (!unit) throw new EvalError(`no rate for ${target.code}`);
|
if (!unit) throw new EvalError(`no rate for ${target.code}`);
|
||||||
}
|
}
|
||||||
if (v.kind !== "quantity")
|
if (v.kind !== "quantity") throw new EvalError("cannot convert to currency");
|
||||||
throw new EvalError("cannot convert to currency");
|
|
||||||
if (!v.unit) return qty(v.value, unit);
|
if (!v.unit) return qty(v.value, unit);
|
||||||
return convertQ(v, unit);
|
return convertQ(v, unit);
|
||||||
}
|
}
|
||||||
|
|
@ -398,17 +324,13 @@ function evalConv(v: Value, target: ConvTarget, ctx: EvalCtx): Value {
|
||||||
return { ...v, timeZone: zone };
|
return { ...v, timeZone: zone };
|
||||||
}
|
}
|
||||||
case "unix": {
|
case "unix": {
|
||||||
if (v.kind === "date")
|
if (v.kind === "date") return qty(new Decimal(v.ms).div(1000).floor(), null, "plain");
|
||||||
return qty(new Decimal(v.ms).div(1000).floor(), null, "plain");
|
|
||||||
throw new EvalError("unix needs a date");
|
throw new EvalError("unix needs a date");
|
||||||
}
|
}
|
||||||
case "todate": {
|
case "todate": {
|
||||||
if (v.kind !== "quantity" || v.unit)
|
if (v.kind !== "quantity" || v.unit) throw new EvalError("date needs a plain number");
|
||||||
throw new EvalError("date needs a plain number");
|
|
||||||
// heuristics: > 1e12 means milliseconds, else seconds
|
// heuristics: > 1e12 means milliseconds, else seconds
|
||||||
const ms = v.value.gt(1e12)
|
const ms = v.value.gt(1e12) ? v.value.toNumber() : v.value.mul(1000).toNumber();
|
||||||
? v.value.toNumber()
|
|
||||||
: v.value.mul(1000).toNumber();
|
|
||||||
return { kind: "date", ms, hasTime: true };
|
return { kind: "date", ms, hasTime: true };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -416,25 +338,12 @@ function evalConv(v: Value, target: ConvTarget, ctx: EvalCtx): Value {
|
||||||
|
|
||||||
function evalDateWord(word: string): Value {
|
function evalDateWord(word: string): Value {
|
||||||
switch (word) {
|
switch (word) {
|
||||||
case "today":
|
case "today": return { kind: "date", ms: startOfToday(), hasTime: false };
|
||||||
return { kind: "date", ms: startOfToday(), hasTime: false };
|
case "tomorrow": return { kind: "date", ms: startOfToday() + 86400_000, hasTime: false };
|
||||||
case "tomorrow": {
|
case "yesterday": return { kind: "date", ms: startOfToday() - 86400_000, hasTime: false };
|
||||||
const d = new Date(startOfToday());
|
case "now": return { kind: "date", ms: Date.now(), hasTime: true };
|
||||||
d.setDate(d.getDate() + 1);
|
case "time": case "local": return { kind: "date", ms: Date.now(), hasTime: true, timeOnly: true };
|
||||||
return { kind: "date", ms: d.getTime(), hasTime: false };
|
default: throw new EvalError(`bad date word ${word}`);
|
||||||
}
|
|
||||||
case "yesterday": {
|
|
||||||
const d = new Date(startOfToday());
|
|
||||||
d.setDate(d.getDate() - 1);
|
|
||||||
return { kind: "date", ms: d.getTime(), hasTime: false };
|
|
||||||
}
|
|
||||||
case "now":
|
|
||||||
return { kind: "date", ms: Date.now(), hasTime: true };
|
|
||||||
case "time":
|
|
||||||
case "local":
|
|
||||||
return { kind: "date", ms: Date.now(), hasTime: true, timeOnly: true };
|
|
||||||
default:
|
|
||||||
throw new EvalError(`bad date word ${word}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -442,16 +351,10 @@ function evalDateArith(op: string, l: Value, r: Value): Value {
|
||||||
if (l.kind === "date" && r.kind === "date") {
|
if (l.kind === "date" && r.kind === "date") {
|
||||||
if (op !== "minus") throw new EvalError("dates only subtract");
|
if (op !== "minus") throw new EvalError("dates only subtract");
|
||||||
// midnight-to-midnight differences count calendar days (robust across DST)
|
// midnight-to-midnight differences count calendar days (robust across DST)
|
||||||
const days =
|
const days = !l.hasTime && !r.hasTime
|
||||||
!l.hasTime && !r.hasTime
|
? new Decimal(Math.round((l.ms - r.ms) / 86400_000))
|
||||||
? new Decimal(Math.round((l.ms - r.ms) / 86400_000))
|
: new Decimal(l.ms - r.ms).div(86400_000);
|
||||||
: new Decimal(l.ms - r.ms).div(86400_000);
|
return qty(days, { id: "day", dimension: "time", ratio: new Decimal(86400), format: "day" });
|
||||||
return qty(days, {
|
|
||||||
id: "day",
|
|
||||||
dimension: "time",
|
|
||||||
ratio: new Decimal(86400),
|
|
||||||
format: "day",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
const date = (l.kind === "date" ? l : r) as Extract<Value, { kind: "date" }>;
|
const date = (l.kind === "date" ? l : r) as Extract<Value, { kind: "date" }>;
|
||||||
const span = l.kind === "date" ? r : l;
|
const span = l.kind === "date" ? r : l;
|
||||||
|
|
@ -459,8 +362,7 @@ function evalDateArith(op: string, l: Value, r: Value): Value {
|
||||||
throw new EvalError("date arithmetic needs a time span");
|
throw new EvalError("date arithmetic needs a time span");
|
||||||
}
|
}
|
||||||
if (op !== "plus" && op !== "minus") throw new EvalError("bad date op");
|
if (op !== "plus" && op !== "minus") throw new EvalError("bad date op");
|
||||||
if (op === "minus" && l.kind !== "date")
|
if (op === "minus" && l.kind !== "date") throw new EvalError("cannot subtract date from number");
|
||||||
throw new EvalError("cannot subtract date from number");
|
|
||||||
const amount = span.value.toNumber() * (op === "minus" ? -1 : 1);
|
const amount = span.value.toNumber() * (op === "minus" ? -1 : 1);
|
||||||
let ms: number;
|
let ms: number;
|
||||||
let hasTime = date.hasTime;
|
let hasTime = date.hasTime;
|
||||||
|
|
@ -482,82 +384,31 @@ function evalGoalSeek(lhsNode: Node, rhsNode: Node, ctx: EvalCtx): Value {
|
||||||
const innerCtx: EvalCtx = { ...ctx, vars };
|
const innerCtx: EvalCtx = { ...ctx, vars };
|
||||||
const lv = evaluate(lhsNode, innerCtx);
|
const lv = evaluate(lhsNode, innerCtx);
|
||||||
const rv = evaluate(rhsNode, innerCtx);
|
const rv = evaluate(rhsNode, innerCtx);
|
||||||
const toD = (v: Value) => v.kind === "quantity" ? toBase(v) : DEC_ZERO;
|
const toD = (v: Value) => v.kind === "quantity" ? toBase(v) : new Decimal(0);
|
||||||
return toD(lv).minus(toD(rv));
|
return toD(lv).minus(toD(rv));
|
||||||
};
|
};
|
||||||
|
|
||||||
const safeEval = (x: Decimal): Decimal | null => {
|
// Linear probe: f(0) and f(1) give slope → exact root for linear expressions
|
||||||
try {
|
const f0 = evalF(new Decimal(0));
|
||||||
return evalF(x);
|
const f1 = evalF(new Decimal(1));
|
||||||
} catch {
|
const slope = f1.minus(f0);
|
||||||
return null;
|
if (!slope.isZero()) {
|
||||||
}
|
const root = f0.neg().div(slope);
|
||||||
};
|
if (evalF(root).abs().lt(new Decimal("1e-9"))) return qty(root);
|
||||||
|
|
||||||
const f0 = safeEval(DEC_ZERO);
|
|
||||||
const f1 = safeEval(DEC_ONE);
|
|
||||||
|
|
||||||
if (f0 !== null && f1 !== null) {
|
|
||||||
const slope = f1.minus(f0);
|
|
||||||
if (!slope.isZero()) {
|
|
||||||
const root = f0.neg().div(slope);
|
|
||||||
const fRoot = safeEval(root);
|
|
||||||
if (fRoot !== null && fRoot.abs().lt(new Decimal("1e-9")))
|
|
||||||
return qty(root);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bisection fallback for nonlinear cases
|
// Bisection fallback for nonlinear cases
|
||||||
let lo = new Decimal(-1e9);
|
let lo = new Decimal(-1e9);
|
||||||
let hi = new Decimal(1e9);
|
let hi = new Decimal(1e9);
|
||||||
let flo = safeEval(lo);
|
let flo = evalF(lo);
|
||||||
let fhi = safeEval(hi);
|
let fhi = evalF(hi);
|
||||||
|
if (flo.mul(fhi).gt(0)) throw new EvalError("no solution");
|
||||||
if (flo === null || fhi === null || flo.mul(fhi).gt(0)) {
|
|
||||||
const probes = [
|
|
||||||
1, 2, 5, 10, 100, 1000, 10000, -1, -2, -5, -10, -100, -1000, -10000,
|
|
||||||
];
|
|
||||||
let found = false;
|
|
||||||
for (const p of probes) {
|
|
||||||
const pDec = new Decimal(p);
|
|
||||||
const fp = safeEval(pDec);
|
|
||||||
if (fp === null) continue;
|
|
||||||
if (flo === null) {
|
|
||||||
lo = pDec;
|
|
||||||
flo = fp;
|
|
||||||
} else if (fhi === null) {
|
|
||||||
hi = pDec;
|
|
||||||
fhi = fp;
|
|
||||||
} else if (flo.mul(fp).lte(0)) {
|
|
||||||
hi = pDec;
|
|
||||||
fhi = fp;
|
|
||||||
} else if (fhi.mul(fp).lte(0)) {
|
|
||||||
lo = pDec;
|
|
||||||
flo = fp;
|
|
||||||
} else continue;
|
|
||||||
if (flo !== null && fhi !== null && flo.mul(fhi).lte(0)) {
|
|
||||||
found = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!found) throw new EvalError("no solution");
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < 100; i++) {
|
for (let i = 0; i < 100; i++) {
|
||||||
const mid = lo.plus(hi).div(2);
|
const mid = lo.plus(hi).div(2);
|
||||||
if (hi.minus(lo).abs().lt(new Decimal("1e-10"))) return qty(mid);
|
if (hi.minus(lo).abs().lt(new Decimal("1e-10"))) return qty(mid);
|
||||||
const fm = safeEval(mid);
|
const fm = evalF(mid);
|
||||||
if (fm === null) {
|
if (flo.mul(fm).lte(0)) { hi = mid; fhi = fm; }
|
||||||
lo = mid.plus("1e-9");
|
else { lo = mid; flo = fm; }
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (flo!.mul(fm).lte(0)) {
|
|
||||||
hi = mid;
|
|
||||||
fhi = fm;
|
|
||||||
} else {
|
|
||||||
lo = mid;
|
|
||||||
flo = fm;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return qty(lo.plus(hi).div(2));
|
return qty(lo.plus(hi).div(2));
|
||||||
}
|
}
|
||||||
|
|
@ -569,10 +420,7 @@ function filterSameDimension(block: Quantity[]): Quantity[] {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Adds compatible lines left to right, silently skipping ones with an incompatible dimension. */
|
/** Adds compatible lines left to right, silently skipping ones with an incompatible dimension. */
|
||||||
function reduceCompatible(block: Quantity[]): {
|
function reduceCompatible(block: Quantity[]): { acc: Quantity; counted: number } {
|
||||||
acc: Quantity;
|
|
||||||
counted: number;
|
|
||||||
} {
|
|
||||||
let acc = block[0];
|
let acc = block[0];
|
||||||
let counted = 1;
|
let counted = 1;
|
||||||
for (let i = 1; i < block.length; i++) {
|
for (let i = 1; i < block.length; i++) {
|
||||||
|
|
@ -590,8 +438,7 @@ const AGG_FUNCS: Record<string, (block: Quantity[]) => Value> = {
|
||||||
count: (block) => qty(new Decimal(block.length)),
|
count: (block) => qty(new Decimal(block.length)),
|
||||||
chart: (block) => {
|
chart: (block) => {
|
||||||
const compatible = filterSameDimension(block);
|
const compatible = filterSameDimension(block);
|
||||||
if (compatible.length < 2)
|
if (compatible.length < 2) throw new EvalError("need at least 2 values for chart");
|
||||||
throw new EvalError("need at least 2 values for chart");
|
|
||||||
return {
|
return {
|
||||||
kind: "chart",
|
kind: "chart",
|
||||||
points: compatible.map((q) => toBase(q)),
|
points: compatible.map((q) => toBase(q)),
|
||||||
|
|
@ -600,12 +447,12 @@ const AGG_FUNCS: Record<string, (block: Quantity[]) => Value> = {
|
||||||
},
|
},
|
||||||
min: (block) => filterSameDimension(block).reduce((best, q) => toBase(q).lt(toBase(best)) ? q : best),
|
min: (block) => filterSameDimension(block).reduce((best, q) => toBase(q).lt(toBase(best)) ? q : best),
|
||||||
max: (block) => filterSameDimension(block).reduce((best, q) => toBase(q).gt(toBase(best)) ? q : best),
|
max: (block) => filterSameDimension(block).reduce((best, q) => toBase(q).gt(toBase(best)) ? q : best),
|
||||||
product: (block) => qty(block.reduce((acc, q) => acc.mul(q.value), DEC_ONE)),
|
product: (block) => qty(block.reduce((acc, q) => acc.mul(q.value), new Decimal(1))),
|
||||||
sum: (block) => reduceCompatible(block).acc,
|
sum: (block) => reduceCompatible(block).acc,
|
||||||
avg: (block) => {
|
avg: (block) => {
|
||||||
const { acc, counted } = reduceCompatible(block);
|
const { acc, counted } = reduceCompatible(block);
|
||||||
return qty(acc.value.div(counted), acc.unit);
|
return qty(acc.value.div(counted), acc.unit);
|
||||||
},
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
function getPrevResult(ctx: EvalCtx): Value {
|
function getPrevResult(ctx: EvalCtx): Value {
|
||||||
|
|
@ -634,10 +481,7 @@ function getAggBlock(ctx: EvalCtx): Quantity[] {
|
||||||
return block;
|
return block;
|
||||||
}
|
}
|
||||||
|
|
||||||
function evalAgg(
|
function evalAgg(name: "sum" | "avg" | "prev" | "count" | "min" | "max" | "product" | "chart", ctx: EvalCtx): Value {
|
||||||
name: "sum" | "avg" | "prev" | "count" | "min" | "max" | "product" | "chart",
|
|
||||||
ctx: EvalCtx,
|
|
||||||
): Value {
|
|
||||||
if (name === "prev") return getPrevResult(ctx);
|
if (name === "prev") return getPrevResult(ctx);
|
||||||
const block = getAggBlock(ctx);
|
const block = getAggBlock(ctx);
|
||||||
const func = AGG_FUNCS[name];
|
const func = AGG_FUNCS[name];
|
||||||
|
|
@ -645,80 +489,6 @@ function evalAgg(
|
||||||
return func(block);
|
return func(block);
|
||||||
}
|
}
|
||||||
|
|
||||||
const angleArg = (x: Value): Decimal => {
|
|
||||||
if (x.kind === "quantity" && x.unit?.dimension === "angle") {
|
|
||||||
return x.value.mul(x.unit.ratio);
|
|
||||||
}
|
|
||||||
return asScalar(x);
|
|
||||||
};
|
|
||||||
|
|
||||||
const BUILTIN_FUNCS: Record<string, (x: Value, ctx: EvalCtx) => Value> = {
|
|
||||||
sqrt: (x, ctx) => {
|
|
||||||
if (
|
|
||||||
x.kind === "quantity" &&
|
|
||||||
x.unit?.dimension === "area" &&
|
|
||||||
x.unit.id.startsWith("sq:")
|
|
||||||
) {
|
|
||||||
const base = ctx.reg.unitsById.get(x.unit.id.slice(3));
|
|
||||||
if (base) return qty(x.value.sqrt(), base);
|
|
||||||
}
|
|
||||||
return qty(asScalar(x).sqrt());
|
|
||||||
},
|
|
||||||
cbrt: (x) => qty(asScalar(x).cbrt()),
|
|
||||||
sin: (x) => qty(Decimal.sin(angleArg(x))),
|
|
||||||
cos: (x) => qty(Decimal.cos(angleArg(x))),
|
|
||||||
tan: (x) => qty(Decimal.tan(angleArg(x))),
|
|
||||||
cot: (x) => qty(DEC_ONE.div(Decimal.tan(angleArg(x)))),
|
|
||||||
asin: (x) => qty(Decimal.asin(asScalar(x))),
|
|
||||||
arcsin: (x) => qty(Decimal.asin(asScalar(x))),
|
|
||||||
acos: (x) => qty(Decimal.acos(asScalar(x))),
|
|
||||||
arccos: (x) => qty(Decimal.acos(asScalar(x))),
|
|
||||||
atan: (x) => qty(Decimal.atan(asScalar(x))),
|
|
||||||
arctan: (x) => qty(Decimal.atan(asScalar(x))),
|
|
||||||
sinh: (x) => qty(Decimal.sinh(asScalar(x))),
|
|
||||||
cosh: (x) => qty(Decimal.cosh(asScalar(x))),
|
|
||||||
tanh: (x) => qty(Decimal.tanh(asScalar(x))),
|
|
||||||
ln: (x) => qty(Decimal.ln(asScalar(x))),
|
|
||||||
lg: (x) => qty(Decimal.log10(asScalar(x))),
|
|
||||||
log: (x) => qty(Decimal.log10(asScalar(x))),
|
|
||||||
log2: (x) => qty(Decimal.log2(asScalar(x))),
|
|
||||||
exp: (x) => qty(Decimal.exp(asScalar(x))),
|
|
||||||
abs: (x) =>
|
|
||||||
x.kind === "quantity"
|
|
||||||
? { ...x, value: x.value.abs() }
|
|
||||||
: qty(asScalar(x).abs()),
|
|
||||||
round: (x) =>
|
|
||||||
x.kind === "quantity"
|
|
||||||
? { ...x, value: x.value.round() }
|
|
||||||
: qty(asScalar(x).round()),
|
|
||||||
ceil: (x) =>
|
|
||||||
x.kind === "quantity"
|
|
||||||
? { ...x, value: x.value.ceil() }
|
|
||||||
: qty(asScalar(x).ceil()),
|
|
||||||
floor: (x) =>
|
|
||||||
x.kind === "quantity"
|
|
||||||
? { ...x, value: x.value.floor() }
|
|
||||||
: qty(asScalar(x).floor()),
|
|
||||||
fact: (x) => qty(factorial(asScalar(x))),
|
|
||||||
factorial: (x) => qty(factorial(asScalar(x))),
|
|
||||||
until: (x) => {
|
|
||||||
if (x.kind !== "date") throw new EvalError("until needs a date");
|
|
||||||
return evalDateArith("minus", x, {
|
|
||||||
kind: "date",
|
|
||||||
ms: startOfToday(),
|
|
||||||
hasTime: false,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
since: (x) => {
|
|
||||||
if (x.kind !== "date") throw new EvalError("since needs a date");
|
|
||||||
return evalDateArith(
|
|
||||||
"minus",
|
|
||||||
{ kind: "date", ms: startOfToday(), hasTime: false },
|
|
||||||
x,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
function evalCall(name: string, args: Value[], ctx: EvalCtx): Value {
|
function evalCall(name: string, args: Value[], ctx: EvalCtx): Value {
|
||||||
const custom = ctx.reg.customFuncs.get(name);
|
const custom = ctx.reg.customFuncs.get(name);
|
||||||
if (custom) return custom(args);
|
if (custom) return custom(args);
|
||||||
|
|
@ -727,9 +497,53 @@ function evalCall(name: string, args: Value[], ctx: EvalCtx): Value {
|
||||||
const x = args[0];
|
const x = args[0];
|
||||||
if (!x) throw new EvalError(`${name} needs an argument`);
|
if (!x) throw new EvalError(`${name} needs an argument`);
|
||||||
|
|
||||||
const func = BUILTIN_FUNCS[name];
|
// trig works on angles: degrees convert to radians
|
||||||
if (!func) throw new EvalError(`unknown function ${name}`);
|
const angleArg = (): Decimal => {
|
||||||
return func(x, ctx);
|
if (x.kind === "quantity" && x.unit?.dimension === "angle") {
|
||||||
|
return x.value.mul(x.unit.ratio);
|
||||||
|
}
|
||||||
|
return asScalar(x);
|
||||||
|
};
|
||||||
|
const n = () => asScalar(x);
|
||||||
|
|
||||||
|
switch (name) {
|
||||||
|
case "sqrt": {
|
||||||
|
if (x.kind === "quantity" && x.unit?.dimension === "area" && x.unit.id.startsWith("sq:")) {
|
||||||
|
const base = ctx.reg.unitsById.get(x.unit.id.slice(3));
|
||||||
|
if (base) return qty(x.value.sqrt(), base);
|
||||||
|
}
|
||||||
|
return qty(n().sqrt());
|
||||||
|
}
|
||||||
|
case "cbrt": return qty(n().cbrt());
|
||||||
|
case "sin": return qty(Decimal.sin(angleArg()));
|
||||||
|
case "cos": return qty(Decimal.cos(angleArg()));
|
||||||
|
case "tan": return qty(Decimal.tan(angleArg()));
|
||||||
|
case "cot": return qty(new Decimal(1).div(Decimal.tan(angleArg())));
|
||||||
|
case "asin": case "arcsin": return qty(Decimal.asin(n()));
|
||||||
|
case "acos": case "arccos": return qty(Decimal.acos(n()));
|
||||||
|
case "atan": case "arctan": return qty(Decimal.atan(n()));
|
||||||
|
case "sinh": return qty(Decimal.sinh(n()));
|
||||||
|
case "cosh": return qty(Decimal.cosh(n()));
|
||||||
|
case "tanh": return qty(Decimal.tanh(n()));
|
||||||
|
case "ln": return qty(Decimal.ln(n()));
|
||||||
|
case "lg": case "log": return qty(Decimal.log10(n()));
|
||||||
|
case "log2": return qty(Decimal.log2(n()));
|
||||||
|
case "exp": return qty(Decimal.exp(n()));
|
||||||
|
case "abs": return x.kind === "quantity" ? { ...x, value: x.value.abs() } : qty(n().abs());
|
||||||
|
case "round": return x.kind === "quantity" ? { ...x, value: x.value.round() } : qty(n().round());
|
||||||
|
case "ceil": return x.kind === "quantity" ? { ...x, value: x.value.ceil() } : qty(n().ceil());
|
||||||
|
case "floor": return x.kind === "quantity" ? { ...x, value: x.value.floor() } : qty(n().floor());
|
||||||
|
case "fact": case "factorial": return qty(factorial(n()));
|
||||||
|
case "until": {
|
||||||
|
if (x.kind !== "date") throw new EvalError("until needs a date");
|
||||||
|
return evalDateArith("minus", x, { kind: "date", ms: startOfToday(), hasTime: false });
|
||||||
|
}
|
||||||
|
case "since": {
|
||||||
|
if (x.kind !== "date") throw new EvalError("since needs a date");
|
||||||
|
return evalDateArith("minus", { kind: "date", ms: startOfToday(), hasTime: false }, x);
|
||||||
|
}
|
||||||
|
default: throw new EvalError(`unknown function ${name}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Native float, not Decimal: this is a PRNG (mulberry32/strHash bit-twiddling
|
// Native float, not Decimal: this is a PRNG (mulberry32/strHash bit-twiddling
|
||||||
|
|
@ -739,15 +553,10 @@ function evalRandom(args: Value[], ctx: EvalCtx): Value {
|
||||||
const seed = strHash(ctx.line.lineText) ^ (ctx.line.index * 0x9e3779b9);
|
const seed = strHash(ctx.line.lineText) ^ (ctx.line.index * 0x9e3779b9);
|
||||||
const rand = mulberry32(seed);
|
const rand = mulberry32(seed);
|
||||||
if (args.length === 0) return qty(new Decimal(rand));
|
if (args.length === 0) return qty(new Decimal(rand));
|
||||||
const toNum = (v: Value) => (v.kind === "quantity" ? v.value.toNumber() : 0);
|
const toNum = (v: Value) => v.kind === "quantity" ? v.value.toNumber() : 0;
|
||||||
const [lo, hi] =
|
const [lo, hi] = args.length === 1 ? [0, toNum(args[0]!)] : [toNum(args[0]!), toNum(args[1]!)];
|
||||||
args.length === 1
|
|
||||||
? [0, toNum(args[0]!)]
|
|
||||||
: [toNum(args[0]!), toNum(args[1]!)];
|
|
||||||
const isInt = Number.isInteger(lo) && Number.isInteger(hi);
|
const isInt = Number.isInteger(lo) && Number.isInteger(hi);
|
||||||
const raw = isInt
|
const raw = isInt ? Math.floor(rand * (hi - lo + 1)) + lo : rand * (hi - lo) + lo;
|
||||||
? Math.floor(rand * (hi - lo + 1)) + lo
|
|
||||||
: rand * (hi - lo) + lo;
|
|
||||||
return qty(new Decimal(raw));
|
return qty(new Decimal(raw));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -760,32 +569,25 @@ function strHash(s: string): number {
|
||||||
}
|
}
|
||||||
|
|
||||||
function mulberry32(seed: number): number {
|
function mulberry32(seed: number): number {
|
||||||
seed = (seed + 0x6d2b79f5) | 0;
|
seed = (seed + 0x6D2B79F5) | 0;
|
||||||
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
|
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
|
||||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
t = t + Math.imul(t ^ (t >>> 7), 61 | t) ^ t;
|
||||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
}
|
}
|
||||||
|
|
||||||
function factorial(n: Decimal): Decimal {
|
function factorial(n: Decimal): Decimal {
|
||||||
if (!n.isInteger() || n.isNegative() || n.gt(300)) throw new EvalError("bad factorial");
|
if (!n.isInteger() || n.isNegative() || n.gt(300)) throw new EvalError("bad factorial");
|
||||||
let acc = DEC_ONE;
|
let acc = new Decimal(1);
|
||||||
for (let i = 2; i <= n.toNumber(); i++) acc = acc.mul(i);
|
for (let i = 2; i <= n.toNumber(); i++) acc = acc.mul(i);
|
||||||
return acc;
|
return acc;
|
||||||
}
|
}
|
||||||
|
|
||||||
function evaluateConst(node: {
|
function evaluateConst(node: { k: "const", name: "pi" | "e" | "half" | "onehalf" }): Value {
|
||||||
k: "const";
|
|
||||||
name: "pi" | "e" | "half" | "onehalf";
|
|
||||||
}): Value {
|
|
||||||
switch (node.name) {
|
switch (node.name) {
|
||||||
case "pi":
|
case "pi": return qty(PI);
|
||||||
return qty(PI);
|
case "e": return qty(E);
|
||||||
case "e":
|
case "half": return qty(new Decimal("0.5"));
|
||||||
return qty(E);
|
case "onehalf": return qty(new Decimal("1.5"));
|
||||||
case "half":
|
|
||||||
return qty(new Decimal("0.5"));
|
|
||||||
case "onehalf":
|
|
||||||
return qty(new Decimal("1.5"));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -798,9 +600,7 @@ function evaluateRef(node: Node, ctx: EvalCtx): Value {
|
||||||
if (node.k === "xref") {
|
if (node.k === "xref") {
|
||||||
const res = ctx.resolveXRef?.(node.sheet, node.key);
|
const res = ctx.resolveXRef?.(node.sheet, node.key);
|
||||||
if (!res || !res.ok) {
|
if (!res || !res.ok) {
|
||||||
throw new XRefError(
|
throw new XRefError(res && !res.ok ? res.reason : `sheet "${node.sheet}" not found`);
|
||||||
res && !res.ok ? res.reason : `sheet "${node.sheet}" not found`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return res.value;
|
return res.value;
|
||||||
}
|
}
|
||||||
|
|
@ -852,7 +652,7 @@ function evaluateUnitMod(node: Node, ctx: EvalCtx): Value {
|
||||||
throw new EvalError("invalid unit mod node");
|
throw new EvalError("invalid unit mod node");
|
||||||
}
|
}
|
||||||
|
|
||||||
function evaluateSeq(node: { k: "seq"; items: Node[] }, ctx: EvalCtx): Value {
|
function evaluateSeq(node: { k: "seq", items: Node[] }, ctx: EvalCtx): Value {
|
||||||
let acc: Value | null = null;
|
let acc: Value | null = null;
|
||||||
for (const item of node.items) {
|
for (const item of node.items) {
|
||||||
let v: Value;
|
let v: Value;
|
||||||
|
|
@ -862,11 +662,8 @@ function evaluateSeq(node: { k: "seq"; items: Node[] }, ctx: EvalCtx): Value {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
acc?.kind === "quantity" &&
|
acc?.kind === "quantity" && v.kind === "quantity" &&
|
||||||
v.kind === "quantity" &&
|
acc.unit && v.unit && acc.unit.dimension === v.unit.dimension &&
|
||||||
acc.unit &&
|
|
||||||
v.unit &&
|
|
||||||
acc.unit.dimension === v.unit.dimension &&
|
|
||||||
acc.unit.dimension !== "scalar"
|
acc.unit.dimension !== "scalar"
|
||||||
) {
|
) {
|
||||||
// "2 hours 30 min", "5 ft 4 in" — adjacent same-dimension quantities add up
|
// "2 hours 30 min", "5 ft 4 in" — adjacent same-dimension quantities add up
|
||||||
|
|
|
||||||
|
|
@ -74,9 +74,6 @@ export function formatValue(v: Value, s: EngineSettings): string {
|
||||||
|
|
||||||
// quantity
|
// quantity
|
||||||
if (v.repr === "hex" || v.repr === "binary" || v.repr === "octal") {
|
if (v.repr === "hex" || v.repr === "binary" || v.repr === "octal") {
|
||||||
if (v.value.abs().gte(new Decimal("1e10000"))) {
|
|
||||||
return formatNumber(v.value, s);
|
|
||||||
}
|
|
||||||
const int = v.value.toDecimalPlaces(0, Decimal.ROUND_HALF_UP);
|
const int = v.value.toDecimalPlaces(0, Decimal.ROUND_HALF_UP);
|
||||||
const neg = int.isNegative();
|
const neg = int.isNegative();
|
||||||
const abs = int.abs();
|
const abs = int.abs();
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { logger } from "../logger";
|
|
||||||
// Public facade: evaluates whole documents line by line and exposes the
|
// Public facade: evaluates whole documents line by line and exposes the
|
||||||
// extension API (numi.addUnit / addFunction / setVariable).
|
// extension API (numi.addUnit / addFunction / setVariable).
|
||||||
import { DateOrder, Decimal, EngineSettings, EvalError, Quantity, Unit, Value, XRefError, defaultSettings, qty } from "./types";
|
import { DateOrder, Decimal, EngineSettings, EvalError, Quantity, Unit, Value, XRefError, defaultSettings, qty } from "./types";
|
||||||
|
|
@ -138,6 +137,7 @@ export class SumEngine {
|
||||||
historicalRates: this.historicalRates,
|
historicalRates: this.historicalRates,
|
||||||
resolveXRef,
|
resolveXRef,
|
||||||
};
|
};
|
||||||
|
// a line without a result beats a dead sheet.
|
||||||
value = evaluate(parsed.expr, ctx);
|
value = evaluate(parsed.expr, ctx);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -146,11 +146,10 @@ export class SumEngine {
|
||||||
} else if (e instanceof EvalError) {
|
} else if (e instanceof EvalError) {
|
||||||
// Expected evaluation failure; remain silent
|
// Expected evaluation failure; remain silent
|
||||||
} else {
|
} else {
|
||||||
// Capture unexpected execution anomalies like BigInt or Range errors
|
// Surface unexpected bugs/BigInt errors/RangeErrors and log them
|
||||||
lineError = e instanceof Error ? e.message : String(e);
|
lineError = e instanceof Error ? e.message : String(e);
|
||||||
logger.warn("evaluate failed:", e);
|
console.warn("evaluate failed:", e);
|
||||||
}
|
}
|
||||||
// a line without a result beats a dead sheet.
|
|
||||||
value = null;
|
value = null;
|
||||||
}
|
}
|
||||||
if (value?.kind === "quantity" && !value.value.isFinite()) value = null;
|
if (value?.kind === "quantity" && !value.value.isFinite()) value = null;
|
||||||
|
|
|
||||||
|
|
@ -62,18 +62,6 @@ export function parseLine(tokens: Token[], knownVars: Set<string>, line: string)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const filtered = filterNoiseTokens(toks, knownVars);
|
|
||||||
|
|
||||||
const goalSeek = tryParseGoalSeek(filtered, knownVars);
|
|
||||||
if (goalSeek) {
|
|
||||||
return { expr: goalSeek };
|
|
||||||
}
|
|
||||||
|
|
||||||
const p = new Parser(filtered, knownVars);
|
|
||||||
return { assign, expr: p.parseSeq() };
|
|
||||||
}
|
|
||||||
|
|
||||||
function filterNoiseTokens(toks: Token[], knownVars: Set<string>): Token[] {
|
|
||||||
// Noise filtering: drop junk and unknown words, but keep words that follow
|
// Noise filtering: drop junk and unknown words, but keep words that follow
|
||||||
// a conversion operator (potential timezone names).
|
// a conversion operator (potential timezone names).
|
||||||
const filtered: Token[] = [];
|
const filtered: Token[] = [];
|
||||||
|
|
@ -93,10 +81,7 @@ function filterNoiseTokens(toks: Token[], knownVars: Set<string>): Token[] {
|
||||||
afterConv = tk.t === "conv";
|
afterConv = tk.t === "conv";
|
||||||
filtered.push(tk);
|
filtered.push(tk);
|
||||||
}
|
}
|
||||||
return filtered;
|
|
||||||
}
|
|
||||||
|
|
||||||
function tryParseGoalSeek(filtered: Token[], knownVars: Set<string>): Node | null {
|
|
||||||
// Goal seek: `? * 1.2 = 1000` → find x where lhs(x) = rhs(x)
|
// Goal seek: `? * 1.2 = 1000` → find x where lhs(x) = rhs(x)
|
||||||
const hasUnknown = filtered.some((tk) => tk.t === "unknown");
|
const hasUnknown = filtered.some((tk) => tk.t === "unknown");
|
||||||
if (hasUnknown) {
|
if (hasUnknown) {
|
||||||
|
|
@ -104,10 +89,12 @@ function tryParseGoalSeek(filtered: Token[], knownVars: Set<string>): Node | nul
|
||||||
if (assignIdx >= 0) {
|
if (assignIdx >= 0) {
|
||||||
const lhs = new Parser(filtered.slice(0, assignIdx), knownVars).parseSeq();
|
const lhs = new Parser(filtered.slice(0, assignIdx), knownVars).parseSeq();
|
||||||
const rhs = new Parser(filtered.slice(assignIdx + 1), knownVars).parseSeq();
|
const rhs = new Parser(filtered.slice(assignIdx + 1), knownVars).parseSeq();
|
||||||
if (lhs && rhs) return { k: "goalseek", lhs, rhs };
|
if (lhs && rhs) return { expr: { k: "goalseek", lhs, rhs } };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
|
||||||
|
const p = new Parser(filtered, knownVars);
|
||||||
|
return { assign, expr: p.parseSeq() };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -217,131 +204,114 @@ class Parser {
|
||||||
|
|
||||||
if (!lhs) return null;
|
if (!lhs) return null;
|
||||||
|
|
||||||
loop: for (;;) {
|
for (;;) {
|
||||||
tk = this.peek();
|
tk = this.peek();
|
||||||
if (!tk) break;
|
if (!tk) break;
|
||||||
|
|
||||||
switch (tk.t) {
|
if (tk.t === "percent") {
|
||||||
case "percent": {
|
if (PREC.POSTFIX < minPrec) break;
|
||||||
if (PREC.POSTFIX < minPrec) break loop;
|
this.i++;
|
||||||
|
lhs = { k: "pct", x: lhs };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (tk.t === "bang") {
|
||||||
|
if (PREC.POSTFIX < minPrec) break;
|
||||||
|
this.i++;
|
||||||
|
lhs = { k: "fact", x: lhs };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (tk.t === "unit") {
|
||||||
|
if (PREC.POSTFIX < minPrec) break;
|
||||||
|
this.i++;
|
||||||
|
lhs = { k: "unit", x: lhs, unit: tk.unit };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (tk.t === "currency") {
|
||||||
|
if (PREC.POSTFIX < minPrec) break;
|
||||||
|
this.i++;
|
||||||
|
lhs = { k: "curr", x: lhs, code: tk.code };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (tk.t === "scale") {
|
||||||
|
if (PREC.POSTFIX < minPrec) break;
|
||||||
|
this.i++;
|
||||||
|
lhs = { k: "scale", x: lhs, mult: tk.mult, label: "" };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (tk.t === "op" && tk.op === "pow") {
|
||||||
|
if (PREC.POW < minPrec) break;
|
||||||
|
const save = this.i;
|
||||||
|
this.i++;
|
||||||
|
const r = this.parseExpr(PREC.POW);
|
||||||
|
if (!r) { this.i = save; break; }
|
||||||
|
lhs = { k: "bin", op: "pow", l: lhs, r };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (tk.t === "op" && (tk.op === "mul" || tk.op === "div" || tk.op === "mod")) {
|
||||||
|
if (PREC.MUL < minPrec) break;
|
||||||
|
const save = this.i;
|
||||||
|
this.i++;
|
||||||
|
const r = this.parseExpr(PREC.MUL + 1);
|
||||||
|
if (!r) { this.i = save; break; }
|
||||||
|
lhs = { k: "bin", op: tk.op, l: lhs, r };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (tk.t === "pctop" && (tk.op === "of" || tk.op === "off" || tk.op === "on")) {
|
||||||
|
if (!(lhs.k === "pct" || lhs.k === "var" || lhs.k === "pctop")) {
|
||||||
this.i++;
|
this.i++;
|
||||||
lhs = { k: "pct", x: lhs };
|
continue;
|
||||||
continue loop;
|
|
||||||
}
|
|
||||||
case "bang": {
|
|
||||||
if (PREC.POSTFIX < minPrec) break loop;
|
|
||||||
this.i++;
|
|
||||||
lhs = { k: "fact", x: lhs };
|
|
||||||
continue loop;
|
|
||||||
}
|
|
||||||
case "unit": {
|
|
||||||
if (PREC.POSTFIX < minPrec) break loop;
|
|
||||||
this.i++;
|
|
||||||
lhs = { k: "unit", x: lhs, unit: tk.unit };
|
|
||||||
continue loop;
|
|
||||||
}
|
|
||||||
case "currency": {
|
|
||||||
if (PREC.POSTFIX < minPrec) break loop;
|
|
||||||
this.i++;
|
|
||||||
lhs = { k: "curr", x: lhs, code: tk.code };
|
|
||||||
continue loop;
|
|
||||||
}
|
|
||||||
case "scale": {
|
|
||||||
if (PREC.POSTFIX < minPrec) break loop;
|
|
||||||
this.i++;
|
|
||||||
lhs = { k: "scale", x: lhs, mult: tk.mult, label: "" };
|
|
||||||
continue loop;
|
|
||||||
}
|
|
||||||
case "op": {
|
|
||||||
switch (tk.op) {
|
|
||||||
case "pow": {
|
|
||||||
if (PREC.POW < minPrec) break loop;
|
|
||||||
const save = this.i;
|
|
||||||
this.i++;
|
|
||||||
const r = this.parseExpr(PREC.POW);
|
|
||||||
if (!r) { this.i = save; break loop; }
|
|
||||||
lhs = { k: "bin", op: "pow", l: lhs, r };
|
|
||||||
continue loop;
|
|
||||||
}
|
|
||||||
case "mul":
|
|
||||||
case "div":
|
|
||||||
case "mod": {
|
|
||||||
if (PREC.MUL < minPrec) break loop;
|
|
||||||
const save = this.i;
|
|
||||||
this.i++;
|
|
||||||
const r = this.parseExpr(PREC.MUL + 1);
|
|
||||||
if (!r) { this.i = save; break loop; }
|
|
||||||
lhs = { k: "bin", op: tk.op, l: lhs, r };
|
|
||||||
continue loop;
|
|
||||||
}
|
|
||||||
case "plus":
|
|
||||||
case "minus": {
|
|
||||||
if (PREC.ADD < minPrec) break loop;
|
|
||||||
const save = this.i;
|
|
||||||
this.i++;
|
|
||||||
const r = this.parseExpr(PREC.ADD + 1);
|
|
||||||
if (!r) { this.i = save; break loop; }
|
|
||||||
lhs = { k: "bin", op: tk.op, l: lhs, r };
|
|
||||||
continue loop;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break loop;
|
|
||||||
}
|
|
||||||
case "pctop": {
|
|
||||||
if (tk.op === "of" || tk.op === "off" || tk.op === "on") {
|
|
||||||
if (!(lhs.k === "pct" || lhs.k === "var" || lhs.k === "pctop")) {
|
|
||||||
this.i++;
|
|
||||||
continue loop;
|
|
||||||
}
|
|
||||||
if (PREC.MUL < minPrec) break loop;
|
|
||||||
const save = this.i;
|
|
||||||
this.i++;
|
|
||||||
const r = this.parseExpr(PREC.MUL + 1);
|
|
||||||
if (!r) { this.i = save; break loop; }
|
|
||||||
lhs = { k: "pctop", op: tk.op, l: lhs, r };
|
|
||||||
continue loop;
|
|
||||||
}
|
|
||||||
if (tk.op.startsWith("as_pct") || tk.op.endsWith("what_is")) {
|
|
||||||
if (PREC.CONV < minPrec) break loop;
|
|
||||||
const save = this.i;
|
|
||||||
this.i++;
|
|
||||||
const r = this.parseExpr(PREC.ADD);
|
|
||||||
if (!r) { this.i = save; break loop; }
|
|
||||||
lhs = { k: "pctop", op: tk.op, l: lhs, r };
|
|
||||||
continue loop;
|
|
||||||
}
|
|
||||||
break loop;
|
|
||||||
}
|
|
||||||
case "lparen":
|
|
||||||
case "const":
|
|
||||||
case "func":
|
|
||||||
case "word": {
|
|
||||||
if (tk.t === "word" && !this.vars.has(tk.raw)) break loop;
|
|
||||||
if (PREC.MUL < minPrec) break loop;
|
|
||||||
const save = this.i;
|
|
||||||
const r = this.parseExpr(PREC.MUL + 1);
|
|
||||||
if (!r) { this.i = save; break loop; }
|
|
||||||
lhs = { k: "bin", op: "mul", l: lhs, r };
|
|
||||||
continue loop;
|
|
||||||
}
|
|
||||||
case "bitop": {
|
|
||||||
if (PREC.BIT < minPrec) break loop;
|
|
||||||
const save = this.i;
|
|
||||||
this.i++;
|
|
||||||
const r = this.parseExpr(PREC.BIT + 1);
|
|
||||||
if (!r) { this.i = save; break loop; }
|
|
||||||
lhs = { k: "bit", op: tk.op, l: lhs, r };
|
|
||||||
continue loop;
|
|
||||||
}
|
|
||||||
case "conv": {
|
|
||||||
if (PREC.CONV < minPrec) break loop;
|
|
||||||
const save = this.i;
|
|
||||||
this.i++;
|
|
||||||
const target = this.parseTarget();
|
|
||||||
if (!target) { this.i = save; break loop; }
|
|
||||||
lhs = { k: "conv", x: lhs, target: this.tryHistoricalDate(target) };
|
|
||||||
continue loop;
|
|
||||||
}
|
}
|
||||||
|
if (PREC.MUL < minPrec) break;
|
||||||
|
const save = this.i;
|
||||||
|
this.i++;
|
||||||
|
const r = this.parseExpr(PREC.MUL + 1);
|
||||||
|
if (!r) { this.i = save; break; }
|
||||||
|
lhs = { k: "pctop", op: tk.op, l: lhs, r };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (tk.t === "lparen" || tk.t === "const" || tk.t === "func" || (tk.t === "word" && this.vars.has(tk.raw))) {
|
||||||
|
if (PREC.MUL < minPrec) break;
|
||||||
|
const save = this.i;
|
||||||
|
const r = this.parseExpr(PREC.MUL + 1);
|
||||||
|
if (!r) { this.i = save; break; }
|
||||||
|
lhs = { k: "bin", op: "mul", l: lhs, r };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (tk.t === "op" && (tk.op === "plus" || tk.op === "minus")) {
|
||||||
|
if (PREC.ADD < minPrec) break;
|
||||||
|
const save = this.i;
|
||||||
|
this.i++;
|
||||||
|
const r = this.parseExpr(PREC.ADD + 1);
|
||||||
|
if (!r) { this.i = save; break; }
|
||||||
|
lhs = { k: "bin", op: tk.op, l: lhs, r };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (tk.t === "bitop") {
|
||||||
|
if (PREC.BIT < minPrec) break;
|
||||||
|
const save = this.i;
|
||||||
|
this.i++;
|
||||||
|
const r = this.parseExpr(PREC.BIT + 1);
|
||||||
|
if (!r) { this.i = save; break; }
|
||||||
|
lhs = { k: "bit", op: tk.op, l: lhs, r };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (tk.t === "conv") {
|
||||||
|
if (PREC.CONV < minPrec) break;
|
||||||
|
const save = this.i;
|
||||||
|
this.i++;
|
||||||
|
const target = this.parseTarget();
|
||||||
|
if (!target) { this.i = save; break; }
|
||||||
|
lhs = { k: "conv", x: lhs, target: this.tryHistoricalDate(target) };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (tk.t === "pctop" && (tk.op.startsWith("as_pct") || tk.op.endsWith("what_is"))) {
|
||||||
|
if (PREC.CONV < minPrec) break;
|
||||||
|
const save = this.i;
|
||||||
|
this.i++;
|
||||||
|
const r = this.parseExpr(PREC.ADD);
|
||||||
|
if (!r) { this.i = save; break; }
|
||||||
|
lhs = { k: "pctop", op: tk.op, l: lhs, r };
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
|
|
|
||||||
|
|
@ -178,29 +178,29 @@ function registerCoreVocab(reg: Registry): void {
|
||||||
["minus", { t: "op", op: "minus" }],
|
["minus", { t: "op", op: "minus" }],
|
||||||
["multiply", { t: "op", op: "mul" }],
|
["multiply", { t: "op", op: "mul" }],
|
||||||
["divide", { t: "op", op: "div" }],
|
["divide", { t: "op", op: "div" }],
|
||||||
["conversion", { t: "conv" }],
|
|
||||||
["assignment", { t: "assign" }],
|
|
||||||
["sum", { t: "agg", name: "sum" }],
|
|
||||||
["avg", { t: "agg", name: "avg" }],
|
|
||||||
["prev", { t: "agg", name: "prev" }],
|
|
||||||
["count", { t: "agg", name: "count" }],
|
|
||||||
["min", { t: "agg", name: "min" }],
|
|
||||||
["max", { t: "agg", name: "max" }],
|
|
||||||
["product", { t: "agg", name: "product" }],
|
|
||||||
["chart", { t: "agg", name: "chart" }],
|
|
||||||
["sqrt", { t: "func", name: "sqrt" }],
|
|
||||||
["cbrt", { t: "func", name: "cbrt" }],
|
|
||||||
["abs", { t: "func", name: "abs" }],
|
|
||||||
["ln", { t: "func", name: "ln" }],
|
|
||||||
["log", { t: "func", name: "log" }],
|
|
||||||
["round", { t: "func", name: "round" }],
|
|
||||||
["random", { t: "func", name: "random" }],
|
|
||||||
["until", { t: "func", name: "until" }],
|
|
||||||
["since", { t: "func", name: "since" }],
|
|
||||||
];
|
];
|
||||||
for (const [key, payload] of ops) {
|
for (const [key, payload] of ops) {
|
||||||
for (const v of vocab.variants("Operations", `${key}.variants`)) reg.addPhrase(v, payload, { caseSensitive: false });
|
for (const v of vocab.variants("Operations", `${key}.variants`)) reg.addPhrase(v, payload, { caseSensitive: false });
|
||||||
}
|
}
|
||||||
|
for (const v of vocab.variants("Operations", "conversion.variants")) reg.addPhrase(v, { t: "conv" }, { caseSensitive: false });
|
||||||
|
for (const v of vocab.variants("Operations", "assignment.variants")) reg.addPhrase(v, { t: "assign" }, { caseSensitive: false });
|
||||||
|
for (const v of vocab.variants("Operations", "sum.variants")) reg.addPhrase(v, { t: "agg", name: "sum" }, { caseSensitive: false });
|
||||||
|
for (const v of vocab.variants("Operations", "avg.variants")) reg.addPhrase(v, { t: "agg", name: "avg" }, { caseSensitive: false });
|
||||||
|
for (const v of vocab.variants("Operations", "prev.variants")) reg.addPhrase(v, { t: "agg", name: "prev" }, { caseSensitive: false });
|
||||||
|
for (const v of vocab.variants("Operations", "count.variants")) reg.addPhrase(v, { t: "agg", name: "count" }, { caseSensitive: false });
|
||||||
|
for (const v of vocab.variants("Operations", "min.variants")) reg.addPhrase(v, { t: "agg", name: "min" }, { caseSensitive: false });
|
||||||
|
for (const v of vocab.variants("Operations", "max.variants")) reg.addPhrase(v, { t: "agg", name: "max" }, { caseSensitive: false });
|
||||||
|
for (const v of vocab.variants("Operations", "product.variants")) reg.addPhrase(v, { t: "agg", name: "product" }, { caseSensitive: false });
|
||||||
|
for (const v of vocab.variants("Operations", "chart.variants")) reg.addPhrase(v, { t: "agg", name: "chart" }, { caseSensitive: false });
|
||||||
|
for (const v of vocab.variants("Operations", "sqrt.variants")) reg.addPhrase(v, { t: "func", name: "sqrt" }, { caseSensitive: false });
|
||||||
|
for (const v of vocab.variants("Operations", "cbrt.variants")) reg.addPhrase(v, { t: "func", name: "cbrt" }, { caseSensitive: false });
|
||||||
|
for (const v of vocab.variants("Operations", "abs.variants")) reg.addPhrase(v, { t: "func", name: "abs" }, { caseSensitive: false });
|
||||||
|
for (const v of vocab.variants("Operations", "ln.variants")) reg.addPhrase(v, { t: "func", name: "ln" }, { caseSensitive: false });
|
||||||
|
for (const v of vocab.variants("Operations", "log.variants")) reg.addPhrase(v, { t: "func", name: "log" }, { caseSensitive: false });
|
||||||
|
for (const v of vocab.variants("Operations", "round.variants")) reg.addPhrase(v, { t: "func", name: "round" }, { caseSensitive: false });
|
||||||
|
for (const v of vocab.variants("Operations", "random.variants")) reg.addPhrase(v, { t: "func", name: "random" }, { caseSensitive: false });
|
||||||
|
for (const v of vocab.variants("Operations", "until.variants")) reg.addPhrase(v, { t: "func", name: "until" }, { caseSensitive: false });
|
||||||
|
for (const v of vocab.variants("Operations", "since.variants")) reg.addPhrase(v, { t: "func", name: "since" }, { caseSensitive: false });
|
||||||
reg.addPhrase("mod", { t: "op", op: "mod" }, { caseSensitive: false });
|
reg.addPhrase("mod", { t: "op", op: "mod" }, { caseSensitive: false });
|
||||||
reg.addPhrase("modulo", { t: "op", op: "mod" }, { caseSensitive: false });
|
reg.addPhrase("modulo", { t: "op", op: "mod" }, { caseSensitive: false });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -6,8 +6,6 @@ export { Decimal };
|
||||||
|
|
||||||
/** Shared here (not in evaluator.ts) so unitdata.ts can use it too without a cycle. */
|
/** 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 PI = new Decimal("3.14159265358979323846264338327950288419716939937510");
|
||||||
export const DEC_ZERO = new Decimal(0);
|
|
||||||
export const DEC_ONE = new Decimal(1);
|
|
||||||
|
|
||||||
export type Dimension =
|
export type Dimension =
|
||||||
| "scalar"
|
| "scalar"
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { logger } from "./logger";
|
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { makeApi, runExtensions } from "./extensions";
|
import { makeApi, runExtensions } from "./extensions";
|
||||||
import { SumEngine } from "./engine";
|
import { SumEngine } from "./engine";
|
||||||
|
|
@ -42,8 +41,8 @@ describe("extensions", () => {
|
||||||
let consoleWarnSpy: any;
|
let consoleWarnSpy: any;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
consoleErrorSpy = vi.spyOn(logger, "error").mockImplementation(() => {});
|
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
consoleWarnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {});
|
consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|
@ -186,8 +185,6 @@ describe("extensions", () => {
|
||||||
newQuickJSWASMModuleFromVariant: () => Promise.reject(new Error("no WebAssembly")),
|
newQuickJSWASMModuleFromVariant: () => Promise.reject(new Error("no WebAssembly")),
|
||||||
}));
|
}));
|
||||||
const { runExtensions: run } = await import("./extensions");
|
const { runExtensions: run } = await import("./extensions");
|
||||||
const { logger } = await import("./logger");
|
|
||||||
consoleWarnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {});
|
|
||||||
|
|
||||||
await run(mockEngine as SumEngine, [{ name: "s", code: "numi.setVariable('x', 1);" }]);
|
await run(mockEngine as SumEngine, [{ name: "s", code: "numi.setVariable('x', 1);" }]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { logger } from "./logger";
|
|
||||||
// JS extensions runtime: numi.setVariable, numi.addUnit, numi.addFunction.
|
// JS extensions runtime: numi.setVariable, numi.addUnit, numi.addFunction.
|
||||||
// Scripts run inside a QuickJS WASM sandbox — no DOM, no Tauri IPC, no host globals.
|
// Scripts run inside a QuickJS WASM sandbox — no DOM, no Tauri IPC, no host globals.
|
||||||
import {
|
import {
|
||||||
|
|
@ -76,7 +75,7 @@ export async function runExtensions(
|
||||||
try {
|
try {
|
||||||
await load(engine, scripts);
|
await load(engine, scripts);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.warn("extensions disabled: QuickJS sandbox unavailable:", e);
|
console.warn("extensions disabled: QuickJS sandbox unavailable:", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -201,7 +200,7 @@ function evaluateScripts(
|
||||||
}
|
}
|
||||||
res.value.dispose();
|
res.value.dispose();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(`extension ${s.name} failed:`, e);
|
console.error(`extension ${s.name} failed:`, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
|
||||||
});
|
|
||||||
434
src/main.ts
434
src/main.ts
|
|
@ -1,35 +1,14 @@
|
||||||
import { logger } from "./logger";
|
|
||||||
/** App bootstrap: engine + editor + documents + settings + Tauri glue. */
|
/** App bootstrap: engine + editor + documents + settings + Tauri glue. */
|
||||||
import "./ui/app.css";
|
import "./ui/app.css";
|
||||||
import { SumEngine } from "./engine";
|
import { SumEngine } from "./engine";
|
||||||
import { SumEditor } from "./ui/editor";
|
import { SumEditor } from "./ui/editor";
|
||||||
import {
|
import {
|
||||||
AppData,
|
AppData, DocMeta, SettingsData, defaultSettingsData,
|
||||||
DocMeta,
|
loadAppData, saveAppData, flushAppData, onAppQuit, loadSettings, saveSettings,
|
||||||
SettingsData,
|
fetchRates, fetchMarketData, fetchHistoricalRatesBatch, loadExtensionScripts, openExtensionsFolder, isTauri,
|
||||||
defaultSettingsData,
|
getLaunchFile, onOpenFile, onFileDrop,
|
||||||
loadAppData,
|
setDataDir, runBackups, backupDeletedSheet, openBackupsFolder,
|
||||||
saveAppData,
|
chooseFolder, dataDirHasDocuments, migrateDataDir,
|
||||||
flushAppData,
|
|
||||||
onAppQuit,
|
|
||||||
loadSettings,
|
|
||||||
saveSettings,
|
|
||||||
fetchRates,
|
|
||||||
fetchMarketData,
|
|
||||||
fetchHistoricalRatesBatch,
|
|
||||||
loadExtensionScripts,
|
|
||||||
openExtensionsFolder,
|
|
||||||
isTauri,
|
|
||||||
getLaunchFile,
|
|
||||||
onOpenFile,
|
|
||||||
onFileDrop,
|
|
||||||
setDataDir,
|
|
||||||
runBackups,
|
|
||||||
backupDeletedSheet,
|
|
||||||
openBackupsFolder,
|
|
||||||
chooseFolder,
|
|
||||||
dataDirHasDocuments,
|
|
||||||
migrateDataDir,
|
|
||||||
} from "./storage";
|
} from "./storage";
|
||||||
import type { LineResult } from "./engine";
|
import type { LineResult } from "./engine";
|
||||||
import { setLang, detectLang, t } from "./i18n";
|
import { setLang, detectLang, t } from "./i18n";
|
||||||
|
|
@ -43,13 +22,10 @@ import pkg from "../package.json";
|
||||||
|
|
||||||
function welcomeText(lang: string): string {
|
function welcomeText(lang: string): string {
|
||||||
const sample = (lang === "ru" ? RU : EN).Samples?.["sample.welcome"];
|
const sample = (lang === "ru" ? RU : EN).Samples?.["sample.welcome"];
|
||||||
return (
|
return (sample ?? "# Sample\n8 / (45 - 20%)\n5 inches in cm\n$9 in Euro") + "\n";
|
||||||
(sample ?? "# Sample\n8 / (45 - 20%)\n5 inches in cm\n$9 in Euro") + "\n"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const $ = <T extends HTMLElement>(sel: string): T =>
|
const $ = <T extends HTMLElement>(sel: string): T => document.querySelector(sel) as T;
|
||||||
document.querySelector(sel) as T;
|
|
||||||
|
|
||||||
let engine: SumEngine;
|
let engine: SumEngine;
|
||||||
let editor: SumEditor;
|
let editor: SumEditor;
|
||||||
|
|
@ -69,12 +45,8 @@ function uid(): string {
|
||||||
|
|
||||||
// ---------- documents
|
// ---------- documents
|
||||||
|
|
||||||
function getDocById(id: string | undefined): DocMeta | undefined {
|
|
||||||
return data.docs.find((d) => d.id === id);
|
|
||||||
}
|
|
||||||
|
|
||||||
function activeDoc(): DocMeta {
|
function activeDoc(): DocMeta {
|
||||||
return getDocById(data.activeId) ?? data.docs[0];
|
return data.docs.find((d) => d.id === data.activeId) ?? data.docs[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
function titleFromContent(text: string): string {
|
function titleFromContent(text: string): string {
|
||||||
|
|
@ -94,34 +66,21 @@ function syncTitleField(): void {
|
||||||
if (document.activeElement !== field) field.value = activeDoc()?.title ?? "";
|
if (document.activeElement !== field) field.value = activeDoc()?.title ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeDocById(id: string): void {
|
|
||||||
data.docs = data.docs.filter((d) => d.id !== id);
|
|
||||||
}
|
|
||||||
|
|
||||||
function pinDoc(id: string): void {
|
function pinDoc(id: string): void {
|
||||||
const doc = getDocById(id);
|
const doc = data.docs.find((d) => d.id === id);
|
||||||
if (!doc) return;
|
if (!doc) return;
|
||||||
doc.pinned = !doc.pinned;
|
doc.pinned = !doc.pinned;
|
||||||
if (doc.pinned) {
|
if (doc.pinned) {
|
||||||
// move to end of pinned group
|
// move to end of pinned group
|
||||||
removeDocById(id);
|
data.docs = data.docs.filter((d) => d.id !== id);
|
||||||
let lastPinned = -1;
|
let lastPinned = -1;
|
||||||
for (let i = data.docs.length - 1; i >= 0; i--) {
|
for (let i = data.docs.length - 1; i >= 0; i--) { if (data.docs[i].pinned) { lastPinned = i; break; } }
|
||||||
if (data.docs[i].pinned) {
|
|
||||||
lastPinned = i;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
data.docs.splice(lastPinned + 1, 0, doc);
|
data.docs.splice(lastPinned + 1, 0, doc);
|
||||||
} else {
|
} else {
|
||||||
// move to start of non-pinned group
|
// move to start of non-pinned group
|
||||||
removeDocById(id);
|
data.docs = data.docs.filter((d) => d.id !== id);
|
||||||
const firstNonPinned = data.docs.findIndex((d) => !d.pinned);
|
const firstNonPinned = data.docs.findIndex((d) => !d.pinned);
|
||||||
data.docs.splice(
|
data.docs.splice(firstNonPinned === -1 ? data.docs.length : firstNonPinned, 0, doc);
|
||||||
firstNonPinned === -1 ? data.docs.length : firstNonPinned,
|
|
||||||
0,
|
|
||||||
doc,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
saveAppData(data);
|
saveAppData(data);
|
||||||
renderDocList();
|
renderDocList();
|
||||||
|
|
@ -130,17 +89,17 @@ function pinDoc(id: string): void {
|
||||||
/** drag id -> drop-target id; no-op across the pinned/unpinned boundary */
|
/** drag id -> drop-target id; no-op across the pinned/unpinned boundary */
|
||||||
function reorderDoc(srcId: string, targetId: string): void {
|
function reorderDoc(srcId: string, targetId: string): void {
|
||||||
if (srcId === targetId) return;
|
if (srcId === targetId) return;
|
||||||
const src = getDocById(srcId);
|
const src = data.docs.find((d) => d.id === srcId);
|
||||||
const target = getDocById(targetId);
|
const target = data.docs.find((d) => d.id === targetId);
|
||||||
if (!src || !target || !!src.pinned !== !!target.pinned) return;
|
if (!src || !target || !!src.pinned !== !!target.pinned) return;
|
||||||
removeDocById(srcId);
|
data.docs = data.docs.filter((d) => d.id !== srcId);
|
||||||
const targetIdx = data.docs.findIndex((d) => d.id === targetId);
|
const targetIdx = data.docs.findIndex((d) => d.id === targetId);
|
||||||
data.docs.splice(targetIdx, 0, src);
|
data.docs.splice(targetIdx, 0, src);
|
||||||
saveAppData(data);
|
saveAppData(data);
|
||||||
renderDocList();
|
renderDocList();
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupDocInteraction(el: HTMLElement, doc: DocMeta, list: HTMLElement): void {
|
function setupDocInteraction(el: HTMLElement, doc: DocMeta, list: HTMLElement, data: AppData): void {
|
||||||
// Reordering uses plain mouse tracking, not the native HTML5 drag API —
|
// Reordering uses plain mouse tracking, not the native HTML5 drag API —
|
||||||
// Tauri's window-level file-drop hook (dragDropEnabled, needed for
|
// Tauri's window-level file-drop hook (dragDropEnabled, needed for
|
||||||
// dropping .numi files from Explorer) intercepts WebView2's own drag
|
// dropping .numi files from Explorer) intercepts WebView2's own drag
|
||||||
|
|
@ -151,17 +110,8 @@ function setupDocInteraction(el: HTMLElement, doc: DocMeta, list: HTMLElement):
|
||||||
el.addEventListener("mousedown", (e) => {
|
el.addEventListener("mousedown", (e) => {
|
||||||
if ((e.target as HTMLElement).closest("button")) return;
|
if ((e.target as HTMLElement).closest("button")) return;
|
||||||
const startY = e.clientY;
|
const startY = e.clientY;
|
||||||
let cachedItems: { el: HTMLElement; top: number; bottom: number }[] | null =
|
let cachedItems: { el: HTMLElement, top: number, bottom: number }[] | null = null;
|
||||||
null;
|
|
||||||
let prevOver: HTMLElement | undefined;
|
|
||||||
const onMove = (ev: MouseEvent) => {
|
const onMove = (ev: MouseEvent) => {
|
||||||
if (!cachedItems) {
|
|
||||||
const domItems = [...list.querySelectorAll<HTMLElement>(".doc-item")];
|
|
||||||
cachedItems = domItems.map((item) => {
|
|
||||||
const rect = item.getBoundingClientRect();
|
|
||||||
return { el: item, top: rect.top, bottom: rect.bottom };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (!dragging) {
|
if (!dragging) {
|
||||||
if (Math.abs(ev.clientY - startY) < 4) return;
|
if (Math.abs(ev.clientY - startY) < 4) return;
|
||||||
dragging = true;
|
dragging = true;
|
||||||
|
|
@ -169,30 +119,28 @@ function setupDocInteraction(el: HTMLElement, doc: DocMeta, list: HTMLElement):
|
||||||
el.classList.add("dragging");
|
el.classList.add("dragging");
|
||||||
document.body.style.cursor = "grabbing";
|
document.body.style.cursor = "grabbing";
|
||||||
}
|
}
|
||||||
|
if (!cachedItems) {
|
||||||
|
const domItems = [...list.querySelectorAll<HTMLElement>(".doc-item")];
|
||||||
|
cachedItems = domItems.map((item) => {
|
||||||
|
const rect = item.getBoundingClientRect();
|
||||||
|
return { el: item, top: rect.top, bottom: rect.bottom };
|
||||||
|
});
|
||||||
|
}
|
||||||
let over: HTMLElement | undefined;
|
let over: HTMLElement | undefined;
|
||||||
for (const item of cachedItems) {
|
for (const item of cachedItems) {
|
||||||
|
item.el.classList.remove("drag-over");
|
||||||
if (!over && item.el !== el) {
|
if (!over && item.el !== el) {
|
||||||
if (ev.clientY >= item.top && ev.clientY <= item.bottom) {
|
if (ev.clientY >= item.top && ev.clientY <= item.bottom) {
|
||||||
over = item.el;
|
over = item.el;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let newOver: HTMLElement | undefined;
|
|
||||||
if (over) {
|
if (over) {
|
||||||
const overDoc = getDocById(over.dataset.docId);
|
const overDoc = data.docs.find((d) => d.id === over.dataset.docId);
|
||||||
if (overDoc && !!overDoc.pinned === !!doc.pinned) newOver = over;
|
if (overDoc && !!overDoc.pinned === !!doc.pinned) over.classList.add("drag-over");
|
||||||
}
|
}
|
||||||
if (prevOver && prevOver !== newOver) {
|
|
||||||
prevOver.classList.remove("drag-over");
|
|
||||||
}
|
|
||||||
if (newOver && prevOver !== newOver) {
|
|
||||||
newOver.classList.add("drag-over");
|
|
||||||
}
|
|
||||||
prevOver = newOver;
|
|
||||||
};
|
|
||||||
const onScroll = () => {
|
|
||||||
cachedItems = null;
|
|
||||||
};
|
};
|
||||||
|
const onScroll = () => { cachedItems = null; };
|
||||||
const onUp = () => {
|
const onUp = () => {
|
||||||
window.removeEventListener("mousemove", onMove);
|
window.removeEventListener("mousemove", onMove);
|
||||||
window.removeEventListener("mouseup", onUp);
|
window.removeEventListener("mouseup", onUp);
|
||||||
|
|
@ -200,8 +148,7 @@ function setupDocInteraction(el: HTMLElement, doc: DocMeta, list: HTMLElement):
|
||||||
cachedItems = null;
|
cachedItems = null;
|
||||||
if (dragging) {
|
if (dragging) {
|
||||||
const target = list.querySelector<HTMLElement>(".doc-item.drag-over");
|
const target = list.querySelector<HTMLElement>(".doc-item.drag-over");
|
||||||
for (const item of list.querySelectorAll(".doc-item"))
|
for (const item of list.querySelectorAll(".doc-item")) item.classList.remove("drag-over", "dragging");
|
||||||
item.classList.remove("drag-over", "dragging");
|
|
||||||
document.body.style.cursor = "";
|
document.body.style.cursor = "";
|
||||||
if (target?.dataset.docId) reorderDoc(doc.id, target.dataset.docId);
|
if (target?.dataset.docId) reorderDoc(doc.id, target.dataset.docId);
|
||||||
}
|
}
|
||||||
|
|
@ -212,37 +159,22 @@ function setupDocInteraction(el: HTMLElement, doc: DocMeta, list: HTMLElement):
|
||||||
list.addEventListener("scroll", onScroll);
|
list.addEventListener("scroll", onScroll);
|
||||||
});
|
});
|
||||||
el.addEventListener("click", () => {
|
el.addEventListener("click", () => {
|
||||||
if (suppressClick) {
|
if (suppressClick) { suppressClick = false; return; }
|
||||||
suppressClick = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
switchDoc(doc.id);
|
switchDoc(doc.id);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function mkBtn(
|
function mkBtn(cls: string, text: string, title: string, cb: () => void): HTMLButtonElement {
|
||||||
cls: string,
|
|
||||||
text: string,
|
|
||||||
title: string,
|
|
||||||
cb: () => void,
|
|
||||||
): HTMLButtonElement {
|
|
||||||
const b = document.createElement("button");
|
const b = document.createElement("button");
|
||||||
b.className = cls;
|
b.className = cls;
|
||||||
b.textContent = text;
|
b.textContent = text;
|
||||||
b.title = title;
|
b.title = title;
|
||||||
b.setAttribute("aria-label", title);
|
b.setAttribute("aria-label", title);
|
||||||
b.addEventListener("click", (e) => {
|
b.addEventListener("click", (e) => { e.stopPropagation(); cb(); });
|
||||||
e.stopPropagation();
|
|
||||||
cb();
|
|
||||||
});
|
|
||||||
return b;
|
return b;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createDeleteButton(
|
function createDeleteButton(doc: DocMeta, appData: AppData, appSettings: SettingsData): HTMLButtonElement {
|
||||||
doc: DocMeta,
|
|
||||||
appData: AppData,
|
|
||||||
appSettings: SettingsData,
|
|
||||||
): HTMLButtonElement {
|
|
||||||
const del = document.createElement("button");
|
const del = document.createElement("button");
|
||||||
del.className = "del";
|
del.className = "del";
|
||||||
del.textContent = "✕";
|
del.textContent = "✕";
|
||||||
|
|
@ -260,13 +192,9 @@ function createDeleteButton(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (confirmTimer) clearTimeout(confirmTimer);
|
if (confirmTimer) clearTimeout(confirmTimer);
|
||||||
void backupDeletedSheet(
|
void backupDeletedSheet(appSettings.dataDir, doc.title, appData.contents[doc.id] ?? "");
|
||||||
appSettings.dataDir,
|
|
||||||
doc.title,
|
|
||||||
appData.contents[doc.id] ?? "",
|
|
||||||
);
|
|
||||||
delete appData.contents[doc.id];
|
delete appData.contents[doc.id];
|
||||||
removeDocById(doc.id);
|
appData.docs = appData.docs.filter((d) => d.id !== doc.id);
|
||||||
if (appData.activeId === doc.id) switchDoc(appData.docs[0].id);
|
if (appData.activeId === doc.id) switchDoc(appData.docs[0].id);
|
||||||
else renderDocList();
|
else renderDocList();
|
||||||
saveAppData(appData);
|
saveAppData(appData);
|
||||||
|
|
@ -280,25 +208,15 @@ function renderDocList(): void {
|
||||||
const frag = document.createDocumentFragment();
|
const frag = document.createDocumentFragment();
|
||||||
for (const doc of data.docs) {
|
for (const doc of data.docs) {
|
||||||
const el = document.createElement("div");
|
const el = document.createElement("div");
|
||||||
el.className =
|
el.className = "doc-item" + (doc.id === data.activeId ? " active" : "") + (doc.pinned ? " pinned" : "");
|
||||||
"doc-item" +
|
|
||||||
(doc.id === data.activeId ? " active" : "") +
|
|
||||||
(doc.pinned ? " pinned" : "");
|
|
||||||
const name = document.createElement("span");
|
const name = document.createElement("span");
|
||||||
name.className = "doc-name";
|
name.className = "doc-name";
|
||||||
name.textContent = doc.title || t("untitled");
|
name.textContent = doc.title || t("untitled");
|
||||||
el.appendChild(name);
|
el.appendChild(name);
|
||||||
|
|
||||||
el.appendChild(
|
el.appendChild(mkBtn("pin-btn" + (doc.pinned ? " active" : ""), "📌", doc.pinned ? t("unpin") : t("pin"), () => pinDoc(doc.id)));
|
||||||
mkBtn(
|
|
||||||
"pin-btn" + (doc.pinned ? " active" : ""),
|
|
||||||
"📌",
|
|
||||||
doc.pinned ? t("unpin") : t("pin"),
|
|
||||||
() => pinDoc(doc.id),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
setupDocInteraction(el, doc, list);
|
setupDocInteraction(el, doc, list, data);
|
||||||
el.dataset.docId = doc.id;
|
el.dataset.docId = doc.id;
|
||||||
|
|
||||||
el.appendChild(createDeleteButton(doc, data, settings));
|
el.appendChild(createDeleteButton(doc, data, settings));
|
||||||
|
|
@ -320,17 +238,14 @@ function switchDoc(id: string): void {
|
||||||
|
|
||||||
function newDoc(content = ""): void {
|
function newDoc(content = ""): void {
|
||||||
const id = uid();
|
const id = uid();
|
||||||
data.docs.push({
|
data.docs.push({ id, title: content ? titleFromContent(content) : t("untitled") });
|
||||||
id,
|
|
||||||
title: content ? titleFromContent(content) : t("untitled"),
|
|
||||||
});
|
|
||||||
data.contents[id] = content;
|
data.contents[id] = content;
|
||||||
switchDoc(id);
|
switchDoc(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function closeActiveDoc(): Promise<void> {
|
async function closeActiveDoc(): Promise<void> {
|
||||||
if (data.docs.length <= 1) return;
|
if (data.docs.length <= 1) return;
|
||||||
const doc = getDocById(data.activeId);
|
const doc = data.docs.find((d) => d.id === data.activeId);
|
||||||
if (!doc) return;
|
if (!doc) return;
|
||||||
const content = data.contents[doc.id] ?? "";
|
const content = data.contents[doc.id] ?? "";
|
||||||
if (content.trim()) {
|
if (content.trim()) {
|
||||||
|
|
@ -340,8 +255,8 @@ async function closeActiveDoc(): Promise<void> {
|
||||||
const idx = data.docs.findIndex((d) => d.id === doc.id);
|
const idx = data.docs.findIndex((d) => d.id === doc.id);
|
||||||
void backupDeletedSheet(settings.dataDir, doc.title, content);
|
void backupDeletedSheet(settings.dataDir, doc.title, content);
|
||||||
delete data.contents[doc.id];
|
delete data.contents[doc.id];
|
||||||
removeDocById(doc.id);
|
data.docs = data.docs.filter((d) => d.id !== doc.id);
|
||||||
const nextId = data.docs[idx] ? data.docs[idx].id : (data.docs[idx - 1]?.id ?? data.docs[0].id);
|
const nextId = data.docs[idx] ? data.docs[idx].id : data.docs[idx - 1]?.id ?? data.docs[0].id;
|
||||||
switchDoc(nextId);
|
switchDoc(nextId);
|
||||||
saveAppData(data);
|
saveAppData(data);
|
||||||
}
|
}
|
||||||
|
|
@ -349,21 +264,12 @@ async function closeActiveDoc(): Promise<void> {
|
||||||
// ---------- settings
|
// ---------- settings
|
||||||
|
|
||||||
function applySettings(): void {
|
function applySettings(): void {
|
||||||
const theme =
|
const theme = settings.theme === "system"
|
||||||
settings.theme === "system"
|
? (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light")
|
||||||
? matchMedia("(prefers-color-scheme: dark)").matches
|
: settings.theme;
|
||||||
? "dark"
|
|
||||||
: "light"
|
|
||||||
: settings.theme;
|
|
||||||
document.documentElement.dataset.theme = theme;
|
document.documentElement.dataset.theme = theme;
|
||||||
document.documentElement.style.setProperty(
|
document.documentElement.style.setProperty("--editor-font-size", `${settings.fontSize}px`);
|
||||||
"--editor-font-size",
|
$("#editor-wrap").style.setProperty("--results-width", `${settings.resultsWidth}%`);
|
||||||
`${settings.fontSize}px`,
|
|
||||||
);
|
|
||||||
$("#editor-wrap").style.setProperty(
|
|
||||||
"--results-width",
|
|
||||||
`${settings.resultsWidth}%`,
|
|
||||||
);
|
|
||||||
setLang(settings.language);
|
setLang(settings.language);
|
||||||
const searchInput = $<HTMLInputElement>("#search-input");
|
const searchInput = $<HTMLInputElement>("#search-input");
|
||||||
searchInput.placeholder = t("searchPlaceholder");
|
searchInput.placeholder = t("searchPlaceholder");
|
||||||
|
|
@ -376,10 +282,7 @@ function applySettings(): void {
|
||||||
});
|
});
|
||||||
editor?.refresh();
|
editor?.refresh();
|
||||||
syncTitleField();
|
syncTitleField();
|
||||||
$<HTMLElement>("#sidebar").classList.toggle(
|
$<HTMLElement>("#sidebar").classList.toggle("hidden", !settings.sidebarVisible);
|
||||||
"hidden",
|
|
||||||
!settings.sidebarVisible,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function bindFormattingSettings(): void {
|
function bindFormattingSettings(): void {
|
||||||
|
|
@ -403,10 +306,7 @@ function bindFormattingSettings(): void {
|
||||||
|
|
||||||
const save = () => {
|
const save = () => {
|
||||||
settings.theme = themeSel.value as SettingsData["theme"];
|
settings.theme = themeSel.value as SettingsData["theme"];
|
||||||
settings.precision = Math.max(
|
settings.precision = Math.max(0, Math.min(15, Number(precision.value) || 2));
|
||||||
0,
|
|
||||||
Math.min(15, Number(precision.value) || 2),
|
|
||||||
);
|
|
||||||
settings.groupSeparator = groupSep.value;
|
settings.groupSeparator = groupSep.value;
|
||||||
settings.decimalSeparator = decimalSep.value;
|
settings.decimalSeparator = decimalSep.value;
|
||||||
// "1,234,56" is unreadable — a comma decimal forces a space group separator
|
// "1,234,56" is unreadable — a comma decimal forces a space group separator
|
||||||
|
|
@ -416,27 +316,13 @@ function bindFormattingSettings(): void {
|
||||||
}
|
}
|
||||||
settings.dateFormat = dateFmt.value as SettingsData["dateFormat"];
|
settings.dateFormat = dateFmt.value as SettingsData["dateFormat"];
|
||||||
settings.language = langSel.value;
|
settings.language = langSel.value;
|
||||||
settings.fontSize = Math.max(
|
settings.fontSize = Math.max(10, Math.min(32, Number(fontSize.value) || 15));
|
||||||
10,
|
settings.resultsWidth = Math.max(20, Math.min(60, Number(resultsWidth.value) || 42));
|
||||||
Math.min(32, Number(fontSize.value) || 15),
|
|
||||||
);
|
|
||||||
settings.resultsWidth = Math.max(
|
|
||||||
20,
|
|
||||||
Math.min(60, Number(resultsWidth.value) || 42),
|
|
||||||
);
|
|
||||||
applySettings();
|
applySettings();
|
||||||
void saveSettings(settings);
|
void saveSettings(settings);
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const el of [
|
for (const el of [themeSel, precision, groupSep, decimalSep, dateFmt, langSel, fontSize]) {
|
||||||
themeSel,
|
|
||||||
precision,
|
|
||||||
groupSep,
|
|
||||||
decimalSep,
|
|
||||||
dateFmt,
|
|
||||||
langSel,
|
|
||||||
fontSize,
|
|
||||||
]) {
|
|
||||||
el.addEventListener("change", save);
|
el.addEventListener("change", save);
|
||||||
}
|
}
|
||||||
resultsWidth.addEventListener("input", save); // live while sliding
|
resultsWidth.addEventListener("input", save); // live while sliding
|
||||||
|
|
@ -515,10 +401,7 @@ function bindBehaviorSettings(): void {
|
||||||
void saveSettings(settings);
|
void saveSettings(settings);
|
||||||
});
|
});
|
||||||
binDays.addEventListener("change", () => {
|
binDays.addEventListener("change", () => {
|
||||||
settings.deletedRetentionDays = Math.max(
|
settings.deletedRetentionDays = Math.max(1, Math.min(365, Math.round(Number(binDays.value)) || 14));
|
||||||
1,
|
|
||||||
Math.min(365, Math.round(Number(binDays.value)) || 14),
|
|
||||||
);
|
|
||||||
binDays.value = String(settings.deletedRetentionDays);
|
binDays.value = String(settings.deletedRetentionDays);
|
||||||
void saveSettings(settings);
|
void saveSettings(settings);
|
||||||
void runBackups(settings.dataDir, settings.deletedRetentionDays); // prune right away
|
void runBackups(settings.dataDir, settings.deletedRetentionDays); // prune right away
|
||||||
|
|
@ -534,9 +417,7 @@ function bindBehaviorSettings(): void {
|
||||||
function bindDataDirSettings(): void {
|
function bindDataDirSettings(): void {
|
||||||
const dataDirBtn = $<HTMLButtonElement>("#set-datadir");
|
const dataDirBtn = $<HTMLButtonElement>("#set-datadir");
|
||||||
const renderDataDir = () => {
|
const renderDataDir = () => {
|
||||||
dataDirBtn.textContent = settings.dataDir
|
dataDirBtn.textContent = settings.dataDir ? settings.dataDir.split(/[\\/]/).pop() ?? settings.dataDir : t("defaultFolder");
|
||||||
? (settings.dataDir.split(/[\\/]/).pop() ?? settings.dataDir)
|
|
||||||
: t("defaultFolder");
|
|
||||||
dataDirBtn.title = settings.dataDir || t("defaultFolder");
|
dataDirBtn.title = settings.dataDir || t("defaultFolder");
|
||||||
};
|
};
|
||||||
renderDataDir();
|
renderDataDir();
|
||||||
|
|
@ -546,11 +427,7 @@ function bindDataDirSettings(): void {
|
||||||
let strategy: "move" | "overwrite" | "use_existing" = "move";
|
let strategy: "move" | "overwrite" | "use_existing" = "move";
|
||||||
try {
|
try {
|
||||||
if (await dataDirHasDocuments(picked)) {
|
if (await dataDirHasDocuments(picked)) {
|
||||||
const ans = await askModal(
|
const ans = await askModal(t("folderConflict"), t("useExisting"), t("replaceMine"));
|
||||||
t("folderConflict"),
|
|
||||||
t("useExisting"),
|
|
||||||
t("replaceMine"),
|
|
||||||
);
|
|
||||||
if (ans === null) return;
|
if (ans === null) return;
|
||||||
strategy = ans === "a" ? "use_existing" : "overwrite";
|
strategy = ans === "a" ? "use_existing" : "overwrite";
|
||||||
}
|
}
|
||||||
|
|
@ -559,7 +436,7 @@ function bindDataDirSettings(): void {
|
||||||
await flushAppData();
|
await flushAppData();
|
||||||
await migrateDataDir(settings.dataDir, picked, strategy);
|
await migrateDataDir(settings.dataDir, picked, strategy);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.warn("migrate failed", e);
|
console.warn("migrate failed", e);
|
||||||
toast(t("folderError"));
|
toast(t("folderError"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -571,8 +448,7 @@ function bindDataDirSettings(): void {
|
||||||
const fresh = await loadAppData(picked);
|
const fresh = await loadAppData(picked);
|
||||||
if (fresh && fresh.docs.length > 0) {
|
if (fresh && fresh.docs.length > 0) {
|
||||||
data = fresh;
|
data = fresh;
|
||||||
if (!data.docs.some((d) => d.id === data.activeId))
|
if (!data.docs.some((d) => d.id === data.activeId)) data.activeId = data.docs[0].id;
|
||||||
data.activeId = data.docs[0].id;
|
|
||||||
switchDoc(data.activeId);
|
switchDoc(data.activeId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -581,19 +457,12 @@ function bindDataDirSettings(): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
function bindSettingsNavigation(): void {
|
function bindSettingsNavigation(): void {
|
||||||
const tabs = document.querySelectorAll<HTMLButtonElement>(
|
const tabs = document.querySelectorAll<HTMLButtonElement>("#settings-tabs .tab");
|
||||||
"#settings-tabs .tab",
|
const tabPanels = document.querySelectorAll<HTMLElement>(".settings-tabpanel");
|
||||||
);
|
|
||||||
const tabPanels =
|
|
||||||
document.querySelectorAll<HTMLElement>(".settings-tabpanel");
|
|
||||||
for (const tab of tabs) {
|
for (const tab of tabs) {
|
||||||
tab.addEventListener("click", () => {
|
tab.addEventListener("click", () => {
|
||||||
for (const other of tabs) other.classList.toggle("active", other === tab);
|
for (const other of tabs) other.classList.toggle("active", other === tab);
|
||||||
for (const panel of tabPanels)
|
for (const panel of tabPanels) panel.classList.toggle("hidden", panel.dataset.tabpanel !== tab.dataset.tab);
|
||||||
panel.classList.toggle(
|
|
||||||
"hidden",
|
|
||||||
panel.dataset.tabpanel !== tab.dataset.tab,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -603,14 +472,8 @@ function bindSettingsNavigation(): void {
|
||||||
$("#close-settings").addEventListener("click", () => {
|
$("#close-settings").addEventListener("click", () => {
|
||||||
$("#settings-panel").classList.add("hidden");
|
$("#settings-panel").classList.add("hidden");
|
||||||
});
|
});
|
||||||
$("#open-extensions").addEventListener(
|
$("#open-extensions").addEventListener("click", () => void openExtensionsFolder());
|
||||||
"click",
|
$("#open-backups").addEventListener("click", () => void openBackupsFolder(settings.dataDir));
|
||||||
() => void openExtensionsFolder(),
|
|
||||||
);
|
|
||||||
$("#open-backups").addEventListener(
|
|
||||||
"click",
|
|
||||||
() => void openBackupsFolder(settings.dataDir),
|
|
||||||
);
|
|
||||||
$("#check-update").addEventListener("click", () => void checkUpdate(true));
|
$("#check-update").addEventListener("click", () => void checkUpdate(true));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -640,39 +503,18 @@ function normalizeKeyName(e: KeyboardEvent): string | null {
|
||||||
if (/^Numpad\d$/.test(code)) return code;
|
if (/^Numpad\d$/.test(code)) return code;
|
||||||
if (/^F\d{1,2}$/.test(code)) return code;
|
if (/^F\d{1,2}$/.test(code)) return code;
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
Space: "Space",
|
Space: "Space", Enter: "Enter", Backspace: "Backspace", Delete: "Delete",
|
||||||
Enter: "Enter",
|
Tab: "Tab", Home: "Home", End: "End", PageUp: "PageUp", PageDown: "PageDown",
|
||||||
Backspace: "Backspace",
|
ArrowUp: "Up", ArrowDown: "Down", ArrowLeft: "Left", ArrowRight: "Right",
|
||||||
Delete: "Delete",
|
Minus: "-", Equal: "=", Comma: ",", Period: ".", Slash: "/", Backquote: "`",
|
||||||
Tab: "Tab",
|
BracketLeft: "[", BracketRight: "]", Semicolon: ";", Quote: "'",
|
||||||
Home: "Home",
|
|
||||||
End: "End",
|
|
||||||
PageUp: "PageUp",
|
|
||||||
PageDown: "PageDown",
|
|
||||||
ArrowUp: "Up",
|
|
||||||
ArrowDown: "Down",
|
|
||||||
ArrowLeft: "Left",
|
|
||||||
ArrowRight: "Right",
|
|
||||||
Minus: "-",
|
|
||||||
Equal: "=",
|
|
||||||
Comma: ",",
|
|
||||||
Period: ".",
|
|
||||||
Slash: "/",
|
|
||||||
Backquote: "`",
|
|
||||||
BracketLeft: "[",
|
|
||||||
BracketRight: "]",
|
|
||||||
Semicolon: ";",
|
|
||||||
Quote: "'",
|
|
||||||
};
|
};
|
||||||
return map[code] ?? null;
|
return map[code] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- tauri integration
|
// ---------- tauri integration
|
||||||
|
|
||||||
async function registerHotkey(
|
async function registerHotkey(old: string | null, combo: string): Promise<boolean> {
|
||||||
old: string | null,
|
|
||||||
combo: string,
|
|
||||||
): Promise<boolean> {
|
|
||||||
if (!isTauri()) return true;
|
if (!isTauri()) return true;
|
||||||
try {
|
try {
|
||||||
const gs = await import("@tauri-apps/plugin-global-shortcut");
|
const gs = await import("@tauri-apps/plugin-global-shortcut");
|
||||||
|
|
@ -695,7 +537,7 @@ async function registerHotkey(
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.warn("hotkey registration failed", e);
|
console.warn("hotkey registration failed", e);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -707,16 +549,14 @@ async function applyAutostart(enabled: boolean): Promise<void> {
|
||||||
if (enabled) await auto.enable();
|
if (enabled) await auto.enable();
|
||||||
else await auto.disable();
|
else await auto.disable();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.warn("autostart failed", e);
|
console.warn("autostart failed", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function applyAlwaysOnTop(enabled: boolean): Promise<void> {
|
async function applyAlwaysOnTop(enabled: boolean): Promise<void> {
|
||||||
if (!isTauri()) return;
|
if (!isTauri()) return;
|
||||||
const { getCurrentWindow } = await import("@tauri-apps/api/window");
|
const { getCurrentWindow } = await import("@tauri-apps/api/window");
|
||||||
await getCurrentWindow()
|
await getCurrentWindow().setAlwaysOnTop(enabled).catch((e) => console.warn("always-on-top failed", e));
|
||||||
.setAlwaysOnTop(enabled)
|
|
||||||
.catch((e) => logger.warn("always-on-top failed", e));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshRates(force = false): Promise<void> {
|
async function refreshRates(force = false): Promise<void> {
|
||||||
|
|
@ -726,7 +566,7 @@ async function refreshRates(force = false): Promise<void> {
|
||||||
info.classList.remove("spin");
|
info.classList.remove("spin");
|
||||||
if (payload) {
|
if (payload) {
|
||||||
liveRates = Object.fromEntries(
|
liveRates = Object.fromEntries(
|
||||||
Object.entries(payload.rates).map(([k, v]) => [k, Number(v)]),
|
Object.entries(payload.rates).map(([k, v]) => [k, Number(v)])
|
||||||
);
|
);
|
||||||
applyAllRates();
|
applyAllRates();
|
||||||
workspace.invalidateAll();
|
workspace.invalidateAll();
|
||||||
|
|
@ -740,15 +580,7 @@ async function refreshRates(force = false): Promise<void> {
|
||||||
// ---------- market data
|
// ---------- market data
|
||||||
|
|
||||||
// Yahoo delisted Russian tickers (SBER/GAZP) in 2022 — dropped, they always miss.
|
// Yahoo delisted Russian tickers (SBER/GAZP) in 2022 — dropped, they always miss.
|
||||||
const MARKET_SYMBOLS = [
|
const MARKET_SYMBOLS = ["AAPL", "MSFT", "GOOGL", "AMZN", "TSLA", "NVDA", "META"];
|
||||||
"AAPL",
|
|
||||||
"MSFT",
|
|
||||||
"GOOGL",
|
|
||||||
"AMZN",
|
|
||||||
"TSLA",
|
|
||||||
"NVDA",
|
|
||||||
"META",
|
|
||||||
];
|
|
||||||
|
|
||||||
let marketPrices: Record<string, number> = {};
|
let marketPrices: Record<string, number> = {};
|
||||||
let marketFetchedAt = 0;
|
let marketFetchedAt = 0;
|
||||||
|
|
@ -764,7 +596,7 @@ function applyAllRates(): void {
|
||||||
async function refreshMarket(force = false): Promise<void> {
|
async function refreshMarket(force = false): Promise<void> {
|
||||||
if (!isTauri()) return;
|
if (!isTauri()) return;
|
||||||
const info = $("#market-info") as HTMLElement;
|
const info = $("#market-info") as HTMLElement;
|
||||||
info.classList.remove("d-none");
|
info.style.display = "";
|
||||||
info.classList.add("spin");
|
info.classList.add("spin");
|
||||||
info.textContent = t("market");
|
info.textContent = t("market");
|
||||||
const prices = await fetchMarketData(MARKET_SYMBOLS);
|
const prices = await fetchMarketData(MARKET_SYMBOLS);
|
||||||
|
|
@ -787,9 +619,7 @@ const HIST_DATE_RE = /(?:on|на)\s+(\d{4}-\d{2}-\d{2})/gi;
|
||||||
async function fetchNeededHistoricalRates(text: string): Promise<void> {
|
async function fetchNeededHistoricalRates(text: string): Promise<void> {
|
||||||
const dates = new Set<string>();
|
const dates = new Set<string>();
|
||||||
for (const m of text.matchAll(HIST_DATE_RE)) dates.add(m[1]!);
|
for (const m of text.matchAll(HIST_DATE_RE)) dates.add(m[1]!);
|
||||||
const datesToFetch = Array.from(dates).filter(
|
const datesToFetch = Array.from(dates).filter((date) => !engine.hasHistoricalRates(date));
|
||||||
(date) => !engine.hasHistoricalRates(date),
|
|
||||||
);
|
|
||||||
let fetched = false;
|
let fetched = false;
|
||||||
if (datesToFetch.length > 0) {
|
if (datesToFetch.length > 0) {
|
||||||
const results = await fetchHistoricalRatesBatch(datesToFetch);
|
const results = await fetchHistoricalRatesBatch(datesToFetch);
|
||||||
|
|
@ -801,19 +631,13 @@ async function fetchNeededHistoricalRates(text: string): Promise<void> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (fetched) {
|
if (fetched) { workspace.invalidateAll(); editor.refresh(); }
|
||||||
workspace.invalidateAll();
|
|
||||||
editor.refresh();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderMarketInfo(): void {
|
function renderMarketInfo(): void {
|
||||||
const el = $("#market-info") as HTMLElement;
|
const el = $("#market-info") as HTMLElement;
|
||||||
if (marketFetchedAt === 0) {
|
if (marketFetchedAt === 0) { el.style.display = "none"; return; }
|
||||||
el.classList.add("d-none");
|
el.style.display = "";
|
||||||
return;
|
|
||||||
}
|
|
||||||
el.classList.remove("d-none");
|
|
||||||
el.textContent = `${t("market")}: ${agoText(marketFetchedAt)} ↺`;
|
el.textContent = `${t("market")}: ${agoText(marketFetchedAt)} ↺`;
|
||||||
el.title = t("refreshRates");
|
el.title = t("refreshRates");
|
||||||
}
|
}
|
||||||
|
|
@ -831,15 +655,15 @@ function renderTotal(): void {
|
||||||
}
|
}
|
||||||
const total = engine.totalOf(results);
|
const total = engine.totalOf(results);
|
||||||
if (total) {
|
if (total) {
|
||||||
line.replaceChildren();
|
line.innerHTML = "";
|
||||||
const lbl = document.createElement("span");
|
const lbl = document.createElement("span");
|
||||||
lbl.className = "total-label";
|
lbl.className = "total-label";
|
||||||
lbl.textContent = label;
|
lbl.textContent = label;
|
||||||
line.append(lbl, document.createTextNode(total));
|
line.append(lbl, document.createTextNode(total));
|
||||||
line.dataset.value = total;
|
line.dataset.value = total;
|
||||||
line.classList.remove("d-none");
|
line.style.display = "";
|
||||||
} else {
|
} else {
|
||||||
line.classList.add("d-none");
|
line.style.display = "none";
|
||||||
delete line.dataset.value;
|
delete line.dataset.value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -848,8 +672,7 @@ function agoText(unixSec: number): string {
|
||||||
const s = Math.max(0, Math.floor(Date.now() / 1000) - unixSec);
|
const s = Math.max(0, Math.floor(Date.now() / 1000) - unixSec);
|
||||||
if (s < 90) return t("justNow");
|
if (s < 90) return t("justNow");
|
||||||
if (s < 3600) return t("minAgo").replace("{}", String(Math.round(s / 60)));
|
if (s < 3600) return t("minAgo").replace("{}", String(Math.round(s / 60)));
|
||||||
if (s < 86400)
|
if (s < 86400) return t("hourAgo").replace("{}", String(Math.round(s / 3600)));
|
||||||
return t("hourAgo").replace("{}", String(Math.round(s / 3600)));
|
|
||||||
return t("dayAgo").replace("{}", String(Math.round(s / 86400)));
|
return t("dayAgo").replace("{}", String(Math.round(s / 86400)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -871,21 +694,13 @@ let modalChain: Promise<unknown> = Promise.resolve();
|
||||||
/** two-button modal; resolves "a" | "b" | null (click outside = cancel).
|
/** two-button modal; resolves "a" | "b" | null (click outside = cancel).
|
||||||
calls serialize on the shared #modal singleton — a second call waits for
|
calls serialize on the shared #modal singleton — a second call waits for
|
||||||
the first to resolve rather than clobbering its handlers */
|
the first to resolve rather than clobbering its handlers */
|
||||||
function askModal(
|
function askModal(msg: string, aLabel: string, bLabel: string): Promise<"a" | "b" | null> {
|
||||||
msg: string,
|
|
||||||
aLabel: string,
|
|
||||||
bLabel: string,
|
|
||||||
): Promise<"a" | "b" | null> {
|
|
||||||
const run = modalChain.then(() => showModal(msg, aLabel, bLabel));
|
const run = modalChain.then(() => showModal(msg, aLabel, bLabel));
|
||||||
modalChain = run.catch(() => {});
|
modalChain = run.catch(() => {});
|
||||||
return run;
|
return run;
|
||||||
}
|
}
|
||||||
|
|
||||||
function showModal(
|
function showModal(msg: string, aLabel: string, bLabel: string): Promise<"a" | "b" | null> {
|
||||||
msg: string,
|
|
||||||
aLabel: string,
|
|
||||||
bLabel: string,
|
|
||||||
): Promise<"a" | "b" | null> {
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const modal = $("#modal");
|
const modal = $("#modal");
|
||||||
const a = $("#modal-a");
|
const a = $("#modal-a");
|
||||||
|
|
@ -906,8 +721,7 @@ function showModal(
|
||||||
// a click that just refocused the window (native dialog closing, or
|
// a click that just refocused the window (native dialog closing, or
|
||||||
// bringing a backgrounded tray window to front) lands on the backdrop
|
// bringing a backgrounded tray window to front) lands on the backdrop
|
||||||
// too — ignore a backdrop dismiss that arrives right after refocus
|
// too — ignore a backdrop dismiss that arrives right after refocus
|
||||||
if (e.target === modal && performance.now() - lastWindowFocusAt > 300)
|
if (e.target === modal && performance.now() - lastWindowFocusAt > 300) done(null);
|
||||||
done(null);
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -927,10 +741,7 @@ function toast(msg: string): void {
|
||||||
|
|
||||||
async function initSettings(): Promise<void> {
|
async function initSettings(): Promise<void> {
|
||||||
settings = await loadSettings();
|
settings = await loadSettings();
|
||||||
if (
|
if (!localStorage.getItem("summarum.langInit") && settings.language === "en") {
|
||||||
!localStorage.getItem("summarum.langInit") &&
|
|
||||||
settings.language === "en"
|
|
||||||
) {
|
|
||||||
settings.language = detectLang();
|
settings.language = detectLang();
|
||||||
localStorage.setItem("summarum.langInit", "1");
|
localStorage.setItem("summarum.langInit", "1");
|
||||||
}
|
}
|
||||||
|
|
@ -950,34 +761,19 @@ async function initDataAndEngine(): Promise<void> {
|
||||||
|
|
||||||
const stored = await loadAppData(settings.dataDir);
|
const stored = await loadAppData(settings.dataDir);
|
||||||
// a corrupt or foreign documents.json must not crash the boot
|
// a corrupt or foreign documents.json must not crash the boot
|
||||||
if (
|
if (stored && Array.isArray(stored.docs) && stored.docs.length > 0 && stored.contents && typeof stored.contents === "object") {
|
||||||
stored &&
|
|
||||||
Array.isArray(stored.docs) &&
|
|
||||||
stored.docs.length > 0 &&
|
|
||||||
stored.contents &&
|
|
||||||
typeof stored.contents === "object"
|
|
||||||
) {
|
|
||||||
data = stored;
|
data = stored;
|
||||||
} else {
|
} else {
|
||||||
const id = uid();
|
const id = uid();
|
||||||
data = {
|
data = { docs: [{ id, title: "Sample" }], activeId: id, contents: { [id]: welcomeText(settings.language) } };
|
||||||
docs: [{ id, title: "Sample" }],
|
|
||||||
activeId: id,
|
|
||||||
contents: { [id]: welcomeText(settings.language) },
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
if (!data.docs.some((d) => d.id === data.activeId))
|
if (!data.docs.some((d) => d.id === data.activeId)) data.activeId = data.docs[0].id;
|
||||||
data.activeId = data.docs[0].id;
|
|
||||||
|
|
||||||
const scripts = await loadExtensionScripts();
|
const scripts = await loadExtensionScripts();
|
||||||
await runExtensions(engine, scripts);
|
await runExtensions(engine, scripts);
|
||||||
|
|
||||||
workspace = new Workspace(engine, () =>
|
workspace = new Workspace(engine, () =>
|
||||||
data.docs.map((d) => ({
|
data.docs.map((d) => ({ id: d.id, title: d.title, text: data.contents[d.id] ?? "" })),
|
||||||
id: d.id,
|
|
||||||
title: d.title,
|
|
||||||
text: data.contents[d.id] ?? "",
|
|
||||||
})),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
editor = new SumEditor(
|
editor = new SumEditor(
|
||||||
|
|
@ -1020,12 +816,7 @@ async function initDataAndEngine(): Promise<void> {
|
||||||
search = initSearch({
|
search = initSearch({
|
||||||
engine,
|
engine,
|
||||||
workspace,
|
workspace,
|
||||||
docs: () =>
|
docs: () => data.docs.map((d) => ({ id: d.id, title: d.title, text: data.contents[d.id] ?? "" })),
|
||||||
data.docs.map((d) => ({
|
|
||||||
id: d.id,
|
|
||||||
title: d.title,
|
|
||||||
text: data.contents[d.id] ?? "",
|
|
||||||
})),
|
|
||||||
t,
|
t,
|
||||||
onOpen: (docId, line) => {
|
onOpen: (docId, line) => {
|
||||||
if (docId !== data.activeId) switchDoc(docId);
|
if (docId !== data.activeId) switchDoc(docId);
|
||||||
|
|
@ -1071,9 +862,7 @@ function bindMiscUI(): void {
|
||||||
|
|
||||||
$("#open-search").addEventListener("click", () => search.open());
|
$("#open-search").addEventListener("click", () => search.open());
|
||||||
|
|
||||||
window.addEventListener("focus", () => {
|
window.addEventListener("focus", () => { lastWindowFocusAt = performance.now(); });
|
||||||
lastWindowFocusAt = performance.now();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function bindWindowControls(): Promise<void> {
|
async function bindWindowControls(): Promise<void> {
|
||||||
|
|
@ -1083,7 +872,7 @@ async function bindWindowControls(): Promise<void> {
|
||||||
$("#win-min").addEventListener("click", () => void win.minimize());
|
$("#win-min").addEventListener("click", () => void win.minimize());
|
||||||
$("#win-close").addEventListener("click", () => void win.hide());
|
$("#win-close").addEventListener("click", () => void win.hide());
|
||||||
} else {
|
} else {
|
||||||
$("#win-controls").classList.add("d-none");
|
$("#win-controls").style.display = "none";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1098,11 +887,7 @@ function bindSidebarUI(): void {
|
||||||
document.addEventListener("mousedown", (e) => {
|
document.addEventListener("mousedown", (e) => {
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
const panel = $("#settings-panel");
|
const panel = $("#settings-panel");
|
||||||
if (
|
if (!panel.classList.contains("hidden") && !target.closest("#settings-panel") && !target.closest("#open-settings")) {
|
||||||
!panel.classList.contains("hidden") &&
|
|
||||||
!target.closest("#settings-panel") &&
|
|
||||||
!target.closest("#open-settings")
|
|
||||||
) {
|
|
||||||
panel.classList.add("hidden");
|
panel.classList.add("hidden");
|
||||||
}
|
}
|
||||||
if (!settings.sidebarVisible) return;
|
if (!settings.sidebarVisible) return;
|
||||||
|
|
@ -1131,9 +916,7 @@ function bindDividerUI(): void {
|
||||||
window.removeEventListener("mouseup", onUp);
|
window.removeEventListener("mouseup", onUp);
|
||||||
divider.classList.remove("dragging");
|
divider.classList.remove("dragging");
|
||||||
document.body.style.cursor = "";
|
document.body.style.cursor = "";
|
||||||
$<HTMLInputElement>("#set-resultswidth").value = String(
|
$<HTMLInputElement>("#set-resultswidth").value = String(settings.resultsWidth);
|
||||||
settings.resultsWidth,
|
|
||||||
);
|
|
||||||
void saveSettings(settings);
|
void saveSettings(settings);
|
||||||
};
|
};
|
||||||
window.addEventListener("mousemove", onMove);
|
window.addEventListener("mousemove", onMove);
|
||||||
|
|
@ -1176,9 +959,7 @@ function bindExportUI(): void {
|
||||||
});
|
});
|
||||||
document.addEventListener("click", () => exportMenu.classList.add("hidden"));
|
document.addEventListener("click", () => exportMenu.classList.add("hidden"));
|
||||||
exportMenu.addEventListener("click", async (e) => {
|
exportMenu.addEventListener("click", async (e) => {
|
||||||
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>(
|
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>("[data-action]");
|
||||||
"[data-action]",
|
|
||||||
);
|
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
exportMenu.classList.add("hidden");
|
exportMenu.classList.add("hidden");
|
||||||
const action = btn.dataset.action;
|
const action = btn.dataset.action;
|
||||||
|
|
@ -1200,10 +981,7 @@ function bindExportUI(): void {
|
||||||
const isSum = action === "save-sum";
|
const isSum = action === "save-sum";
|
||||||
const content = isSum ? editor.getText() : editor.getSheetWithResults();
|
const content = isSum ? editor.getText() : editor.getSheetWithResults();
|
||||||
try {
|
try {
|
||||||
const saved = await invoke<boolean>("write_text_file", {
|
const saved = await invoke<boolean>("write_text_file", { contents: content, isSum });
|
||||||
contents: content,
|
|
||||||
isSum,
|
|
||||||
});
|
|
||||||
if (saved) {
|
if (saved) {
|
||||||
toast(t("saved"));
|
toast(t("saved"));
|
||||||
}
|
}
|
||||||
|
|
@ -1253,15 +1031,11 @@ async function bindLifecycle(): Promise<void> {
|
||||||
// update check above only ever runs once at boot — recheck periodically
|
// update check above only ever runs once at boot — recheck periodically
|
||||||
// so a long-lived process still notices new releases; re-read the setting
|
// so a long-lived process still notices new releases; re-read the setting
|
||||||
// on every tick since the user can flip it while the app is running
|
// on every tick since the user can flip it while the app is running
|
||||||
setInterval(
|
setInterval(() => { if (settings.autoUpdateEnabled) void checkUpdate(); }, 6 * 60 * 60 * 1000);
|
||||||
() => {
|
|
||||||
if (settings.autoUpdateEnabled) void checkUpdate();
|
|
||||||
},
|
|
||||||
6 * 60 * 60 * 1000,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function initUI(): Promise<void> {
|
async function initUI(): Promise<void> {
|
||||||
|
|
||||||
applySettings(); // sets the language before bindSettingsUI renders dynamic labels
|
applySettings(); // sets the language before bindSettingsUI renders dynamic labels
|
||||||
bindSettingsUI();
|
bindSettingsUI();
|
||||||
renderDocList();
|
renderDocList();
|
||||||
|
|
@ -1295,11 +1069,7 @@ async function checkUpdate(manual = false): Promise<void> {
|
||||||
if (manual) toast(t("upToDate"));
|
if (manual) toast(t("upToDate"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const choice = await askModal(
|
const choice = await askModal(t("updateAvailable").replace("{}", result.version), t("updateInstall"), t("updateLater"));
|
||||||
t("updateAvailable").replace("{}", result.version),
|
|
||||||
t("updateInstall"),
|
|
||||||
t("updateLater"),
|
|
||||||
);
|
|
||||||
if (choice !== "a") return;
|
if (choice !== "a") return;
|
||||||
toast(t("updateInstalling"));
|
toast(t("updateInstalling"));
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { logger } from "./logger";
|
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
import { isTauri, loadSettings, saveSettings, defaultSettingsData, loadAppData, saveAppData, flushAppData, setDataDir, runBackups, backupDeletedSheet, openBackupsFolder, chooseFolder, dataDirHasDocuments, migrateDataDir, fetchRates, fetchHistoricalRates } from './storage';
|
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';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|
@ -15,7 +14,7 @@ describe('storage', () => {
|
||||||
getItem: vi.fn(),
|
getItem: vi.fn(),
|
||||||
setItem: vi.fn(),
|
setItem: vi.fn(),
|
||||||
});
|
});
|
||||||
vi.spyOn(logger, 'warn').mockImplementation(() => {});
|
vi.stubGlobal('console', { warn: vi.fn() });
|
||||||
vi.stubGlobal('fetch', vi.fn());
|
vi.stubGlobal('fetch', vi.fn());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -55,7 +54,7 @@ describe('storage', () => {
|
||||||
it('falls back to defaultSettingsData if localStorage throws', async () => {
|
it('falls back to defaultSettingsData if localStorage throws', async () => {
|
||||||
vi.mocked(localStorage.getItem).mockImplementation(() => { throw new Error('localStorage error'); });
|
vi.mocked(localStorage.getItem).mockImplementation(() => { throw new Error('localStorage error'); });
|
||||||
const settings = await loadSettings();
|
const settings = await loadSettings();
|
||||||
expect(logger.warn).toHaveBeenCalledWith('loadSettings failed', expect.any(Error));
|
expect(console.warn).toHaveBeenCalledWith('loadSettings failed', expect.any(Error));
|
||||||
expect(settings).toEqual(defaultSettingsData);
|
expect(settings).toEqual(defaultSettingsData);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -63,7 +62,7 @@ describe('storage', () => {
|
||||||
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
||||||
vi.mocked(invoke).mockRejectedValue(new Error('invoke error'));
|
vi.mocked(invoke).mockRejectedValue(new Error('invoke error'));
|
||||||
const settings = await loadSettings();
|
const settings = await loadSettings();
|
||||||
expect(logger.warn).toHaveBeenCalledWith('loadSettings failed', expect.any(Error));
|
expect(console.warn).toHaveBeenCalledWith('loadSettings failed', expect.any(Error));
|
||||||
expect(settings).toEqual(defaultSettingsData);
|
expect(settings).toEqual(defaultSettingsData);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -76,7 +75,7 @@ describe('storage', () => {
|
||||||
it('falls back to defaultSettingsData if JSON is invalid', async () => {
|
it('falls back to defaultSettingsData if JSON is invalid', async () => {
|
||||||
vi.mocked(localStorage.getItem).mockReturnValue("invalid json");
|
vi.mocked(localStorage.getItem).mockReturnValue("invalid json");
|
||||||
const settings = await loadSettings();
|
const settings = await loadSettings();
|
||||||
expect(logger.warn).toHaveBeenCalledWith('loadSettings failed', expect.any(Error));
|
expect(console.warn).toHaveBeenCalledWith('loadSettings failed', expect.any(Error));
|
||||||
expect(settings).toEqual(defaultSettingsData);
|
expect(settings).toEqual(defaultSettingsData);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -125,7 +124,7 @@ describe('storage', () => {
|
||||||
it('returns null if localStorage throws', async () => {
|
it('returns null if localStorage throws', async () => {
|
||||||
vi.mocked(localStorage.getItem).mockImplementation(() => { throw new Error('localStorage error'); });
|
vi.mocked(localStorage.getItem).mockImplementation(() => { throw new Error('localStorage error'); });
|
||||||
const data = await loadAppData('');
|
const data = await loadAppData('');
|
||||||
expect(logger.warn).toHaveBeenCalledWith('loadAppData failed', expect.any(Error));
|
expect(console.warn).toHaveBeenCalledWith('loadAppData failed', expect.any(Error));
|
||||||
expect(data).toBeNull();
|
expect(data).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -133,7 +132,7 @@ describe('storage', () => {
|
||||||
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
||||||
vi.mocked(invoke).mockRejectedValue(new Error('invoke error'));
|
vi.mocked(invoke).mockRejectedValue(new Error('invoke error'));
|
||||||
const data = await loadAppData('');
|
const data = await loadAppData('');
|
||||||
expect(logger.warn).toHaveBeenCalledWith('loadAppData failed', expect.any(Error));
|
expect(console.warn).toHaveBeenCalledWith('loadAppData failed', expect.any(Error));
|
||||||
expect(data).toBeNull();
|
expect(data).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -146,7 +145,7 @@ describe('storage', () => {
|
||||||
it('returns null if JSON is invalid', async () => {
|
it('returns null if JSON is invalid', async () => {
|
||||||
vi.mocked(localStorage.getItem).mockReturnValue("invalid json");
|
vi.mocked(localStorage.getItem).mockReturnValue("invalid json");
|
||||||
const data = await loadAppData('');
|
const data = await loadAppData('');
|
||||||
expect(logger.warn).toHaveBeenCalledWith('loadAppData failed', expect.any(Error));
|
expect(console.warn).toHaveBeenCalledWith('loadAppData failed', expect.any(Error));
|
||||||
expect(data).toBeNull();
|
expect(data).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -224,7 +223,7 @@ describe('storage', () => {
|
||||||
|
|
||||||
await flushAppData();
|
await flushAppData();
|
||||||
|
|
||||||
expect(logger.warn).toHaveBeenCalledWith('saveAppData failed', expect.any(Error));
|
expect(console.warn).toHaveBeenCalledWith('saveAppData failed', expect.any(Error));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles invoke throw gracefully in flushAppData', async () => {
|
it('handles invoke throw gracefully in flushAppData', async () => {
|
||||||
|
|
@ -235,7 +234,7 @@ describe('storage', () => {
|
||||||
|
|
||||||
await flushAppData();
|
await flushAppData();
|
||||||
|
|
||||||
expect(logger.warn).toHaveBeenCalledWith('saveAppData failed', expect.any(Error));
|
expect(console.warn).toHaveBeenCalledWith('saveAppData failed', expect.any(Error));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -256,7 +255,7 @@ describe('storage', () => {
|
||||||
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
||||||
vi.mocked(invoke).mockRejectedValue(new Error('invoke error'));
|
vi.mocked(invoke).mockRejectedValue(new Error('invoke error'));
|
||||||
await runBackups('dir', 10);
|
await runBackups('dir', 10);
|
||||||
expect(logger.warn).toHaveBeenCalledWith('runBackups failed', expect.any(Error));
|
expect(console.warn).toHaveBeenCalledWith('runBackups failed', expect.any(Error));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -282,7 +281,7 @@ describe('storage', () => {
|
||||||
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
||||||
vi.mocked(invoke).mockRejectedValue(new Error('invoke error'));
|
vi.mocked(invoke).mockRejectedValue(new Error('invoke error'));
|
||||||
await backupDeletedSheet('dir', 'title', 'contents');
|
await backupDeletedSheet('dir', 'title', 'contents');
|
||||||
expect(logger.warn).toHaveBeenCalledWith('backupDeletedSheet failed', expect.any(Error));
|
expect(console.warn).toHaveBeenCalledWith('backupDeletedSheet failed', expect.any(Error));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -370,7 +369,7 @@ describe('storage', () => {
|
||||||
it('returns null if fetch fails', async () => {
|
it('returns null if fetch fails', async () => {
|
||||||
vi.mocked(fetch).mockRejectedValue(new Error('Network error'));
|
vi.mocked(fetch).mockRejectedValue(new Error('Network error'));
|
||||||
const result = await fetchRates();
|
const result = await fetchRates();
|
||||||
expect(logger.warn).toHaveBeenCalledWith('fetchRates failed', expect.any(Error));
|
expect(console.warn).toHaveBeenCalledWith('fetchRates failed', expect.any(Error));
|
||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -405,7 +404,7 @@ describe('storage', () => {
|
||||||
it('returns null if fetch fails', async () => {
|
it('returns null if fetch fails', async () => {
|
||||||
vi.mocked(fetch).mockRejectedValue(new Error('Network error'));
|
vi.mocked(fetch).mockRejectedValue(new Error('Network error'));
|
||||||
const result = await fetchHistoricalRates('2024-01-01');
|
const result = await fetchHistoricalRates('2024-01-01');
|
||||||
expect(logger.warn).toHaveBeenCalledWith('fetchHistoricalRates failed', '2024-01-01', expect.any(Error));
|
expect(console.warn).toHaveBeenCalledWith('fetchHistoricalRates failed', '2024-01-01', expect.any(Error));
|
||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { logger } from "./logger";
|
|
||||||
// Storage: Tauri commands in the app, localStorage fallback for `vite dev`.
|
// Storage: Tauri commands in the app, localStorage fallback for `vite dev`.
|
||||||
|
|
||||||
export interface DocMeta {
|
export interface DocMeta {
|
||||||
|
|
@ -80,7 +79,7 @@ export async function loadSettings(): Promise<SettingsData> {
|
||||||
: localStorage.getItem("summarum.settings");
|
: localStorage.getItem("summarum.settings");
|
||||||
if (raw) return { ...defaultSettingsData, ...JSON.parse(raw) };
|
if (raw) return { ...defaultSettingsData, ...JSON.parse(raw) };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.warn("loadSettings failed", e);
|
console.warn("loadSettings failed", e);
|
||||||
}
|
}
|
||||||
return { ...defaultSettingsData };
|
return { ...defaultSettingsData };
|
||||||
}
|
}
|
||||||
|
|
@ -100,7 +99,7 @@ export async function loadAppData(dataDir: string): Promise<AppData | null> {
|
||||||
: localStorage.getItem("summarum.documents");
|
: localStorage.getItem("summarum.documents");
|
||||||
if (raw) return JSON.parse(raw);
|
if (raw) return JSON.parse(raw);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.warn("loadAppData failed", e);
|
console.warn("loadAppData failed", e);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -133,7 +132,7 @@ export async function flushAppData(): Promise<void> {
|
||||||
if (isTauri()) await invoke("save_documents", { dir: dirArg(currentDataDir), contents: raw });
|
if (isTauri()) await invoke("save_documents", { dir: dirArg(currentDataDir), contents: raw });
|
||||||
else localStorage.setItem("summarum.documents", raw);
|
else localStorage.setItem("summarum.documents", raw);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.warn("saveAppData failed", e);
|
console.warn("saveAppData failed", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -151,7 +150,7 @@ export async function runBackups(dataDir: string, retentionDays: number): Promis
|
||||||
try {
|
try {
|
||||||
await invoke("run_backups", { dir: dirArg(dataDir), retentionDays });
|
await invoke("run_backups", { dir: dirArg(dataDir), retentionDays });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.warn("runBackups failed", e);
|
console.warn("runBackups failed", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -160,7 +159,7 @@ export async function backupDeletedSheet(dataDir: string, title: string, content
|
||||||
try {
|
try {
|
||||||
await invoke("backup_deleted_sheet", { dir: dirArg(dataDir), title, contents });
|
await invoke("backup_deleted_sheet", { dir: dirArg(dataDir), title, contents });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.warn("backupDeletedSheet failed", e);
|
console.warn("backupDeletedSheet failed", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -206,7 +205,7 @@ export async function fetchRates(force = false): Promise<RatesPayload | null> {
|
||||||
return { date: json.time_last_update_utc, rates: json.rates, fetchedAt: Math.floor(Date.now() / 1000) };
|
return { date: json.time_last_update_utc, rates: json.rates, fetchedAt: Math.floor(Date.now() / 1000) };
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.warn("fetchRates failed", e);
|
console.warn("fetchRates failed", e);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -228,7 +227,7 @@ export async function fetchHistoricalRatesBatch(dates: string[]): Promise<Record
|
||||||
);
|
);
|
||||||
return results;
|
return results;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.warn("fetchHistoricalRatesBatch failed", e);
|
console.warn("fetchHistoricalRatesBatch failed", e);
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -247,7 +246,7 @@ export async function fetchHistoricalRates(date: string): Promise<Record<string,
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.warn("fetchHistoricalRates failed", date, e);
|
console.warn("fetchHistoricalRates failed", date, e);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -266,7 +265,7 @@ export async function fetchMarketData(symbols: string[]): Promise<Record<string,
|
||||||
try {
|
try {
|
||||||
return await invoke<Record<string, number>>("fetch_market_data", { symbols });
|
return await invoke<Record<string, number>>("fetch_market_data", { symbols });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.warn("fetchMarketData failed", e);
|
console.warn("fetchMarketData failed", e);
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -278,7 +277,7 @@ export async function loadExtensionScripts(): Promise<{ name: string; code: stri
|
||||||
try {
|
try {
|
||||||
return await invoke<{ name: string; code: string }[]>("load_extensions");
|
return await invoke<{ name: string; code: string }[]>("load_extensions");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.warn("loadExtensions failed", e);
|
console.warn("loadExtensions failed", e);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -315,7 +314,7 @@ export async function onFileDrop(cb: (content: string) => void): Promise<void> {
|
||||||
const content = await invoke<string>("read_text_file", { path });
|
const content = await invoke<string>("read_text_file", { path });
|
||||||
cb(content);
|
cb(content);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.warn("file drop rejected", path, e);
|
console.warn("file drop rejected", path, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -654,4 +654,3 @@ body {
|
||||||
}
|
}
|
||||||
.cm-tooltip-autocomplete ul li { font-family: var(--mono); font-size: 13px; color: var(--fg); }
|
.cm-tooltip-autocomplete ul li { font-family: var(--mono); font-size: 13px; color: var(--fg); }
|
||||||
.cm-tooltip-autocomplete ul li[aria-selected] { background: var(--sel); color: var(--fg); }
|
.cm-tooltip-autocomplete ul li[aria-selected] { background: var(--sel); color: var(--fg); }
|
||||||
.d-none { display: none !important; }
|
|
||||||
|
|
|
||||||
|
|
@ -75,8 +75,6 @@ const highlightField = StateField.define<DecorationSet>({
|
||||||
provide: (f) => EditorView.decorations.from(f),
|
provide: (f) => EditorView.decorations.from(f),
|
||||||
});
|
});
|
||||||
|
|
||||||
const varHighlightReCache = new Map<string, { assignment: RegExp; highlight: RegExp }>();
|
|
||||||
|
|
||||||
/** Highlights every occurrence of the variable under the cursor. */
|
/** Highlights every occurrence of the variable under the cursor. */
|
||||||
function buildVarHighlight(state: EditorState): DecorationSet {
|
function buildVarHighlight(state: EditorState): DecorationSet {
|
||||||
const builder = new RangeSetBuilder<Decoration>();
|
const builder = new RangeSetBuilder<Decoration>();
|
||||||
|
|
@ -92,21 +90,11 @@ function buildVarHighlight(state: EditorState): DecorationSet {
|
||||||
if (a === b) return builder.finish();
|
if (a === b) return builder.finish();
|
||||||
const word = text.slice(a, b);
|
const word = text.slice(a, b);
|
||||||
if (!/^[\p{L}_][\p{L}\d_]*$/u.test(word)) return builder.finish();
|
if (!/^[\p{L}_][\p{L}\d_]*$/u.test(word)) return builder.finish();
|
||||||
|
|
||||||
let cachedRe = varHighlightReCache.get(word);
|
|
||||||
if (!cachedRe) {
|
|
||||||
const esc = word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
||||||
cachedRe = {
|
|
||||||
assignment: new RegExp(`^\\s*${esc}\\s*=`, "mu"),
|
|
||||||
highlight: new RegExp(`(?<![\\p{L}\\d_])${esc}(?![\\p{L}\\d_])`, "gu"),
|
|
||||||
};
|
|
||||||
if (varHighlightReCache.size > 1000) varHighlightReCache.clear();
|
|
||||||
varHighlightReCache.set(word, cachedRe);
|
|
||||||
}
|
|
||||||
|
|
||||||
// only highlight if the word is actually assigned somewhere in the document
|
// only highlight if the word is actually assigned somewhere in the document
|
||||||
if (!cachedRe.assignment.test(text)) return builder.finish();
|
const esc = word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
for (const m of text.matchAll(cachedRe.highlight)) {
|
if (!new RegExp(`^\\s*${esc}\\s*=`, "mu").test(text)) return builder.finish();
|
||||||
|
const re = new RegExp(`(?<![\\p{L}\\d_])${esc}(?![\\p{L}\\d_])`, "gu");
|
||||||
|
for (const m of text.matchAll(re)) {
|
||||||
builder.add(m.index, m.index + word.length, Decoration.mark({ class: "tok-var-active" }));
|
builder.add(m.index, m.index + word.length, Decoration.mark({ class: "tok-var-active" }));
|
||||||
}
|
}
|
||||||
return builder.finish();
|
return builder.finish();
|
||||||
|
|
@ -170,7 +158,6 @@ export class SumEditor {
|
||||||
this.resultsEl = resultsEl;
|
this.resultsEl = resultsEl;
|
||||||
|
|
||||||
let engineOptions: { label: string; type: string; detail?: string }[] | null = null;
|
let engineOptions: { label: string; type: string; detail?: string }[] | null = null;
|
||||||
const varPattern = /^\s*([\p{L}_][\p{L}\d_]*)\s*=/u;
|
|
||||||
const completionSource = (ctx: CompletionContext): CompletionResult | null => {
|
const completionSource = (ctx: CompletionContext): CompletionResult | null => {
|
||||||
const word = ctx.matchBefore(/[\p{L}_]+$/u);
|
const word = ctx.matchBefore(/[\p{L}_]+$/u);
|
||||||
if (!word || (word.from === word.to && !ctx.explicit)) return null;
|
if (!word || (word.from === word.to && !ctx.explicit)) return null;
|
||||||
|
|
@ -183,15 +170,9 @@ export class SumEditor {
|
||||||
}));
|
}));
|
||||||
const options = [...engineOptions];
|
const options = [...engineOptions];
|
||||||
// document variables
|
// document variables
|
||||||
const iter = ctx.state.doc.iterLines();
|
const text = ctx.state.doc.toString();
|
||||||
for (let next = iter.next(); !next.done; next = iter.next()) {
|
for (const m of text.matchAll(/^\s*([\p{L}_][\p{L}\d_]*)\s*=/gmu)) {
|
||||||
const line = next.value;
|
options.push({ label: m[1], type: "variable" });
|
||||||
if (line.includes("=")) {
|
|
||||||
const m = varPattern.exec(line);
|
|
||||||
if (m) {
|
|
||||||
options.push({ label: m[1], type: "variable" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return { from: word.from, options, validFor: /^[\p{L}_]*$/u };
|
return { from: word.from, options, validFor: /^[\p{L}_]*$/u };
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,171 +0,0 @@
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
||||||
import { exportSheetImage } from './export';
|
|
||||||
import { writeImageFile } from '../storage';
|
|
||||||
|
|
||||||
vi.mock('../storage', () => ({
|
|
||||||
writeImageFile: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe('exportSheetImage', () => {
|
|
||||||
let mockContext2D: any;
|
|
||||||
let mockCanvas: any;
|
|
||||||
let toastMock: any;
|
|
||||||
let tMock: any;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
toastMock = vi.fn();
|
|
||||||
tMock = vi.fn((key: string) => `trans_${key}`);
|
|
||||||
|
|
||||||
mockContext2D = {
|
|
||||||
measureText: vi.fn().mockReturnValue({ width: 10 }),
|
|
||||||
scale: vi.fn(),
|
|
||||||
fillRect: vi.fn(),
|
|
||||||
fillText: vi.fn(),
|
|
||||||
font: '',
|
|
||||||
fillStyle: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
mockCanvas = {
|
|
||||||
getContext: vi.fn().mockReturnValue(mockContext2D),
|
|
||||||
toBlob: vi.fn((cb) => cb(new Blob(['fake-image-data'], { type: 'image/png' }))),
|
|
||||||
width: 0,
|
|
||||||
height: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
vi.stubGlobal('document', {
|
|
||||||
documentElement: {},
|
|
||||||
createElement: vi.fn((tag) => {
|
|
||||||
if (tag === 'canvas') return mockCanvas;
|
|
||||||
return {};
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.stubGlobal('window', {
|
|
||||||
devicePixelRatio: 0
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.stubGlobal('getComputedStyle', vi.fn().mockReturnValue({
|
|
||||||
getPropertyValue: vi.fn((prop) => {
|
|
||||||
if (prop === '--bg') return '';
|
|
||||||
if (prop === '--fg') return '';
|
|
||||||
if (prop === '--result') return '';
|
|
||||||
if (prop === '--mono') return '';
|
|
||||||
return '';
|
|
||||||
})
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.stubGlobal('Blob', class Blob {
|
|
||||||
content: any[];
|
|
||||||
options: any;
|
|
||||||
constructor(content: any[], options: any) {
|
|
||||||
this.content = content;
|
|
||||||
this.options = options;
|
|
||||||
}
|
|
||||||
async arrayBuffer() {
|
|
||||||
return new Uint8Array([1, 2, 3]).buffer;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.stubGlobal('ClipboardItem', class ClipboardItem {
|
|
||||||
constructor(data: any) {
|
|
||||||
Object.assign(this, data);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.stubGlobal('navigator', {
|
|
||||||
clipboard: {
|
|
||||||
write: vi.fn().mockResolvedValue(undefined)
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.stubGlobal('btoa', (str: string) => Buffer.from(str, 'binary').toString('base64'));
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create a canvas and copy it to clipboard successfully', async () => {
|
|
||||||
await exportSheetImage({
|
|
||||||
lines: ['1 + 1'],
|
|
||||||
results: [{ text: '2' }],
|
|
||||||
fontSize: 14,
|
|
||||||
toast: toastMock,
|
|
||||||
t: tMock
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(document.createElement).toHaveBeenCalledWith('canvas');
|
|
||||||
expect(mockCanvas.getContext).toHaveBeenCalledWith('2d');
|
|
||||||
expect(mockContext2D.fillText).toHaveBeenCalledWith('1 + 1', expect.any(Number), expect.any(Number));
|
|
||||||
expect(mockContext2D.fillText).toHaveBeenCalledWith('2', expect.any(Number), expect.any(Number));
|
|
||||||
|
|
||||||
expect(navigator.clipboard.write).toHaveBeenCalled();
|
|
||||||
expect(toastMock).toHaveBeenCalledWith('trans_imageCopied');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle blob failure', async () => {
|
|
||||||
mockCanvas.toBlob = vi.fn((cb) => cb(null));
|
|
||||||
|
|
||||||
await exportSheetImage({
|
|
||||||
lines: ['test'],
|
|
||||||
results: [{ text: null }],
|
|
||||||
fontSize: 14,
|
|
||||||
toast: toastMock,
|
|
||||||
t: tMock
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(toastMock).toHaveBeenCalledWith('trans_imageFailed');
|
|
||||||
expect(navigator.clipboard.write).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should fallback to writeImageFile when clipboard write fails', async () => {
|
|
||||||
navigator.clipboard.write = vi.fn().mockRejectedValue(new Error('clipboard fail'));
|
|
||||||
vi.mocked(writeImageFile).mockResolvedValue(true);
|
|
||||||
|
|
||||||
await exportSheetImage({
|
|
||||||
lines: ['fallback'],
|
|
||||||
results: [{ text: 'res' }],
|
|
||||||
fontSize: 14,
|
|
||||||
toast: toastMock,
|
|
||||||
t: tMock
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(navigator.clipboard.write).toHaveBeenCalled();
|
|
||||||
expect(writeImageFile).toHaveBeenCalled();
|
|
||||||
expect(writeImageFile).toHaveBeenCalledWith('AQID');
|
|
||||||
expect(toastMock).toHaveBeenCalledWith('trans_saved');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle writeImageFile failure in fallback', async () => {
|
|
||||||
navigator.clipboard.write = vi.fn().mockRejectedValue(new Error('clipboard fail'));
|
|
||||||
vi.mocked(writeImageFile).mockRejectedValue(new Error('save fail'));
|
|
||||||
|
|
||||||
await exportSheetImage({
|
|
||||||
lines: ['fail'],
|
|
||||||
results: [{ text: 'res' }],
|
|
||||||
fontSize: 14,
|
|
||||||
toast: toastMock,
|
|
||||||
t: tMock
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(writeImageFile).toHaveBeenCalled();
|
|
||||||
expect(toastMock).toHaveBeenCalledWith('trans_imageFailed');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle writeImageFile returning false', async () => {
|
|
||||||
navigator.clipboard.write = vi.fn().mockRejectedValue(new Error('clipboard fail'));
|
|
||||||
vi.mocked(writeImageFile).mockResolvedValue(false);
|
|
||||||
|
|
||||||
await exportSheetImage({
|
|
||||||
lines: ['fail'],
|
|
||||||
results: [{ text: 'res' }],
|
|
||||||
fontSize: 14,
|
|
||||||
toast: toastMock,
|
|
||||||
t: tMock
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(writeImageFile).toHaveBeenCalled();
|
|
||||||
expect(toastMock).not.toHaveBeenCalledWith('trans_saved');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,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;
|
open(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const $ = <T extends HTMLElement>(sel: string): T =>
|
const $ = <T extends HTMLElement>(sel: string): T => document.querySelector(sel) as T;
|
||||||
document.querySelector(sel) as T;
|
|
||||||
|
|
||||||
export function parseResultQuery(
|
function parseResultQuery(engine: SumEngine, q: string): { op: string; threshold: Value } | null {
|
||||||
engine: SumEngine,
|
|
||||||
q: string,
|
|
||||||
): { op: string; threshold: Value } | null {
|
|
||||||
const m = /^(>=|<=|>|<|=|~)\s*(.+)$/.exec(q.trim());
|
const m = /^(>=|<=|>|<|=|~)\s*(.+)$/.exec(q.trim());
|
||||||
if (!m) return null;
|
if (!m) return null;
|
||||||
const v = engine.evaluateExpression(m[2].trim());
|
const v = engine.evaluateExpression(m[2].trim());
|
||||||
|
|
@ -44,7 +40,7 @@ export function parseResultQuery(
|
||||||
return { op: m[1], threshold: v };
|
return { op: m[1], threshold: v };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function searchAllSheets(deps: SearchDeps, query: string): SearchHit[] {
|
function searchAllSheets(deps: SearchDeps, query: string): SearchHit[] {
|
||||||
const q = query.trim();
|
const q = query.trim();
|
||||||
if (!q) return [];
|
if (!q) return [];
|
||||||
const docs = deps.docs();
|
const docs = deps.docs();
|
||||||
|
|
@ -54,41 +50,22 @@ export function searchAllSheets(deps: SearchDeps, query: string): SearchHit[] {
|
||||||
const hits: SearchHit[] = [];
|
const hits: SearchHit[] = [];
|
||||||
for (const doc of docs) {
|
for (const doc of docs) {
|
||||||
const results = deps.workspace.getCachedResults(doc);
|
const results = deps.workspace.getCachedResults(doc);
|
||||||
let lines: string[] | undefined;
|
const lines = doc.text.split("\n");
|
||||||
for (let i = 0; i < results.length; i++) {
|
for (let i = 0; i < results.length; i++) {
|
||||||
const r = results[i];
|
const r = results[i];
|
||||||
if (!r.value || r.value.kind !== "quantity") continue;
|
if (!r.value || r.value.kind !== "quantity") continue;
|
||||||
const v = r.value.value;
|
const v = r.value.value;
|
||||||
let match = false;
|
let match = false;
|
||||||
switch (rq.op) {
|
switch (rq.op) {
|
||||||
case ">":
|
case ">": match = v.gt(th); break;
|
||||||
match = v.gt(th);
|
case ">=": match = v.gte(th); break;
|
||||||
break;
|
case "<": match = v.lt(th); break;
|
||||||
case ">=":
|
case "<=": match = v.lte(th); break;
|
||||||
match = v.gte(th);
|
case "=": match = v.eq(th); break;
|
||||||
break;
|
case "~": match = !th.isZero() && v.minus(th).abs().div(th.abs()).lte(0.01); break;
|
||||||
case "<":
|
|
||||||
match = v.lt(th);
|
|
||||||
break;
|
|
||||||
case "<=":
|
|
||||||
match = v.lte(th);
|
|
||||||
break;
|
|
||||||
case "=":
|
|
||||||
match = v.eq(th);
|
|
||||||
break;
|
|
||||||
case "~":
|
|
||||||
match = !th.isZero() && v.minus(th).abs().div(th.abs()).lte(0.01);
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
if (match) {
|
if (match) {
|
||||||
if (!lines) lines = doc.text.split("\n");
|
hits.push({ docId: doc.id, docTitle: doc.title, line: i + 1, text: lines[i] ?? "", result: r.text ?? undefined });
|
||||||
hits.push({
|
|
||||||
docId: doc.id,
|
|
||||||
docTitle: doc.title,
|
|
||||||
line: i + 1,
|
|
||||||
text: lines[i] ?? "",
|
|
||||||
result: r.text ?? undefined,
|
|
||||||
});
|
|
||||||
if (hits.length >= 200) return hits;
|
if (hits.length >= 200) return hits;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -101,12 +78,7 @@ export function searchAllSheets(deps: SearchDeps, query: string): SearchHit[] {
|
||||||
const lines = doc.text.split("\n");
|
const lines = doc.text.split("\n");
|
||||||
for (let i = 0; i < lines.length; i++) {
|
for (let i = 0; i < lines.length; i++) {
|
||||||
if (lines[i].toLowerCase().includes(ql)) {
|
if (lines[i].toLowerCase().includes(ql)) {
|
||||||
hits.push({
|
hits.push({ docId: doc.id, docTitle: doc.title, line: i + 1, text: lines[i] });
|
||||||
docId: doc.id,
|
|
||||||
docTitle: doc.title,
|
|
||||||
line: i + 1,
|
|
||||||
text: lines[i],
|
|
||||||
});
|
|
||||||
if (hits.length >= 200) return hits;
|
if (hits.length >= 200) return hits;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -141,9 +113,7 @@ export function initSearch(deps: SearchDeps): SearchController {
|
||||||
docEl.textContent = hit.docTitle;
|
docEl.textContent = hit.docTitle;
|
||||||
const lineEl = document.createElement("div");
|
const lineEl = document.createElement("div");
|
||||||
lineEl.className = "line";
|
lineEl.className = "line";
|
||||||
lineEl.textContent = hit.result
|
lineEl.textContent = hit.result ? `${hit.text} = ${hit.result}` : hit.text;
|
||||||
? `${hit.text} = ${hit.result}`
|
|
||||||
: hit.text;
|
|
||||||
item.append(docEl, lineEl);
|
item.append(docEl, lineEl);
|
||||||
item.addEventListener("click", () => {
|
item.addEventListener("click", () => {
|
||||||
deps.onOpen(hit.docId, hit.line);
|
deps.onOpen(hit.docId, hit.line);
|
||||||
|
|
|
||||||
|
|
@ -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,4 +1,3 @@
|
||||||
import { logger } from "./logger";
|
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { checkForUpdate } from './updater';
|
import { checkForUpdate } from './updater';
|
||||||
import * as storage from './storage';
|
import * as storage from './storage';
|
||||||
|
|
@ -23,8 +22,8 @@ vi.mock('@tauri-apps/plugin-process', () => ({
|
||||||
describe('checkForUpdate', () => {
|
describe('checkForUpdate', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
vi.spyOn(logger, 'info').mockImplementation(() => {});
|
vi.spyOn(console, 'info').mockImplementation(() => {});
|
||||||
vi.spyOn(logger, 'warn').mockImplementation(() => {});
|
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return null if not in Tauri', async () => {
|
it('should return null if not in Tauri', async () => {
|
||||||
|
|
@ -40,7 +39,7 @@ describe('checkForUpdate', () => {
|
||||||
|
|
||||||
const result = await checkForUpdate();
|
const result = await checkForUpdate();
|
||||||
expect(result).toBe('error');
|
expect(result).toBe('error');
|
||||||
expect(logger.warn).toHaveBeenCalledWith('update check failed', expect.any(Error));
|
expect(console.warn).toHaveBeenCalledWith('update check failed', expect.any(Error));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return null if no update is available', async () => {
|
it('should return null if no update is available', async () => {
|
||||||
|
|
@ -53,7 +52,7 @@ describe('checkForUpdate', () => {
|
||||||
|
|
||||||
const result = await checkForUpdate();
|
const result = await checkForUpdate();
|
||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
expect(logger.info).toHaveBeenCalledWith("no update: running 1.0.0, that's the latest published release");
|
expect(console.info).toHaveBeenCalledWith("no update: running 1.0.0, that's the latest published release");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return an update object if an update is available', async () => {
|
it('should return an update object if an update is available', async () => {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { logger } from "./logger";
|
|
||||||
import { isTauri } from "./storage";
|
import { isTauri } from "./storage";
|
||||||
|
|
||||||
interface AvailableUpdate {
|
interface AvailableUpdate {
|
||||||
|
|
@ -20,10 +19,10 @@ export async function checkForUpdate(): Promise<AvailableUpdate | null | "error"
|
||||||
// visible in devtools console — the fastest way to tell "already on
|
// visible in devtools console — the fastest way to tell "already on
|
||||||
// the latest published release" apart from "the check silently failed"
|
// the latest published release" apart from "the check silently failed"
|
||||||
const { getVersion } = await import("@tauri-apps/api/app");
|
const { getVersion } = await import("@tauri-apps/api/app");
|
||||||
logger.info(`no update: running ${await getVersion()}, that's the latest published release`);
|
console.info(`no update: running ${await getVersion()}, that's the latest published release`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
logger.info(`update available: ${update.currentVersion} -> ${update.version}`);
|
console.info(`update available: ${update.currentVersion} -> ${update.version}`);
|
||||||
return {
|
return {
|
||||||
version: update.version,
|
version: update.version,
|
||||||
async install() {
|
async install() {
|
||||||
|
|
@ -33,7 +32,7 @@ export async function checkForUpdate(): Promise<AvailableUpdate | null | "error"
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.warn("update check failed", e);
|
console.warn("update check failed", e);
|
||||||
return "error";
|
return "error";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,19 +44,6 @@ describe("Workspace exports", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Workspace error handling", () => {
|
|
||||||
it("cleans up the resolving set if evaluation throws an unexpected error", () => {
|
|
||||||
const { ws, engine } = makeWorkspace({
|
|
||||||
a: { title: "A", text: "1 + 1" },
|
|
||||||
});
|
|
||||||
vi.spyOn(engine, "evaluateDocument").mockImplementation(() => {
|
|
||||||
throw new Error("mocked crash");
|
|
||||||
});
|
|
||||||
expect(() => ws.evaluateSheet("a", "1 + 1")).toThrowError("mocked crash");
|
|
||||||
expect((ws as any).resolving.has("a")).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Workspace invalidation", () => {
|
describe("Workspace invalidation", () => {
|
||||||
it("invalidating a sheet also invalidates its dependents, not unrelated sheets", () => {
|
it("invalidating a sheet also invalidates its dependents, not unrelated sheets", () => {
|
||||||
const { ws, store } = makeWorkspace({
|
const { ws, store } = makeWorkspace({
|
||||||
|
|
|
||||||
|
|
@ -54,11 +54,10 @@ export class Workspace {
|
||||||
invalidate(sheetId: string): void {
|
invalidate(sheetId: string): void {
|
||||||
this.titleCache = null;
|
this.titleCache = null;
|
||||||
const dirty = new Set<string>([sheetId]);
|
const dirty = new Set<string>([sheetId]);
|
||||||
const sheetsList = this.sheets();
|
|
||||||
let changed = true;
|
let changed = true;
|
||||||
while (changed) {
|
while (changed) {
|
||||||
changed = false;
|
changed = false;
|
||||||
for (const s of sheetsList) {
|
for (const s of this.sheets()) {
|
||||||
if (dirty.has(s.id)) continue;
|
if (dirty.has(s.id)) continue;
|
||||||
for (const title of this.referencedTitles(s.text, s.id)) {
|
for (const title of this.referencedTitles(s.text, s.id)) {
|
||||||
const target = this.findSheetByTitle(title);
|
const target = this.findSheetByTitle(title);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue