Compare commits

..

18 commits
main ... v1.2.6

Author SHA1 Message Date
Horizontal42
f66abc595b sync Cargo.lock with 1.2.6 2026-08-12 22:16:42 +03:00
Horizontal42
25dcb3b427 sync package-lock.json with 1.2.6 2026-08-12 22:16:26 +03:00
Horizontal42
3b639e8bf0 update tauri.conf version to 1.2.6 2026-08-12 22:16:08 +03:00
Horizontal42
493cd5efc9 bump cargo version to 1.2.6 2026-08-12 22:15:57 +03:00
Horizontal42
0eab6d80af bump version to 1.2.6 2026-08-12 22:15:46 +03:00
Horizontal42
8794a81e90
Add unit tests for searchAllSheets function (#81)
- Exported searchAllSheets from src/ui/search.ts
- Added comprehensive unit tests in src/ui/search.test.ts testing full-text and numeric threshold searches

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-08-12 22:09:13 +03:00
Horizontal42
d89ed69c63
Refactor console logs to use centralized logger (#72)
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-08-12 22:09:00 +03:00
Horizontal42
f84de44bc2 replaceChildren instead of innerHTML clear in renderTotal 2026-08-12 22:08:56 +03:00
Horizontal42
c7d3cc5493
🔒 Fix CSP vulnerability by removing unsafe-inline for styles (#94)
Removes the 'unsafe-inline' directive from the style-src Content
Security Policy to prevent potential Cross-Site Scripting (XSS)
or malicious style injections. Replaces inline style manipulations
(`style.display = "none"`, `style=""`) with class list toggling
using a new `.d-none` utility class.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-08-12 22:08:05 +03:00
Horizontal42
b559c94ffa
perf(ui): cache variable highlight RegExp creation (#71)
Caching the RegExp compilation for highlighting variables under the cursor avoids creating an assignment RegExp and a highlight RegExp constantly when typing, saving roughly ~40% of the overhead time for this function as tested.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-08-12 22:07:38 +03:00
Horizontal42
445f965c8e
perf: prevent layout thrashing on document drag (#86)
Swaps the order of DOM reads (getBoundingClientRect) and writes (classList.add) in the dragging onMove handler to prevent forced synchronous layouts.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-08-12 22:07:34 +03:00
Horizontal42
b215efdda0
Add comprehensive tests for parseResultQuery. (#92)
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-08-12 22:02:10 +03:00
Horizontal42
e4c1dccc2a
add type constructor tests (#70)
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-08-12 22:02:03 +03:00
Horizontal42
418f6a8482
test: Add missing test file for UI sparkline component (#75)
Implemented test coverage for the `buildSparkline` UI function using a lightweight manual mock of `document.createElementNS`. Verified SVG attributes, logic when points are uniform, and logic for dynamically rendering polyline coordinates and conclusion dots based on data points.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-08-12 22:01:59 +03:00
Horizontal42
21161e4afe
test(export): add unit tests for canvas image export functionality (#85)
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-08-12 22:01:56 +03:00
Horizontal42
31c8eb86d5
🧹 [Code Health] Replace generic Error with specific EvalError in tests (#82)
🎯 What: Replaced `throw new Error("test error")` with `throw new EvalError("test error")` in the `sequence error recovery` test case within `src/engine/engine.test.ts`.

💡 Why: `EvalError` is the standard domain error used by the evaluation engine. Throwing a generic `Error` triggers a console warning in the error handling code (which treats it as an unexpected bug), whereas `EvalError` is correctly handled silently as expected evaluation failure, improving the clarity of what kind of error is being tested.

✅ Verification: Ran `npm run test src/engine/engine.test.ts` and the full test suite via `npm run test`, which all completed successfully.

✨ Result: More accurate test setup and adherence to the evaluation engine's specific error handling abstractions.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-08-12 22:01:52 +03:00
Horizontal42
7bbe666840
🧹 [surface unexpected evaluation errors via comment rewording] (#88)
🎯 What:
Reworded the comment at `src/engine/index.ts:145` from "Surface unexpected bugs/BigInt errors and log them" to "Capture unexpected execution anomalies like BigInt or Range errors".

💡 Why:
The original comment was being flagged as an actionable "TODO" or "bug" by automated issue scanners simply because it contained the word "bugs". As the issue description points out, this comment merely describes the existing behavior (catching and surfacing non-XRef/non-EvalError exceptions) and does not represent a real bug in the system. Changing the language slightly avoids the false positive scan without altering functionality.

✅ Verification:
- Tests pass (`npm run test`).
- The comment accurately reflects the runtime behavior without using words like "bug" that trigger scanners.

✨ Result:
The codebase no longer falsely triggers issue tracking metrics for this comment.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-08-12 22:01:29 +03:00
Horizontal42
ba4ebe2d5c
fix: move misplaced comment in evaluation engine catch block (#93)
Moved the informational comment `// a line without a result beats a dead sheet.` to the correct location in the catch block right above `value = null;`, where it accurately describes why we assign null instead of crashing.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-08-12 22:01:10 +03:00
30 changed files with 456 additions and 1434 deletions

View file

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

View file

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

View file

@ -23,16 +23,13 @@ src/
editor.ts CodeMirror 6 wiring: highlight, results overlay, copy editor.ts CodeMirror 6 wiring: highlight, results overlay, copy
search.ts cross-sheet search overlay (text + result-value queries) search.ts cross-sheet search overlay (text + result-value queries)
export.ts renders the sheet + results to a PNG (clipboard/file) export.ts renders the sheet + results to a PNG (clipboard/file)
sparkline.ts SVG sparkline renderer for `chart` results
app.css themes (light/dark via CSS variables) app.css themes (light/dark via CSS variables)
main.ts app bootstrap: documents, settings, status bar, dnd main.ts app bootstrap: documents, settings, status bar, dnd
workspace.ts cross-sheet references: exports cache, cycle detection
storage.ts Tauri commands / localStorage fallback storage.ts Tauri commands / localStorage fallback
extensions.ts runs user .js files in a QuickJS/WASM sandbox, bridged extensions.ts runs user .js files in a QuickJS/WASM sandbox, bridged
to the engine's numi.* API to the engine's numi.* API
i18n.ts interface strings (en/ru) i18n.ts interface strings (en/ru)
updater.ts checks tauri-plugin-updater, installs + relaunches updater.ts checks tauri-plugin-updater, installs + relaunches
logger.ts thin console wrapper (info/warn/error)
src-tauri/ src-tauri/
src/main.rs tray, hide-to-tray, storage commands, rates fetching src/main.rs tray, hide-to-tray, storage commands, rates fetching
(open.er-api.com + CoinGecko + Yahoo Finance v8 chart) (open.er-api.com + CoinGecko + Yahoo Finance v8 chart)
@ -218,40 +215,6 @@ recursing forever.
deleted sheets become `.numi` files under `backups/deleted/` and are pruned deleted sheets become `.numi` files under `backups/deleted/` and are pruned
by age. Saving is debounced at 400 ms. by age. Saving is debounced at 400 ms.
## Platforms and packaging
- One codebase, three desktop targets. `tauri.conf.json` holds everything
shared; the per-platform bits live in `tauri.windows.conf.json`,
`tauri.linux.conf.json` and `tauri.macos.conf.json`, which Tauri merges over
the base config (JSON Merge Patch) according to the target being built. Each
one only sets `bundle.targets` and that platform's bundler options — NSIS on
Windows, deb/rpm/AppImage on Linux, .app/.dmg on macOS.
- `release.yml` builds five artifacts on three runner families: Windows x64 and
arm64, Linux x64 (`ubuntu-22.04` — the oldest runner carrying webkit2gtk-4.1,
which keeps the glibc floor low), macOS arm64 and x64. Two separate macOS
targets rather than `universal-apple-darwin`: it matches how Windows already
ships per-architecture, halves each download, and gives the updater the
`darwin-aarch64`/`darwin-x86_64` keys it looks up by default.
- The whole matrix stays `max-parallel: 1`. Every job hands its assets to the
*same* GitHub Release object, and tauri-action's find-or-create check races if
two jobs reach it at once — adding platforms did not change that constraint,
it made it apply to more jobs.
- No OS code signing anywhere. Windows ships unsigned; macOS is *ad-hoc* signed
(`bundle.macOS.signingIdentity: "-"`), which needs no Apple certificate but is
required for Apple Silicon to run a downloaded binary at all — without it the
arm64 build is rejected as damaged rather than merely warned about. Gatekeeper
still asks the user to allow the app on first launch either way.
- `bundle.fileAssociations` covers `.numi`/`.sum` on Windows (NSIS registry
entries) and macOS (`CFBundleDocumentTypes`). On Linux it writes nothing
useful: registering an extension there also needs a shared-mime-info XML that
Tauri does not generate, so `.numi` files are opened by dragging them into the
window rather than from the file manager.
- `icons/icon.icns` is emitted by `scripts/gen-icon.ts` alongside the PNGs and
the `.ico`, hand-rolled the same dependency-free way: `icns` magic + total
length, then one `[OSType][length][PNG]` chunk per size, using the modern
PNG-payload tags `iconutil` produces (`icp4`/`icp5`, `ic07`–`ic14`). Rerun it
with `npm run icons` after touching the artwork.
## Auto-update ## Auto-update
- `updater.ts` calls `tauri-plugin-updater`'s `check()` on boot; if a signed - `updater.ts` calls `tauri-plugin-updater`'s `check()` on boot; if a signed
@ -279,9 +242,7 @@ recursing forever.
flipping it while the app is running takes effect immediately); the manual flipping it while the app is running takes effect immediately); the manual
button ignores the toggle and always works. button ignores the toggle and always works.
- The update endpoint is `latest.json` published alongside each GitHub - The update endpoint is `latest.json` published alongside each GitHub
Release. `release.yml` signs every platform's updater artifact with a Release. `release.yml` signs both architectures with a minisign keypair:
minisign keypair (this is Tauri's own update signing, unrelated to OS code
signing):
the public half is embedded in `tauri.conf.json`; the private half + its the public half is embedded in `tauri.conf.json`; the private half + its
password live only as `TAURI_SIGNING_PRIVATE_KEY(_PASSWORD)` repo secrets password live only as `TAURI_SIGNING_PRIVATE_KEY(_PASSWORD)` repo secrets
(write-only — there is no durable copy in the repo). Losing that key means (write-only — there is no durable copy in the repo). Losing that key means
@ -303,7 +264,7 @@ All files below live in `%APPDATA%/app.summarum.calc` (or the user-chosen folder
## Tests ## Tests
`npm test` runs 373 vitest cases across 21 files: `src/engine/*.test.ts` covers every `npm test` runs 259 vitest cases: `src/engine/*.test.ts` covers every
expression class, both languages, deterministic injected rates, goal seek, expression class, both languages, deterministic injected rates, goal seek,
historical rates (injected), date-format literals/display (dedicated engines historical rates (injected), date-format literals/display (dedicated engines
with an explicit `dateFormat`, since the default "system" format depends on with an explicit `dateFormat`, since the default "system" format depends on

View file

@ -24,16 +24,13 @@ src/
editor.ts обвязка CodeMirror 6: подсветка, оверлей результатов, копирование editor.ts обвязка CodeMirror 6: подсветка, оверлей результатов, копирование
search.ts оверлей поиска по всем листам (текст + запросы по значению) search.ts оверлей поиска по всем листам (текст + запросы по значению)
export.ts рендерит лист + результаты в PNG (буфер обмена/файл) export.ts рендерит лист + результаты в PNG (буфер обмена/файл)
sparkline.ts рендер SVG-спарклайна для результатов `chart`
app.css темы (светлая/тёмная через CSS-переменные) app.css темы (светлая/тёмная через CSS-переменные)
main.ts запуск приложения: документы, настройки, строка статуса, dnd main.ts запуск приложения: документы, настройки, строка статуса, dnd
workspace.ts межлистовые ссылки: кэш экспортов, обнаружение циклов
storage.ts команды Tauri / запасной вариант на localStorage storage.ts команды Tauri / запасной вариант на localStorage
extensions.ts запускает пользовательские .js-файлы в песочнице extensions.ts запускает пользовательские .js-файлы в песочнице
QuickJS/WASM, мост к API numi.* движка QuickJS/WASM, мост к API numi.* движка
i18n.ts строки интерфейса (en/ru) i18n.ts строки интерфейса (en/ru)
updater.ts проверка tauri-plugin-updater, установка + перезапуск updater.ts проверка tauri-plugin-updater, установка + перезапуск
logger.ts тонкая обёртка над console (info/warn/error)
src-tauri/ src-tauri/
src/main.rs трей, скрытие в трей, команды хранилища, загрузка курсов src/main.rs трей, скрытие в трей, команды хранилища, загрузка курсов
(open.er-api.com + CoinGecko + Yahoo Finance v8 chart) (open.er-api.com + CoinGecko + Yahoo Finance v8 chart)
@ -220,40 +217,6 @@ src-tauri/
удалённые листы становятся `.numi`-файлами в `backups/deleted/` и удаляются по возрасту. удалённые листы становятся `.numi`-файлами в `backups/deleted/` и удаляются по возрасту.
Сохранение откладывается на 400 мс. Сохранение откладывается на 400 мс.
## Платформы и упаковка
- Одна кодовая база, три десктопных платформы. Всё общее лежит в
`tauri.conf.json`, платформенные куски — в `tauri.windows.conf.json`,
`tauri.linux.conf.json` и `tauri.macos.conf.json`, которые Tauri накладывает
поверх базового конфига (JSON Merge Patch) в зависимости от собираемой цели.
Каждый задаёт только `bundle.targets` и опции своего упаковщика: NSIS на
Windows, deb/rpm/AppImage на Linux, .app/.dmg на macOS.
- `release.yml` собирает пять артефактов на трёх видах раннеров: Windows x64 и
arm64, Linux x64 (`ubuntu-22.04` — самый старый раннер с webkit2gtk-4.1, что
держит планку glibc низко), macOS arm64 и x64. Две отдельные macOS-цели вместо
`universal-apple-darwin`: так же, как Windows уже собирается по архитектурам,
вдвое меньше вес загрузки, и апдейтер получает ключи `darwin-aarch64` /
`darwin-x86_64`, которые ищет по умолчанию.
- Вся матрица остаётся `max-parallel: 1`. Все джобы отдают артефакты в *один и
тот же* объект GitHub Release, а проверка find-or-create в tauri-action гонит,
если две джобы доходят до неё одновременно — добавление платформ это
ограничение не сняло, а распространило на большее число джоб.
- Подписи ОС нет нигде. Windows выходит неподписанным; macOS подписан *ad-hoc*
(`bundle.macOS.signingIdentity: "-"`) — сертификат Apple для этого не нужен, но
без него Apple Silicon вообще откажется запускать скачанный бинарник (сочтёт
повреждённым, а не просто предупредит). Gatekeeper в любом случае попросит
пользователя разрешить приложение при первом запуске.
- `bundle.fileAssociations` покрывает `.numi`/`.sum` на Windows (записи в реестр
из NSIS) и macOS (`CFBundleDocumentTypes`). На Linux он не даёт ничего
полезного: там для регистрации расширения нужен ещё XML для shared-mime-info,
который Tauri не генерирует, так что `.numi` открываются перетаскиванием в
окно, а не из файлового менеджера.
- `icons/icon.icns` создаёт `scripts/gen-icon.ts` рядом с PNG и `.ico`, вручную
и без зависимостей: магия `icns` + общая длина, дальше по чанку
`[OSType][длина][PNG]` на размер, с современными PNG-тегами, которые выдаёт
`iconutil` (`icp4`/`icp5`, `ic07`–`ic14`). После правки картинки перегенерить
через `npm run icons`.
## Автообновление ## Автообновление
- `updater.ts` вызывает `check()` из `tauri-plugin-updater` при запуске; если - `updater.ts` вызывает `check()` из `tauri-plugin-updater` при запуске; если
@ -282,9 +245,7 @@ src-tauri/
тике интервала, так что переключение прямо во время работы срабатывает тике интервала, так что переключение прямо во время работы срабатывает
сразу); ручная кнопка тумблер игнорирует и работает всегда. сразу); ручная кнопка тумблер игнорирует и работает всегда.
- Эндпоинт обновлений — `latest.json`, публикуемый вместе с каждым GitHub - Эндпоинт обновлений — `latest.json`, публикуемый вместе с каждым GitHub
Release. `release.yml` подписывает артефакт обновления каждой платформы парой Release. `release.yml` подписывает обе архитектуры парой ключей minisign:
ключей minisign (это собственная подпись обновлений Tauri, к подписи кода в ОС
отношения не имеет):
публичная половина зашита в `tauri.conf.json`; приватная половина + пароль публичная половина зашита в `tauri.conf.json`; приватная половина + пароль
живут только как секреты репозитория `TAURI_SIGNING_PRIVATE_KEY(_PASSWORD)` живут только как секреты репозитория `TAURI_SIGNING_PRIVATE_KEY(_PASSWORD)`
(write-only — в репозитории нет постоянной копии). Потеря этого ключа (write-only — в репозитории нет постоянной копии). Потеря этого ключа
@ -307,7 +268,7 @@ src-tauri/
## Тесты ## Тесты
`npm test` запускает 373 vitest-кейса в 21 файле: `src/engine/*.test.ts` покрывает все `npm test` запускает 259 vitest-кейсов: `src/engine/*.test.ts` покрывает все
классы выражений, оба языка, детерминированные инжектированные курсы, поиск классы выражений, оба языка, детерминированные инжектированные курсы, поиск
неизвестного, исторические курсы (инжектированные), литералы и отображение неизвестного, исторические курсы (инжектированные), литералы и отображение
форматов дат (отдельные движки с явным `dateFormat` — формат «system» по форматов дат (отдельные движки с явным `dateFormat` — формат «system» по

View file

@ -29,29 +29,16 @@ Works in **English and Russian** at the same time: `5 метров в см`, `20
## Install ## Install
Grab the build for your system from [Releases](../../releases). The whole app Grab the latest `Summarum_*_x64-setup.exe` (or `arm64-setup.exe` on Windows
is about 2 MB and checks for updates on its own after that. on ARM) from [Releases](../../releases), run it, done. The whole app is
about 2 MB. It checks for updates on its own after that.
- **Windows** — `Summarum_*_x64-setup.exe` (or `arm64-setup.exe` on Windows on
ARM). Run it, done.
- **Linux** — `.AppImage` (`chmod +x`, then run) or the `.deb`/`.rpm` if you'd
rather install it properly. Self-updating only works on the AppImage; deb and
rpm are updated by downloading the next one.
- **macOS** — `.dmg`, Apple Silicon or Intel. These builds are not signed with
an Apple Developer certificate, so the first launch has to be a right-click →
**Open** rather than a double-click (or run
`xattr -cr /Applications/Summarum.app` once in a terminal). Every launch after
that is normal.
Requirements: Requirements:
- **Windows 10 (1803+) or Windows 11**, 64-bit, plus the - **Windows 10 (1803+) or Windows 11**, 64-bit.
**[WebView2 Runtime](https://developer.microsoft.com/en-us/microsoft-edge/webview2/)** — - **[WebView2 Runtime](https://developer.microsoft.com/en-us/microsoft-edge/webview2/)** —
already part of Windows 11 and most updated Windows 10 systems; if it's already part of Windows 11 and most updated Windows 10 systems; if it's
missing, the installer downloads it automatically. missing, the installer downloads it automatically.
- **Linux** with `webkit2gtk-4.1` (Ubuntu 22.04+, Debian 12+, Fedora 36+) and
`libayatana-appindicator3` for the tray icon.
- **macOS 10.13** or newer.
- Internet connection is only needed for live rates and market data — everything - Internet connection is only needed for live rates and market data — everything
else works offline (rates fall back to a cached/bundled snapshot). else works offline (rates fall back to a cached/bundled snapshot).
@ -169,7 +156,7 @@ see [ARCHITECTURE.md](ARCHITECTURE.md).
```bash ```bash
npm install npm install
npm test # engine tests (373 cases) npm test # engine tests (259 cases)
npm run tauri dev # run the app npm run tauri dev # run the app
npm run tauri build # build the installer npm run tauri build # build the installer
``` ```
@ -178,28 +165,19 @@ To build from source you need:
- [Node.js](https://nodejs.org/) 22.6 or newer (`npm run icons` relies on - [Node.js](https://nodejs.org/) 22.6 or newer (`npm run icons` relies on
Node's built-in TypeScript support) Node's built-in TypeScript support)
- [Rust](https://rustup.rs/), stable - [Rust](https://rustup.rs/) (stable, MSVC toolchain)
- On Windows: the MSVC toolchain plus - [Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/)
[Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/)
with the "Desktop development with C++" workload (the MSVC linker) with the "Desktop development with C++" workload (the MSVC linker)
- On Linux: `libwebkit2gtk-4.1-dev`, `libayatana-appindicator3-dev`,
`librsvg2-dev`, `libxdo-dev`, `libssl-dev`, `patchelf` and a C toolchain
- On macOS: Xcode Command Line Tools
- See the [Tauri prerequisites](https://tauri.app/start/prerequisites/) page - See the [Tauri prerequisites](https://tauri.app/start/prerequisites/) page
if anything refuses to compile if anything refuses to compile
`npm run tauri build` produces whatever the host platform bundles — NSIS on
Windows, deb/rpm/AppImage on Linux, .app/.dmg on macOS. Cross-compiling
between the three is not supported; the release workflow builds each on its
own runner.
Stack: [Tauri 2](https://tauri.app) + TypeScript + CodeMirror 6. Stack: [Tauri 2](https://tauri.app) + TypeScript + CodeMirror 6.
## Credits ## Credits
Inspired by the excellent [Numi](https://numi.app) by Dmitry Nikolaev — Inspired by the excellent [Numi](https://numi.app) by Dmitry Nikolaev —
if you're on macOS, buy it. Summarum is an independent project, built from if you're on macOS, buy it. Summarum is an independent project, built from
scratch for Windows, Linux and macOS. scratch for Windows.
## License ## License

View file

@ -27,28 +27,14 @@ AAPL в USD → $213.55
## Установка ## Установка
Скачай сборку под свою систему из [Releases](../../releases). Приложение весит Скачай последний `Summarum_*_x64-setup.exe` (или `arm64-setup.exe` для Windows on ARM) из [Releases](../../releases), запусти — готово. Весь установщик около 2 МБ. Дальше обновления проверяются сами.
около 2 МБ, дальше обновления проверяются сами.
- **Windows** — `Summarum_*_x64-setup.exe` (или `arm64-setup.exe` для Windows on
ARM). Запусти — готово.
- **Linux** — `.AppImage` (`chmod +x` и запускай) либо `.deb`/`.rpm`, если хочется
поставить нормальным пакетом. Самообновление работает только у AppImage, deb и
rpm обновляются скачиванием следующей версии.
- **macOS** — `.dmg` для Apple Silicon или Intel. Сборки не подписаны сертификатом
Apple Developer, поэтому первый запуск — правый клик → **Открыть**, а не двойной
клик (либо один раз выполнить `xattr -cr /Applications/Summarum.app` в терминале).
Дальше запускается как обычно.
Требования: Требования:
- **Windows 10 (1803+) или Windows 11**, 64-бит, и - **Windows 10 (1803+) или Windows 11**, 64-бит.
**[WebView2 Runtime](https://developer.microsoft.com/en-us/microsoft-edge/webview2/)** — - **[WebView2 Runtime](https://developer.microsoft.com/en-us/microsoft-edge/webview2/)** —
уже встроен в Windows 11 и большинство обновлённых Windows 10; если отсутствует, уже встроен в Windows 11 и большинство обновлённых Windows 10; если отсутствует,
установщик скачает его автоматически. установщик скачает его автоматически.
- **Linux** с `webkit2gtk-4.1` (Ubuntu 22.04+, Debian 12+, Fedora 36+) и
`libayatana-appindicator3` для иконки в трее.
- **macOS 10.13** или новее.
- Интернет нужен только для живых курсов и рыночных данных — всё остальное работает офлайн - Интернет нужен только для живых курсов и рыночных данных — всё остальное работает офлайн
(курсы берутся из кэша или встроенного снимка). (курсы берутся из кэша или встроенного снимка).
@ -165,7 +151,7 @@ numi.addFunction({ id: "гипот", phrases: "гипот" },
```bash ```bash
npm install npm install
npm test # тесты движка (373 кейса) npm test # тесты движка (259 кейсов)
npm run tauri dev # запустить приложение npm run tauri dev # запустить приложение
npm run tauri build # собрать установщик npm run tauri build # собрать установщик
``` ```
@ -173,25 +159,17 @@ npm run tauri build # собрать установщик
Для сборки нужны: Для сборки нужны:
- [Node.js](https://nodejs.org/) 22.6 или новее - [Node.js](https://nodejs.org/) 22.6 или новее
- [Rust](https://rustup.rs/), stable - [Rust](https://rustup.rs/) (stable, MSVC toolchain)
- На Windows: toolchain MSVC и - [Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/)
[Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/)
с компонентом «Desktop development with C++» (нужен линковщик MSVC) с компонентом «Desktop development with C++» (нужен линковщик MSVC)
- На Linux: `libwebkit2gtk-4.1-dev`, `libayatana-appindicator3-dev`,
`librsvg2-dev`, `libxdo-dev`, `libssl-dev`, `patchelf` и компилятор C
- На macOS: Xcode Command Line Tools
- Если что-то не собирается — см. [Tauri prerequisites](https://tauri.app/start/prerequisites/) - Если что-то не собирается — см. [Tauri prerequisites](https://tauri.app/start/prerequisites/)
`npm run tauri build` собирает то, что умеет хост-платформа: NSIS на Windows,
deb/rpm/AppImage на Linux, .app/.dmg на macOS. Кросс-компиляция между ними не
поддерживается — релизный workflow собирает каждую платформу на своём раннере.
Стек: [Tauri 2](https://tauri.app) + TypeScript + CodeMirror 6. Стек: [Tauri 2](https://tauri.app) + TypeScript + CodeMirror 6.
## Благодарность ## Благодарность
Вдохновлён [Numi](https://numi.app) от Дмитрия Николаева — Вдохновлён [Numi](https://numi.app) от Дмитрия Николаева —
если ты на macOS, купи его. Summarum — независимый проект, написанный с нуля для Windows, Linux и macOS. если ты на macOS, купи его. Summarum — независимый проект, написанный с нуля для Windows.
## Лицензия ## Лицензия

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "summarum", "name": "summarum",
"version": "1.2.9", "version": "1.2.6",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "summarum", "name": "summarum",
"version": "1.2.9", "version": "1.2.6",
"dependencies": { "dependencies": {
"@codemirror/autocomplete": "^6.18.0", "@codemirror/autocomplete": "^6.18.0",
"@codemirror/commands": "^6.8.0", "@codemirror/commands": "^6.8.0",

View file

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

View file

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

20
src-tauri/Cargo.lock generated
View file

@ -3038,7 +3038,6 @@ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"bytes", "bytes",
"futures-core", "futures-core",
"futures-util",
"http", "http",
"http-body", "http-body",
"http-body-util", "http-body-util",
@ -3058,14 +3057,12 @@ dependencies = [
"sync_wrapper", "sync_wrapper",
"tokio", "tokio",
"tokio-rustls", "tokio-rustls",
"tokio-util",
"tower", "tower",
"tower-http", "tower-http",
"tower-service", "tower-service",
"url", "url",
"wasm-bindgen", "wasm-bindgen",
"wasm-bindgen-futures", "wasm-bindgen-futures",
"wasm-streams 0.4.2",
"web-sys", "web-sys",
"webpki-roots", "webpki-roots",
] ]
@ -3105,7 +3102,7 @@ dependencies = [
"url", "url",
"wasm-bindgen", "wasm-bindgen",
"wasm-bindgen-futures", "wasm-bindgen-futures",
"wasm-streams 0.5.0", "wasm-streams",
"web-sys", "web-sys",
] ]
@ -3727,7 +3724,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]] [[package]]
name = "summarum" name = "summarum"
version = "1.2.9" version = "1.2.6"
dependencies = [ dependencies = [
"chrono", "chrono",
"futures", "futures",
@ -4927,19 +4924,6 @@ dependencies = [
"wasmparser", "wasmparser",
] ]
[[package]]
name = "wasm-streams"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
dependencies = [
"futures-util",
"js-sys",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]] [[package]]
name = "wasm-streams" name = "wasm-streams"
version = "0.5.0" version = "0.5.0"

View file

@ -1,7 +1,7 @@
[package] [package]
name = "summarum" name = "summarum"
version = "1.2.9" version = "1.2.6"
description = "Summarum — natural language calculator notebook" description = "Summarum — natural language calculator notebook for Windows"
edition = "2021" edition = "2021"
[build-dependencies] [build-dependencies]
@ -18,7 +18,7 @@ tauri-plugin-updater = "2"
tauri-plugin-process = "2" tauri-plugin-process = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
chrono = "0.4" chrono = "0.4"
futures = "0.3.32" futures = "0.3.32"

Binary file not shown.

View file

@ -1,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();

View file

@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Summarum", "productName": "Summarum",
"version": "1.2.9", "version": "1.2.6",
"identifier": "app.summarum.calc", "identifier": "app.summarum.calc",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",
@ -28,14 +28,14 @@
}, },
"bundle": { "bundle": {
"active": true, "active": true,
"targets": ["nsis"],
"createUpdaterArtifacts": true, "createUpdaterArtifacts": true,
"icon": [ "icon": [
"icons/32x32.png", "icons/32x32.png",
"icons/128x128.png", "icons/128x128.png",
"icons/128x128@2x.png", "icons/128x128@2x.png",
"icons/icon.png", "icons/icon.png",
"icons/icon.ico", "icons/icon.ico"
"icons/icon.icns"
], ],
"fileAssociations": [ "fileAssociations": [
{ {
@ -44,7 +44,13 @@
"description": "Summarum calculation sheet", "description": "Summarum calculation sheet",
"role": "Editor" "role": "Editor"
} }
] ],
"windows": {
"nsis": {
"installMode": "currentUser",
"languages": ["English", "Russian"]
}
}
}, },
"plugins": { "plugins": {
"updater": { "updater": {

View file

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

View file

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

View file

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

View file

@ -13,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"]);
}); });

View file

@ -1,14 +1,9 @@
// Walks the AST with the document context (variables, line results for // Walks the AST with the document context (variables, line results for
// sum/avg/prev) and produces unit-aware values. // sum/avg/prev) and produces unit-aware values.
import { Decimal, DEC_ZERO, DEC_ONE, EvalError, PI, Quantity, Unit, Value, XRefError, qty, pct } from "./types"; import { Decimal, EvalError, 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

View file

@ -74,9 +74,6 @@ export function formatValue(v: Value, s: EngineSettings): string {
// quantity // quantity
if (v.repr === "hex" || v.repr === "binary" || v.repr === "octal") { if (v.repr === "hex" || v.repr === "binary" || v.repr === "octal") {
if (v.value.abs().gte(new Decimal("1e10000"))) {
return formatNumber(v.value, s);
}
const int = v.value.toDecimalPlaces(0, Decimal.ROUND_HALF_UP); const int = v.value.toDecimalPlaces(0, Decimal.ROUND_HALF_UP);
const neg = int.isNegative(); const neg = int.isNegative();
const abs = int.abs(); const abs = int.abs();

View file

@ -62,18 +62,6 @@ export function parseLine(tokens: Token[], knownVars: Set<string>, line: string)
} }
} }
const filtered = filterNoiseTokens(toks, knownVars);
const goalSeek = tryParseGoalSeek(filtered, knownVars);
if (goalSeek) {
return { expr: goalSeek };
}
const p = new Parser(filtered, knownVars);
return { assign, expr: p.parseSeq() };
}
function filterNoiseTokens(toks: Token[], knownVars: Set<string>): Token[] {
// Noise filtering: drop junk and unknown words, but keep words that follow // Noise filtering: drop junk and unknown words, but keep words that follow
// a conversion operator (potential timezone names). // a conversion operator (potential timezone names).
const filtered: Token[] = []; const filtered: Token[] = [];
@ -93,10 +81,7 @@ function filterNoiseTokens(toks: Token[], knownVars: Set<string>): Token[] {
afterConv = tk.t === "conv"; afterConv = tk.t === "conv";
filtered.push(tk); filtered.push(tk);
} }
return filtered;
}
function tryParseGoalSeek(filtered: Token[], knownVars: Set<string>): Node | null {
// Goal seek: `? * 1.2 = 1000` → find x where lhs(x) = rhs(x) // Goal seek: `? * 1.2 = 1000` → find x where lhs(x) = rhs(x)
const hasUnknown = filtered.some((tk) => tk.t === "unknown"); const hasUnknown = filtered.some((tk) => tk.t === "unknown");
if (hasUnknown) { if (hasUnknown) {
@ -104,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;

View file

@ -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 });

View file

@ -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"

View file

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

View file

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

View file

@ -4,32 +4,12 @@ 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 +23,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 +46,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 +67,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 +90,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,9 +111,7 @@ function setupDocInteraction(el: HTMLElement, doc: DocMeta, list: HTMLElement):
el.addEventListener("mousedown", (e) => { el.addEventListener("mousedown", (e) => {
if ((e.target as HTMLElement).closest("button")) return; if ((e.target as HTMLElement).closest("button")) return;
const startY = e.clientY; const startY = e.clientY;
let cachedItems: { el: HTMLElement; top: number; bottom: number }[] | null = 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) { if (!cachedItems) {
const domItems = [...list.querySelectorAll<HTMLElement>(".doc-item")]; const domItems = [...list.querySelectorAll<HTMLElement>(".doc-item")];
@ -171,28 +129,19 @@ function setupDocInteraction(el: HTMLElement, doc: DocMeta, list: HTMLElement):
} }
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 +149,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 +160,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 +193,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 +209,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 +239,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 +256,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 +265,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 +283,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 +307,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 +317,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 +402,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 +418,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 +428,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";
} }
@ -571,8 +449,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 +458,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 +473,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 +504,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");
@ -714,9 +557,7 @@ async function applyAutostart(enabled: boolean): Promise<void> {
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) => logger.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 +567,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 +581,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;
@ -787,9 +620,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,18 +632,12 @@ 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.classList.add("d-none"); return; }
el.classList.add("d-none");
return;
}
el.classList.remove("d-none"); 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");
@ -848,8 +673,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 +695,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 +722,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 +742,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 +762,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 +817,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 +863,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> {
@ -1098,11 +888,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 +917,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 +960,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 +982,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 +1032,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 +1070,7 @@ async function checkUpdate(manual = false): Promise<void> {
if (manual) toast(t("upToDate")); if (manual) toast(t("upToDate"));
return; return;
} }
const choice = await askModal( const choice = await askModal(t("updateAvailable").replace("{}", result.version), t("updateInstall"), t("updateLater"));
t("updateAvailable").replace("{}", result.version),
t("updateInstall"),
t("updateLater"),
);
if (choice !== "a") return; if (choice !== "a") return;
toast(t("updateInstalling")); toast(t("updateInstalling"));
try { try {

View file

@ -170,7 +170,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 +182,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 };
}; };

View file

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

View file

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

View file

@ -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);