v1.1.0: bug fixes, atomic writes, safe quit, .sum files, CI

Engine: fix BigInt crash on NaN, KM/KG case-insensitive units, space-grouped
and comma-decimal number parsing, variable-vs-unit shadowing, small value
display (1 mm in km), DST-safe date arithmetic, month-end clamp, log mustTouch,
avg counted, 50% of 50%, 100 USD + 20 after conversion, percent power.

Rust: atomic writes (temp+rename) for documents.json, safe quit (app-quit event
+ 2s watchdog so tray-quit doesn't lose edits), .sum file association + open,
migration safety (canonicalize + all_copied guard), opener API for backups and
extensions folders.

UI: decimal separator setting, hotkey rollback on failure, corrupted JSON guard,
ru welcome sheet, orange cursor visibility.

CI: windows-latest workflow (tsc + vitest + cargo clippy).
This commit is contained in:
Horizontal42 2026-06-11 12:57:39 +03:00
parent 0cef414e9a
commit f55266f70b
23 changed files with 595 additions and 324 deletions

31
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,31 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
engine:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npx tsc --noEmit
- run: npx vitest run
rust:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri
- run: cargo clippy --manifest-path src-tauri/Cargo.toml

6
.gitignore vendored
View file

@ -3,3 +3,9 @@ dist/
src-tauri/target/
src-tauri/gen/
*.log
# local reference copy of the original macOS Numi bundle — not ours to ship
Contents/
# one-off debugging artifacts
scripts/smoke-*

View file

@ -114,6 +114,7 @@ snapshot price) plus the mirror list in `src-tauri/src/main.rs`.
## Tests
`npm test` runs ~80 vitest cases over the engine (`src/engine/*.test.ts`):
every expression class, both languages, deterministic injected rates.
`npm test` runs ~94 vitest cases over the engine (`src/engine/*.test.ts`):
every expression class, both languages, deterministic injected rates,
plus a regression suite covering all known-fixed bugs.
UI is exercised manually; the engine is where the complexity lives.

View file

@ -75,7 +75,8 @@ the sheet total; select a few lines to see just their sum.
- **Click a result** to copy it. Copying a line copies it *with* the answer
(`rent * 3 = $1,500`). **Ctrl+Shift+C** copies the whole sheet with answers.
- **Drag the divider** to resize the results column.
- **Drop a `.numi` or `.txt` file** into the window to open it as a sheet.
- **Drop a `.numi`, `.sum`, `.txt` or `.md` file** into the window to open it
as a sheet.
- **Backups are automatic**: a daily snapshot of all sheets (last 14 kept), and
deleted sheets sit in a bin for 14 days (configurable) — restore one by
dragging the file back in. Settings → Backups opens the folder.
@ -116,7 +117,8 @@ npm run tauri build # build the installer
To build from source you need:
- [Node.js](https://nodejs.org/) 20 or newer
- [Node.js](https://nodejs.org/) 22.6 or newer (`npm run icons` relies on
Node's built-in TypeScript support)
- [Rust](https://rustup.rs/) (stable, MSVC toolchain)
- [Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/)
with the "Desktop development with C++" workload (the MSVC linker)

View file

@ -52,6 +52,12 @@
<option value="">1000</option>
</select>
</label>
<label><span data-i18n="decimalsep">Decimal separator</span>
<select id="set-decimalsep">
<option value=".">0.5</option>
<option value=",">0,5</option>
</select>
</label>
<label><span data-i18n="language">Language</span>
<select id="set-lang">
<option value="en">English</option>

View file

@ -1,7 +1,7 @@
{
"name": "summarum",
"private": true,
"version": "1.0.0",
"version": "1.1.0",
"description": "Summarum — natural language calculator notebook for Windows",
"type": "module",
"scripts": {

383
src-tauri/Cargo.lock generated
View file

@ -464,6 +464,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chrono"
version = "0.4.45"
@ -507,16 +513,6 @@ dependencies = [
"version_check",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation"
version = "0.10.1"
@ -540,9 +536,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
dependencies = [
"bitflags 2.13.0",
"core-foundation 0.10.1",
"core-foundation",
"core-graphics-types",
"foreign-types 0.5.0",
"foreign-types",
"libc",
]
@ -553,7 +549,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
dependencies = [
"bitflags 2.13.0",
"core-foundation 0.10.1",
"core-foundation",
"libc",
]
@ -898,15 +894,6 @@ version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]]
name = "endi"
version = "1.1.1"
@ -1041,15 +1028,6 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared 0.1.1",
]
[[package]]
name = "foreign-types"
version = "0.5.0"
@ -1057,7 +1035,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
dependencies = [
"foreign-types-macros",
"foreign-types-shared 0.3.1",
"foreign-types-shared",
]
[[package]]
@ -1071,12 +1049,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]]
name = "foreign-types-shared"
version = "0.3.1"
@ -1302,8 +1274,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi",
"wasm-bindgen",
]
[[package]]
@ -1313,9 +1287,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 5.3.0",
"wasip2",
"wasm-bindgen",
]
[[package]]
@ -1497,25 +1473,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "h2"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"http",
"indexmap 2.14.0",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
@ -1620,7 +1577,6 @@ dependencies = [
"bytes",
"futures-channel",
"futures-core",
"h2",
"http",
"http-body",
"httparse",
@ -1644,22 +1600,7 @@ dependencies = [
"tokio",
"tokio-rustls",
"tower-service",
]
[[package]]
name = "hyper-tls"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
dependencies = [
"bytes",
"http-body-util",
"hyper",
"hyper-util",
"native-tls",
"tokio",
"tokio-native-tls",
"tower-service",
"webpki-roots",
]
[[package]]
@ -1680,11 +1621,9 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"socket2",
"system-configuration",
"tokio",
"tower-service",
"tracing",
"windows-registry",
]
[[package]]
@ -2101,6 +2040,12 @@ version = "0.4.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "markup5ever"
version = "0.38.0"
@ -2175,23 +2120,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "native-tls"
version = "0.2.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
dependencies = [
"libc",
"log",
"openssl",
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework",
"security-framework-sys",
"tempfile",
]
[[package]]
name = "ndk"
version = "0.9.0"
@ -2473,49 +2401,6 @@ dependencies = [
"pathdiff",
]
[[package]]
name = "openssl"
version = "0.10.80"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967"
dependencies = [
"bitflags 2.13.0",
"cfg-if",
"foreign-types 0.3.2",
"libc",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "openssl-probe"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "openssl-sys"
version = "0.9.116"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "option-ext"
version = "0.2.0"
@ -2742,6 +2627,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "precomputed-hash"
version = "0.1.1"
@ -2829,6 +2723,61 @@ dependencies = [
"memchr",
]
[[package]]
name = "quinn"
version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"thiserror 2.0.18",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
dependencies = [
"bytes",
"getrandom 0.3.4",
"lru-slab",
"rand",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.18",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.52.0",
]
[[package]]
name = "quote"
version = "1.0.45"
@ -2850,6 +2799,35 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
dependencies = [
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "raw-window-handle"
version = "0.6.2"
@ -2944,29 +2922,26 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"encoding_rs",
"futures-core",
"h2",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-tls",
"hyper-util",
"js-sys",
"log",
"mime",
"native-tls",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-native-tls",
"tokio-rustls",
"tower",
"tower-http",
"tower-service",
@ -2974,6 +2949,7 @@ dependencies = [
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"webpki-roots",
]
[[package]]
@ -3083,6 +3059,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
dependencies = [
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
@ -3095,6 +3072,7 @@ version = "1.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
dependencies = [
"web-time",
"zeroize",
]
@ -3130,15 +3108,6 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "schannel"
version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "schemars"
version = "0.8.22"
@ -3196,29 +3165,6 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "security-framework"
version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags 2.13.0",
"core-foundation 0.10.1",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "selectors"
version = "0.36.1"
@ -3638,27 +3584,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "system-configuration"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [
"bitflags 2.13.0",
"core-foundation 0.9.4",
"system-configuration-sys",
]
[[package]]
name = "system-configuration-sys"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "system-deps"
version = "6.2.2"
@ -3680,7 +3605,7 @@ checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9"
dependencies = [
"bitflags 2.13.0",
"block2",
"core-foundation 0.10.1",
"core-foundation",
"core-graphics",
"crossbeam-channel",
"dbus",
@ -4199,16 +4124,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
dependencies = [
"native-tls",
"tokio",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
@ -4593,12 +4508,6 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version-compare"
version = "0.2.1"
@ -4786,6 +4695,16 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "web_atoms"
version = "0.2.4"
@ -4842,6 +4761,15 @@ dependencies = [
"system-deps",
]
[[package]]
name = "webpki-roots"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webview2-com"
version = "0.38.2"
@ -5027,17 +4955,6 @@ dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows-registry"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [
"windows-link 0.2.1",
"windows-result 0.4.1",
"windows-strings 0.5.1",
]
[[package]]
name = "windows-result"
version = "0.3.4"
@ -5638,6 +5555,26 @@ dependencies = [
"zvariant",
]
[[package]]
name = "zerocopy"
version = "0.8.52"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.52"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "zerofrom"
version = "0.1.8"

View file

@ -1,6 +1,6 @@
[package]
name = "summarum"
version = "1.0.0"
version = "1.1.0"
description = "Summarum — natural language calculator notebook for Windows"
edition = "2021"
@ -16,7 +16,7 @@ tauri-plugin-opener = "2"
tauri-plugin-dialog = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
reqwest = { version = "0.12", features = ["json"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
chrono = "0.4"
[profile.release]

View file

@ -4,11 +4,12 @@
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use tauri::menu::{Menu, MenuItem};
use tauri::tray::TrayIconBuilder;
use tauri::{AppHandle, Emitter, Manager, WindowEvent};
use tauri_plugin_opener::OpenerExt;
const RATES_TTL_SECS: u64 = 3600;
const SAMPLE_JS: &str = include_str!("../extension-sample.js");
@ -39,12 +40,19 @@ fn safe_name(name: &str) -> bool {
!name.is_empty() && !name.contains(['/', '\\', ':']) && !name.contains("..")
}
/// write via a temp file + rename so a crash mid-write cannot corrupt the target
fn write_atomic(path: &Path, contents: &str) -> Result<(), String> {
let tmp = path.with_extension("tmp");
fs::write(&tmp, contents).map_err(|e| e.to_string())?;
fs::rename(&tmp, path).map_err(|e| e.to_string())
}
#[tauri::command]
fn save_file(app: AppHandle, name: String, contents: String) -> Result<(), String> {
if !safe_name(&name) {
return Err("bad file name".into());
}
fs::write(data_dir(&app).join(name), contents).map_err(|e| e.to_string())
write_atomic(&data_dir(&app).join(name), &contents)
}
#[tauri::command]
@ -152,7 +160,7 @@ async fn fetch_rates(app: AppHandle, force: bool) -> Result<RatesPayload, String
Ok(payload) => {
let cache = RatesCache { fetched_at: now_secs(), payload: payload.clone() };
if let Ok(raw) = serde_json::to_string(&cache) {
fs::write(data_dir(&app).join("rates.json"), raw).ok();
write_atomic(&data_dir(&app).join("rates.json"), &raw).ok();
}
Ok(payload)
}
@ -179,7 +187,7 @@ fn load_documents(app: AppHandle, dir: Option<String>) -> Result<Option<String>,
#[tauri::command]
fn save_documents(app: AppHandle, dir: Option<String>, contents: String) -> Result<(), String> {
fs::write(docs_dir(&app, &dir).join("documents.json"), contents).map_err(|e| e.to_string())
write_atomic(&docs_dir(&app, &dir).join("documents.json"), &contents)
}
// ---------- backups
@ -255,11 +263,9 @@ fn backup_deleted_sheet(app: AppHandle, dir: Option<String>, title: String, cont
fn open_backups_folder(app: AppHandle, dir: Option<String>) -> Result<(), String> {
let bdir = docs_dir(&app, &dir).join("backups");
fs::create_dir_all(&bdir).ok();
std::process::Command::new("explorer")
.arg(bdir)
.spawn()
.map_err(|e| e.to_string())?;
Ok(())
app.opener()
.open_path(bdir.to_string_lossy(), None::<&str>)
.map_err(|e| e.to_string())
}
// ---------- data folder migration
@ -276,7 +282,10 @@ fn migrate_data_dir(app: AppHandle, old_dir: Option<String>, new_dir: String, st
let old = docs_dir(&app, &old_dir);
let new = PathBuf::from(&new_dir);
fs::create_dir_all(&new).map_err(|e| e.to_string())?;
if old == new {
// canonicalize: "C:\Data" and "c:\data" are the same folder on Windows
let canon_old = fs::canonicalize(&old).unwrap_or_else(|_| old.clone());
let canon_new = fs::canonicalize(&new).unwrap_or_else(|_| new.clone());
if canon_old == canon_new {
return Ok(());
}
if strategy == "use_existing" {
@ -287,6 +296,7 @@ fn migrate_data_dir(app: AppHandle, old_dir: Option<String>, new_dir: String, st
fs::copy(&old_docs, new.join("documents.json")).map_err(|e| e.to_string())?;
}
// merge backups (skip files that already exist in the target)
let mut all_copied = true;
for sub in ["backups", "backups/deleted"] {
let from = old.join(sub);
let to = new.join(sub);
@ -294,17 +304,19 @@ fn migrate_data_dir(app: AppHandle, old_dir: Option<String>, new_dir: String, st
if let Ok(entries) = fs::read_dir(&from) {
for entry in entries.flatten() {
let dest = to.join(entry.file_name());
if entry.path().is_file() && !dest.exists() {
fs::copy(entry.path(), dest).ok();
if entry.path().is_file() && !dest.exists() && fs::copy(entry.path(), dest).is_err() {
all_copied = false;
}
}
}
}
// originals removed only after everything is copied
// originals removed only after everything is actually copied
if old_docs.exists() {
fs::remove_file(&old_docs).ok();
}
fs::remove_dir_all(old.join("backups")).ok();
if all_copied {
fs::remove_dir_all(old.join("backups")).ok();
}
Ok(())
}
@ -317,7 +329,7 @@ fn read_text_file(path: String) -> Result<String, String> {
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
if !["numi", "txt", "md"].contains(&ext.as_str()) {
if !["numi", "sum", "txt", "md"].contains(&ext.as_str()) {
return Err("unsupported file type".into());
}
let meta = fs::metadata(&p).map_err(|e| e.to_string())?;
@ -365,11 +377,16 @@ fn load_extensions(app: AppHandle) -> Vec<ExtensionScript> {
out
}
/// Contents of a .numi file passed on the command line (file association).
fn is_sheet_path(path: &str) -> bool {
let lower = path.to_lowercase();
lower.ends_with(".numi") || lower.ends_with(".sum")
}
/// Contents of a .numi/.sum file passed on the command line (file association).
#[tauri::command]
fn get_launch_file() -> Option<String> {
let arg = std::env::args().nth(1)?;
if arg.to_lowercase().ends_with(".numi") {
if is_sheet_path(&arg) {
fs::read_to_string(arg).ok()
} else {
None
@ -379,11 +396,29 @@ fn get_launch_file() -> Option<String> {
#[tauri::command]
fn open_extensions_folder(app: AppHandle) -> Result<(), String> {
let dir = extensions_dir(&app);
std::process::Command::new("explorer")
.arg(dir)
.spawn()
.map_err(|e| e.to_string())?;
Ok(())
app.opener()
.open_path(dir.to_string_lossy(), None::<&str>)
.map_err(|e| e.to_string())
}
/// Called by the frontend after it has flushed unsaved edits on "app-quit".
#[tauri::command]
fn exit_app(app: AppHandle) {
app.exit(0);
}
/// Quit via the frontend so the debounced autosave gets flushed first;
/// a watchdog exits anyway in case the webview is unresponsive.
fn request_quit(app: &AppHandle) {
if app.emit("app-quit", ()).is_err() {
app.exit(0);
return;
}
let handle = app.clone();
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(2000));
handle.exit(0);
});
}
fn toggle_window(app: &AppHandle) {
@ -401,7 +436,7 @@ fn toggle_window(app: &AppHandle) {
fn main() {
tauri::Builder::default()
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
if let Some(path) = args.iter().skip(1).find(|a| a.to_lowercase().ends_with(".numi")) {
if let Some(path) = args.iter().skip(1).find(|a| is_sheet_path(a)) {
if let Ok(content) = fs::read_to_string(path) {
app.emit("open-file", content).ok();
}
@ -432,7 +467,8 @@ fn main() {
load_extensions,
open_extensions_folder,
get_launch_file,
read_text_file
read_text_file,
exit_app
])
.setup(|app| {
let handle = app.handle().clone();
@ -446,7 +482,7 @@ fn main() {
.show_menu_on_left_click(false)
.on_menu_event(move |app, event| match event.id.as_ref() {
"show" => toggle_window(app),
"quit" => app.exit(0),
"quit" => request_quit(app),
_ => {}
})
.on_tray_icon_event(|tray, event| {

View file

@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Summarum",
"version": "1.0.0",
"version": "1.1.0",
"identifier": "app.summarum.calc",
"build": {
"beforeDevCommand": "npm run dev",
@ -23,7 +23,7 @@
}
],
"security": {
"csp": null
"csp": "default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src ipc: http://ipc.localhost https://open.er-api.com https://api.coingecko.com"
}
},
"bundle": {

View file

@ -88,22 +88,40 @@ export function startOfToday(): number {
return d.getTime();
}
/** Calendar-aware add: months/years via Date methods, the rest via ms. */
/**
* Calendar-aware add. Days/weeks go through setDate so the wall-clock time
* survives DST transitions; months/years clamp to the end of the target
* month (Jan 31 + 1 month = Feb 28, not Mar 3).
*/
export function addToDate(ms: number, amount: number, unitId: string): number {
const baseId = unitId.includes(":") ? unitId.split(":")[1] : unitId;
const d = new Date(ms);
if ((baseId === "day" || baseId === "week") && Number.isInteger(amount)) {
d.setDate(d.getDate() + amount * (baseId === "week" ? 7 : 1));
return d.getTime();
}
if (baseId === "month" && Number.isInteger(amount)) {
const day = d.getDate();
d.setDate(1);
d.setMonth(d.getMonth() + amount);
d.setDate(Math.min(day, lastDayOfMonth(d)));
return d.getTime();
}
if (baseId === "year" && Number.isInteger(amount)) {
const day = d.getDate();
d.setDate(1);
d.setFullYear(d.getFullYear() + amount);
d.setDate(Math.min(day, lastDayOfMonth(d)));
return d.getTime();
}
return ms; // caller falls back to ms math for everything else
}
function lastDayOfMonth(d: Date): number {
return new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate();
}
export function isCalendarUnit(unitId: string, amount: number): boolean {
const baseId = unitId.includes(":") ? unitId.split(":")[1] : unitId;
return (baseId === "month" || baseId === "year") && Number.isInteger(amount);
return (baseId === "day" || baseId === "week" || baseId === "month" || baseId === "year") && Number.isInteger(amount);
}

View file

@ -142,3 +142,68 @@ describe("crypto", () => {
expect(r).toMatch(/^\$/);
});
});
describe("regressions", () => {
it("a broken line does not kill the document (BigInt from NaN)", () => {
const r = eng.evaluateDocument("2+2\nasin(5) & 3\n3+3").map((x) => x.text);
expect(r).toEqual(["4", null, "6"]);
});
it("non-finite results are suppressed", () => {
expect(calc("100 / 0%")).toBe(null);
expect(calc("asin(5)")).toBe(null);
expect(calc("sqrt(-1)")).toBe(null);
});
it("arithmetic continues after a line-level conversion", () => {
expect(calc("100 USD in EUR + 20")).toBe("€ 70"); // 50 € + 20
expect(calc("100 USD in EUR - 20")).toBe("€ 30");
expect(calc("100 USD in EUR * 2")).toBe("€ 100");
expect(calc("1 km in m + 1")).toBe("1,001 m");
});
it("uppercase prefixed symbols are accepted when unambiguous", () => {
expect(calc("5 KM in miles")).toBe("3.11 mi.");
expect(calc("2 KG in pounds")).toBe("4.41 lb");
expect(calc("5 КМ в метрах")).toBe("5,000 m");
});
it("ambiguous case stays strict: MM is not megameters", () => {
expect(calc("5 MM in m")).toBe("5 m"); // unrecognized, bare 5 converted
});
it("space-grouped numbers", () => {
expect(calc("1 000 + 234")).toBe("1,234");
expect(calc("1 000 000 / 2")).toBe("500,000");
expect(calc("1 000,5 + 0,5")).toBe("1,001");
});
it("decimal comma", () => {
expect(calc("1,23 + 1")).toBe("2.23");
expect(calc("0,5 * 4")).toBe("2");
expect(calc("1,234 + 0")).toBe("1,234"); // exactly 3 digits = thousands
});
it("single-letter unit names can be variables", () => {
expect(eng.evaluateDocument("m = 5\nm * 2").map((x) => x.text)).toEqual(["5", "10"]);
expect(eng.evaluateDocument("t = 100\nt + 20%").map((x) => x.text)).toEqual(["100", "120"]);
});
it("tiny conversion results show significant digits, not 0", () => {
expect(calc("1 mm in km")).toBe("0.000001 km");
expect(calc("32 F in C")).toBe("0°C"); // conversion noise stays zero
});
it("log with a space is log10", () => {
expect(calc("log 1000")).toBe("3");
expect(calc("log2(8)")).toBe("3");
expect(calc("log 2 + 1")).toBe("1.3");
});
it("percent of a percent stays a percent", () => {
expect(calc("50% of 50%")).toBe("25%");
expect(calc("10% off 50%")).toBe("45%");
});
it("percent base on the left of ^", () => {
expect(calc("50% ^ 2")).toBe("0.25");
expect(calc("4 ^ 50%")).toBe("2");
});
it("avg skips incompatible lines in the denominator", () => {
expect(eng.evaluateDocument("10 kg\n20 kg\n5 hours\navg").map((x) => x.text)).toEqual(["10 kg", "20 kg", "5 h", "15 kg"]);
});
it("calendar month add clamps to month end", () => {
const doc = eng.evaluateDocument("today + 1 month");
const v = doc[0].value;
expect(v?.kind).toBe("date");
});
});

View file

@ -195,7 +195,11 @@ function evalBin(op: "plus" | "minus" | "mul" | "div" | "mod" | "pow", l: Value,
case "div":
if (l.kind === "percent") throw new EvalError("percent div number");
return qty(q.value.div(p.div(100)), q.unit);
case "pow": return qty(q.value.pow(p.div(100)), q.unit);
case "pow":
// which side the percent is on matters: 50% ^ 2 = 0.25, 2 ^ 50% = √2
return l.kind === "percent"
? qty(p.div(100).pow(q.value))
: qty(q.value.pow(p.div(100)), q.unit);
default: throw new EvalError("bad percent op");
}
}
@ -247,6 +251,7 @@ function evalBin(op: "plus" | "minus" | "mul" | "div" | "mod" | "pow", l: Value,
function evalBit(op: string, l: Value, r: Value): Value {
if (l.kind !== "quantity" || r.kind !== "quantity") throw new EvalError("bitwise needs integers");
if (!l.value.isFinite() || !r.value.isFinite()) throw new EvalError("bitwise needs finite integers");
const a = BigInt(l.value.toDecimalPlaces(0).toFixed(0));
const b = BigInt(r.value.toDecimalPlaces(0).toFixed(0));
if ((op === "shl" || op === "shr") && (b < 0n || b > 256n)) throw new EvalError("bad shift");
@ -271,11 +276,13 @@ function evalPctOp(op: string, l: Value, r: Value): Value {
if (op === "of" || op === "off" || op === "on") {
const p = l.kind === "percent" ? l.value : asScalar(l).mul(100);
switch (op) {
case "of": return qty(rv.mul(p).div(100), unit);
case "off": return qty(rv.mul(new Decimal(1).sub(p.div(100))), unit);
case "on": return qty(rv.mul(new Decimal(1).add(p.div(100))), unit);
}
const factor =
op === "of" ? p.div(100)
: op === "off" ? new Decimal(1).sub(p.div(100))
: new Decimal(1).add(p.div(100));
// a percent of a percent stays a percent: 50% of 50% = 25%
if (r.kind === "percent") return pct(r.value.mul(factor));
return qty(rv.mul(factor), unit);
}
if (op === "as_pct_of" || op === "as_pct_off" || op === "as_pct_on") {
const lv = l.kind === "quantity" ? l.value : asScalar(l);
@ -356,7 +363,10 @@ function evalDateWord(word: string): Value {
function evalDateArith(op: string, l: Value, r: Value): Value {
if (l.kind === "date" && r.kind === "date") {
if (op !== "minus") throw new EvalError("dates only subtract");
const days = new Decimal(l.ms - r.ms).div(86400_000);
// midnight-to-midnight differences count calendar days (robust across DST)
const days = !l.hasTime && !r.hasTime
? new Decimal(Math.round((l.ms - r.ms) / 86400_000))
: new Decimal(l.ms - r.ms).div(86400_000);
return qty(days, { id: "day", dimension: "time", ratio: new Decimal(86400), format: "day" });
}
const date = (l.kind === "date" ? l : r) as Extract<Value, { kind: "date" }>;
@ -368,12 +378,16 @@ function evalDateArith(op: string, l: Value, r: Value): Value {
if (op === "minus" && l.kind !== "date") throw new EvalError("cannot subtract date from number");
const amount = span.value.toNumber() * (op === "minus" ? -1 : 1);
let ms: number;
let hasTime = date.hasTime;
if (isCalendarUnit(span.unit.id, amount)) {
ms = addToDate(date.ms, amount, span.unit.id);
} else {
ms = date.ms + span.value.mul(span.unit.ratio).mul(1000).toNumber() * (op === "minus" ? -1 : 1);
const seconds = span.value.mul(span.unit.ratio);
ms = date.ms + seconds.mul(1000).toNumber() * (op === "minus" ? -1 : 1);
// "today + 2 hours" must show the time part to be visible at all
if (!seconds.mod(86400).isZero()) hasTime = true;
}
return { ...date, ms };
return { ...date, ms, hasTime };
}
function evalAgg(name: "sum" | "avg" | "prev", ctx: EvalCtx): Value {
@ -398,15 +412,17 @@ function evalAgg(name: "sum" | "avg" | "prev", ctx: EvalCtx): Value {
if (block.length === 0) throw new EvalError("nothing to aggregate");
block.reverse();
let acc = block[0];
let counted = 1;
for (let i = 1; i < block.length; i++) {
const item = block[i];
try {
acc = numericAdd(acc, item, 1);
counted++;
} catch {
// skip lines with incompatible dimensions, as Numi does
}
}
if (name === "avg") return qty(acc.value.div(block.length), acc.unit);
if (name === "avg") return qty(acc.value.div(counted), acc.unit);
return acc;
}

View file

@ -12,7 +12,12 @@ function group(intPart: string, sep: string): string {
}
export function formatNumber(v: Decimal, s: EngineSettings): string {
const rounded = v.toDecimalPlaces(s.precision);
let rounded = v.toDecimalPlaces(s.precision);
if (rounded.isZero() && !v.isZero() && v.abs().gte("1e-12")) {
// 1 mm in km must not show "0 km" — fall back to significant digits
// (anything below 1e-12 is conversion noise, e.g. 32 F in C)
rounded = v.toSignificantDigits(Math.max(s.precision, 1));
}
const str = rounded.toString();
if (str.includes("e") || str.includes("E")) return str;
const [int, frac] = str.split(".");

View file

@ -90,7 +90,7 @@ export class SumEngine {
const tokens = tokenize(line, this.reg);
const knownVars = new Set(vars.keys());
const parsed = parseLine(tokens, knownVars);
const parsed = parseLine(tokens, knownVars, line);
let value: Value | null = null;
if (parsed.expr) {
@ -101,11 +101,14 @@ export class SumEngine {
};
try {
value = evaluate(parsed.expr, ctx);
if (parsed.assign) vars.set(parsed.assign, value);
// a line without a result beats a dead sheet — swallow everything,
// including extension bugs and BigInt conversion errors
} catch (e) {
if (!(e instanceof EvalError)) throw e;
if (!(e instanceof EvalError)) console.warn("evaluate failed:", e);
value = null;
}
if (value?.kind === "quantity" && !value.value.isFinite()) value = null;
if (value && parsed.assign) vars.set(parsed.assign, value);
}
lineValues.push(value);

View file

@ -12,8 +12,14 @@ export interface Lex {
}
const WORD_RE = /^[\p{L}_]+/u;
// Order matters: grouped thousands first, then plain decimal/exponent, then leading-dot.
const NUM_RE = /^(?:\d{1,3}(?:,\d{3})+(?:\.\d+)?|\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|\.\d+)/;
// Number forms, tried in order. Space/NBSP-grouped numbers may use a decimal
// comma ("1 000,5" — what the app itself prints with the space separator);
// comma-grouped is the en convention ("1,000"), and a comma that does not
// form groups of three is a decimal comma ("1,23" → 1.23).
const NUM_SPACE_GROUPED_RE = /^\d{1,3}(?:[   ]\d{3})+(?:[.,]\d+)?(?!\d)/;
const NUM_COMMA_GROUPED_RE = /^\d{1,3}(?:,\d{3})+(?:\.\d+)?(?!\d)/;
const NUM_DECIMAL_COMMA_RE = /^\d+,\d+/;
const NUM_PLAIN_RE = /^\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/;
export function lexLine(text: string): Lex[] {
const out: Lex[] = [];
@ -46,12 +52,31 @@ export function lexLine(text: string): Lex[] {
continue;
}
}
const num = NUM_RE.exec(rest);
if (num && (ch >= "0" && ch <= "9")) {
const raw = num[0];
out.push({ type: "num", raw, start: i, end: i + raw.length, value: new Decimal(raw.replace(/,/g, "")), repr: "decimal" });
i += raw.length;
continue;
if (ch >= "0" && ch <= "9") {
let raw: string | null = null;
let cleaned = "";
const sp = NUM_SPACE_GROUPED_RE.exec(rest);
const cg = sp ? null : NUM_COMMA_GROUPED_RE.exec(rest);
const dc = sp || cg ? null : NUM_DECIMAL_COMMA_RE.exec(rest);
const pl = sp || cg || dc ? null : NUM_PLAIN_RE.exec(rest);
if (sp) {
raw = sp[0];
cleaned = raw.replace(/[   ]/g, "").replace(",", ".");
} else if (cg) {
raw = cg[0];
cleaned = raw.replace(/,/g, "");
} else if (dc) {
raw = dc[0];
cleaned = raw.replace(",", ".");
} else if (pl) {
raw = pl[0];
cleaned = raw;
}
if (raw) {
out.push({ type: "num", raw, start: i, end: i + raw.length, value: new Decimal(cleaned), repr: "decimal" });
i += raw.length;
continue;
}
}
if (ch === "." && /^\.\d/.test(rest)) {
const m = /^\.\d+/.exec(rest)!;

View file

@ -38,14 +38,24 @@ export interface ParsedLine {
expr: Node | null;
}
export function parseLine(tokens: Token[], knownVars: Set<string>): ParsedLine {
let assign: string | undefined;
let toks = tokens;
const VAR_NAME_RE = /^[\p{L}_][\p{L}\d_]*$/u;
// `x = expr` / `x is expr`
if (toks.length >= 2 && toks[0].t === "word" && toks[1].t === "assign") {
assign = toks[0].raw;
toks = toks.slice(2);
export function parseLine(tokens: Token[], knownVars: Set<string>, line: string): ParsedLine {
let assign: string | undefined;
// a defined variable shadows whatever its name tokenized as (unit "m", agg "sum"...)
let toks: Token[] = tokens.map((tk) => {
if (tk.t === "word" || tk.t === "num" || tk.t === "junk") return tk;
const raw = line.slice(tk.start, tk.end);
return knownVars.has(raw) ? { t: "word", raw, start: tk.start, end: tk.end } : tk;
});
// `x = expr` / `x is expr` — the name may have tokenized as a unit (`m = 5`)
if (toks.length >= 2 && toks[1].t === "assign") {
const raw = toks[0].t === "word" ? toks[0].raw : line.slice(toks[0].start, toks[0].end);
if (VAR_NAME_RE.test(raw)) {
assign = raw;
toks = toks.slice(2);
}
}
// Noise filtering: drop junk and unknown words, but keep words that follow
@ -106,6 +116,29 @@ class Parser {
}
continue;
}
// an operator after a finished item continues it: "100 USD in EUR + 20"
if (tk.t === "op" && items.length > 0) {
const save = this.i;
this.i++;
const r = tk.op === "plus" || tk.op === "minus" ? this.parseMul() : this.parseUnary();
if (r) {
items = [{ k: "bin", op: tk.op, l: mk(), r }];
continue;
}
this.i = save + 1; // dangling operator is noise
continue;
}
if (tk.t === "bitop" && items.length > 0) {
const save = this.i;
this.i++;
const r = this.parseAdd();
if (r) {
items = [{ k: "bit", op: tk.op, l: mk(), r }];
continue;
}
this.i = save + 1;
continue;
}
const before = this.i;
const node = this.parseBit();
if (node) items.push(node);

View file

@ -3,7 +3,7 @@
// for the tokenizer. Longest phrase wins; case-sensitive entries (symbols
// like "m"/"M", "kB") are tried before the case-insensitive ones.
import { Decimal, Dimension, NumeralRepr, Unit, Value } from "./types";
import { lexPhrase } from "./lexer";
import { Lex, lexLine } from "./lexer";
import * as vocab from "./vocab";
import { UNIT_DATA, SI_PREFIXES, DATA_SI_PREFIXES, IEC_PREFIXES, SCALE_DATA } from "./unitdata";
import { EXTRA_UNITS, CRYPTO } from "./extraunits";
@ -38,6 +38,11 @@ interface PhraseEntry {
lexemes: string[]; // raw, as in the phrase
lower: string[];
caseSensitive: boolean;
/**
* per-gap flag: word/number lexemes glued together in the phrase ("log2")
* must also be glued in the input — otherwise "log 2" matches log2
*/
mustTouch: boolean[];
payload: Payload;
}
@ -68,10 +73,16 @@ export class Registry {
completions: Completion[] = [];
addPhrase(phrase: string, payload: Payload, opts: { caseSensitive?: boolean } = {}): void {
const lexemes = lexPhrase(phrase);
if (lexemes.length === 0) return;
const lexs = lexLine(phrase);
if (lexs.length === 0) return;
const lexemes = lexs.map((l) => l.raw);
const caseSensitive = opts.caseSensitive ?? lexemes.every((l) => l.length <= 2);
const entry: PhraseEntry = { lexemes, lower: lexemes.map((l) => l.toLowerCase()), caseSensitive, payload };
const wordOrNum = (t: Lex["type"]) => t === "word" || t === "num";
const mustTouch: boolean[] = [];
for (let k = 1; k < lexs.length; k++) {
mustTouch.push(lexs[k].start === lexs[k - 1].end && wordOrNum(lexs[k].type) && wordOrNum(lexs[k - 1].type));
}
const entry: PhraseEntry = { lexemes, lower: lexemes.map((l) => l.toLowerCase()), caseSensitive, mustTouch, payload };
const key = entry.lower[0];
const list = this.byFirst.get(key);
if (list) {
@ -88,16 +99,20 @@ export class Registry {
/**
* Match the longest phrase at position i of the lexeme stream.
* rawAt/lowerAt give the lexemes of the input line.
* lexes are the lexemes of the input line, lowers their lowercased raws.
*/
match(raws: string[], lowers: string[], i: number): { payload: Payload; length: number } | null {
match(lexes: Lex[], lowers: string[], i: number): { payload: Payload; length: number } | null {
const list = this.byFirst.get(lowers[i]);
if (!list) return null;
for (const e of list) {
if (i + e.lexemes.length > raws.length) continue;
if (i + e.lexemes.length > lexes.length) continue;
let ok = true;
for (let k = 0; k < e.lexemes.length; k++) {
if (e.caseSensitive ? raws[i + k] !== e.lexemes[k] : lowers[i + k] !== e.lower[k]) {
if (e.caseSensitive ? lexes[i + k].raw !== e.lexemes[k] : lowers[i + k] !== e.lower[k]) {
ok = false;
break;
}
if (k > 0 && e.mustTouch[k - 1] && lexes[i + k].start !== lexes[i + k - 1].end) {
ok = false;
break;
}
@ -222,6 +237,8 @@ export function buildRegistry(): Registry {
// units, SI/IEC prefixes, area/volume templates
interface LengthPhrase { phrase: string; unit: Unit; caseSensitive?: boolean }
const lengthPhrases: LengthPhrase[] = [];
/** lowercased composed symbol -> unit; null = ambiguous (mm vs Mm) */
const lenientSym = new Map<string, Unit | null>();
for (const d of UNIT_DATA) {
const variants = vocab.variants(d.category, `${d.id}.variants`);
@ -276,6 +293,14 @@ export function buildRegistry(): Registry {
reg.unitsById.set(pUnit.id, pUnit);
for (const ph of csPhrases) reg.addPhrase(ph, { t: "unit", unit: pUnit }, { caseSensitive: true });
for (const ph of phrases) reg.addPhrase(ph, { t: "unit", unit: pUnit }, { caseSensitive: false });
// collect unambiguous lowercase forms: "KM"/"Km" should still mean km,
// while "mm"/"Mm" stay strict (milli vs mega)
for (const ph of csPhrases) {
const lower = ph.toLowerCase();
const prev = lenientSym.get(lower);
if (prev === undefined) lenientSym.set(lower, pUnit);
else if (prev && prev.id !== pUnit.id) lenientSym.set(lower, null);
}
if (d.dimension === "length") {
for (const ph of csPhrases) lengthPhrases.push({ phrase: ph, unit: pUnit, caseSensitive: true });
for (const pw of pWords) for (const wv of wordVariants) lengthPhrases.push({ phrase: pw + wv, unit: pUnit, caseSensitive: false });
@ -284,6 +309,10 @@ export function buildRegistry(): Registry {
}
}
for (const [lower, unit] of lenientSym) {
if (unit) reg.addPhrase(lower, { t: "unit", unit }, { caseSensitive: false });
}
// Area/volume from templates: "square %@" / "cubic %@" applied to every length phrase
const applyTemplates = (
templates: string[],

View file

@ -32,8 +32,7 @@ const SYM_OPS: Record<string, Token["t"] | "plus" | "minus" | "mul" | "div" | "p
export function tokenize(line: string, reg: Registry): Token[] {
void SYM_OPS;
const lexes = lexLine(line);
const raws = lexes.map((l) => l.raw);
const lowers = raws.map((r) => r.toLowerCase());
const lowers = lexes.map((l) => l.raw.toLowerCase());
const tokens: Token[] = [];
let i = 0;
while (i < lexes.length) {
@ -46,7 +45,7 @@ export function tokenize(line: string, reg: Registry): Token[] {
continue;
}
const m = reg.match(raws, lowers, i);
const m = reg.match(lexes, lowers, i);
if (m) {
const end = lexes[i + m.length - 1].end;
const s = { start: lx.start, end };

View file

@ -39,6 +39,9 @@ const dict: Record<string, Record<string, string>> = {
useExisting: "Use existing",
replaceMine: "Replace with mine",
folderChanged: "Data folder changed",
folderError: "Could not change the data folder",
hotkeyFailed: "Hotkey is taken by another app",
decimalsep: "Decimal separator",
},
ru: {
settings: "Настройки",
@ -78,6 +81,9 @@ const dict: Record<string, Record<string, string>> = {
useExisting: "Использовать их",
replaceMine: "Заменить моими",
folderChanged: "Папка данных изменена",
folderError: "Не удалось сменить папку данных",
hotkeyFailed: "Хоткей занят другой программой",
decimalsep: "Десятичный разделитель",
},
};

View file

@ -4,7 +4,7 @@ import { SumEngine } from "./engine";
import { SumEditor } from "./ui/editor";
import {
AppData, DocMeta, SettingsData, defaultSettingsData,
loadAppData, saveAppData, loadSettings, saveSettings,
loadAppData, saveAppData, flushAppData, onAppQuit, loadSettings, saveSettings,
fetchRates, loadExtensionScripts, openExtensionsFolder, isTauri,
getLaunchFile, onOpenFile, onFileDrop,
setDataDir, runBackups, backupDeletedSheet, openBackupsFolder,
@ -13,9 +13,12 @@ import {
import type { LineResult } from "./engine";
import { setLang, detectLang, t } from "./i18n";
import { runExtensions } from "./extensions";
import * as vocabApi from "./engine/vocab";
import { EN, RU } from "./engine/vocab-data";
const WELCOME = (vocabApi.entry("Samples", "sample.welcome") ?? "# Sample\n8 / (45 - 20%)\n5 inches in cm\n$9 in Euro") + "\n";
function welcomeText(lang: string): string {
const sample = (lang === "ru" ? RU : EN).Samples?.["sample.welcome"];
return (sample ?? "# Sample\n8 / (45 - 20%)\n5 inches in cm\n$9 in Euro") + "\n";
}
const $ = <T extends HTMLElement>(sel: string): T => document.querySelector(sel) as T;
@ -117,7 +120,11 @@ function applySettings(): void {
document.documentElement.style.setProperty("--editor-font-size", `${settings.fontSize}px`);
$("#editor-wrap").style.setProperty("--results-width", `${settings.resultsWidth}%`);
setLang(settings.language);
engine.updateSettings({ precision: settings.precision, groupSeparator: settings.groupSeparator });
engine.updateSettings({
precision: settings.precision,
groupSeparator: settings.groupSeparator,
decimalSeparator: settings.decimalSeparator,
});
editor?.refresh();
syncTitleField();
$<HTMLElement>("#sidebar").classList.toggle("hidden", !settings.sidebarVisible);
@ -127,6 +134,7 @@ function bindSettingsUI(): void {
const themeSel = $<HTMLSelectElement>("#set-theme");
const precision = $<HTMLInputElement>("#set-precision");
const groupSep = $<HTMLSelectElement>("#set-groupsep");
const decimalSep = $<HTMLSelectElement>("#set-decimalsep");
const langSel = $<HTMLSelectElement>("#set-lang");
const hotkey = $<HTMLInputElement>("#set-hotkey");
const autostart = $<HTMLInputElement>("#set-autostart");
@ -136,6 +144,7 @@ function bindSettingsUI(): void {
themeSel.value = settings.theme;
precision.value = String(settings.precision);
groupSep.value = settings.groupSeparator;
decimalSep.value = settings.decimalSeparator;
langSel.value = settings.language;
hotkey.value = settings.hotkey;
autostart.checked = settings.autostart;
@ -146,13 +155,19 @@ function bindSettingsUI(): void {
settings.theme = themeSel.value as SettingsData["theme"];
settings.precision = Math.max(0, Math.min(15, Number(precision.value) || 2));
settings.groupSeparator = groupSep.value;
settings.decimalSeparator = decimalSep.value;
// "1,234,56" is unreadable — a comma decimal forces a space group separator
if (settings.decimalSeparator === settings.groupSeparator) {
settings.groupSeparator = settings.decimalSeparator === "," ? " " : ",";
groupSep.value = settings.groupSeparator;
}
settings.language = langSel.value;
settings.fontSize = Math.max(10, Math.min(32, Number(fontSize.value) || 15));
settings.resultsWidth = Math.max(20, Math.min(60, Number(resultsWidth.value) || 42));
applySettings();
void saveSettings(settings);
};
for (const el of [themeSel, precision, groupSep, langSel, fontSize]) {
for (const el of [themeSel, precision, groupSep, decimalSep, langSel, fontSize]) {
el.addEventListener("change", save);
}
resultsWidth.addEventListener("input", save); // live while sliding
@ -188,10 +203,15 @@ function bindSettingsUI(): void {
if (!keyName) return;
const combo = [...mods, keyName].join("+");
const old = settings.hotkey;
settings.hotkey = combo;
hotkey.value = combo;
await registerHotkey(old, combo);
void saveSettings(settings);
if (await registerHotkey(old, combo)) {
settings.hotkey = combo;
hotkey.value = combo;
void saveSettings(settings);
} else {
// combo is taken by another app — restore the old one
await registerHotkey(null, old);
toast(t("hotkeyFailed"));
}
hotkey.blur();
});
autostart.addEventListener("change", async () => {
@ -224,7 +244,13 @@ function bindSettingsUI(): void {
if (ans === null) return;
strategy = ans === "a" ? "use_existing" : "overwrite";
}
await migrateDataDir(settings.dataDir, picked, strategy);
try {
await migrateDataDir(settings.dataDir, picked, strategy);
} catch (e) {
console.warn("migrate failed", e);
toast(t("folderError"));
return;
}
settings.dataDir = picked;
setDataDir(picked);
await saveSettings(settings);
@ -273,8 +299,8 @@ function normalizeKeyName(e: KeyboardEvent): string | null {
// ---------- tauri integration
async function registerHotkey(old: string | null, combo: string): Promise<void> {
if (!isTauri()) return;
async function registerHotkey(old: string | null, combo: string): Promise<boolean> {
if (!isTauri()) return true;
try {
const gs = await import("@tauri-apps/plugin-global-shortcut");
if (old) await gs.unregister(old).catch(() => {});
@ -289,8 +315,10 @@ async function registerHotkey(old: string | null, combo: string): Promise<void>
await win.setFocus();
}
});
return true;
} catch (e) {
console.warn("hotkey registration failed", e);
return false;
}
}
@ -417,11 +445,12 @@ async function boot(): Promise<void> {
await runBackups(settings.dataDir, settings.deletedRetentionDays);
const stored = await loadAppData(settings.dataDir);
if (stored && stored.docs.length > 0) {
// a corrupt or foreign documents.json must not crash the boot
if (stored && Array.isArray(stored.docs) && stored.docs.length > 0 && stored.contents && typeof stored.contents === "object") {
data = stored;
} else {
const id = uid();
data = { docs: [{ id, title: "Sample" }], activeId: id, contents: { [id]: WELCOME } };
data = { docs: [{ id, title: "Sample" }], activeId: id, contents: { [id]: welcomeText(settings.language) } };
}
if (!data.docs.some((d) => d.id === data.activeId)) data.activeId = data.docs[0].id;
@ -563,6 +592,7 @@ async function boot(): Promise<void> {
await registerHotkey(null, settings.hotkey);
if (settings.autostart) await applyAutostart(true);
void onAppQuit(() => flushAppData());
void refreshRates();
setInterval(() => void refreshRates(), 60 * 60 * 1000);

View file

@ -17,6 +17,7 @@ export interface SettingsData {
theme: "system" | "light" | "dark";
precision: number;
groupSeparator: string;
decimalSeparator: string;
language: string;
hotkey: string;
autostart: boolean;
@ -34,6 +35,7 @@ export const defaultSettingsData: SettingsData = {
theme: "system",
precision: 2,
groupSeparator: ",",
decimalSeparator: ".",
language: "en",
hotkey: "Ctrl+Alt+N",
autostart: false,
@ -93,6 +95,7 @@ export async function loadAppData(dataDir: string): Promise<AppData | null> {
}
let saveTimer: ReturnType<typeof setTimeout> | null = null;
let pendingData: AppData | null = null;
let currentDataDir = "";
export function setDataDir(dataDir: string): void {
@ -101,16 +104,33 @@ export function setDataDir(dataDir: string): void {
export function saveAppData(data: AppData): void {
// debounce: autosave on every keystroke without disk churn
pendingData = data;
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(async () => {
const raw = JSON.stringify(data);
try {
if (isTauri()) await invoke("save_documents", { dir: dirArg(currentDataDir), contents: raw });
else localStorage.setItem("numi.documents", raw);
} catch (e) {
console.warn("saveAppData failed", e);
}
}, 400);
saveTimer = setTimeout(() => void flushAppData(), 400);
}
/** write whatever is pending right now (used before quitting) */
export async function flushAppData(): Promise<void> {
if (saveTimer) {
clearTimeout(saveTimer);
saveTimer = null;
}
if (!pendingData) return;
const raw = JSON.stringify(pendingData);
pendingData = null;
try {
if (isTauri()) await invoke("save_documents", { dir: dirArg(currentDataDir), contents: raw });
else localStorage.setItem("numi.documents", raw);
} catch (e) {
console.warn("saveAppData failed", e);
}
}
/** quit initiated from the tray: flush unsaved edits, then exit for real */
export async function onAppQuit(cb: () => Promise<void>): Promise<void> {
if (!isTauri()) return;
const { listen } = await import("@tauri-apps/api/event");
await listen("app-quit", () => void cb().finally(() => void invoke("exit_app")));
}
// ---------- backups & data folder

View file

@ -118,19 +118,22 @@ export class SumEditor {
) {
this.resultsEl = resultsEl;
let engineOptions: { label: string; type: string; detail?: string }[] | null = null;
const completionSource = (ctx: CompletionContext): CompletionResult | null => {
const word = ctx.matchBefore(/[\p{L}_]+$/u);
if (!word || (word.from === word.to && !ctx.explicit)) return null;
if (word.to - word.from < 2 && !ctx.explicit) return null;
const options = this.engine.completions().map((c) => ({
// the engine's phrase list is fixed after boot — map it once
engineOptions ??= this.engine.completions().map((c) => ({
label: c.label,
type: c.type === "function" ? "function" : c.type === "keyword" ? "keyword" : "constant",
detail: c.detail,
}));
const options = [...engineOptions];
// document variables
const text = ctx.state.doc.toString();
for (const m of text.matchAll(/^\s*([\p{L}_][\p{L}\d_]*)\s*=/gmu)) {
options.push({ label: m[1], type: "variable", detail: undefined as unknown as string });
options.push({ label: m[1], type: "variable" });
}
return { from: word.from, options, validFor: /^[\p{L}_]*$/u };
};