mirror of
https://github.com/Horizontal42/summarum.git
synced 2026-11-11 02:12:00 +03:00
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:
parent
0cef414e9a
commit
f55266f70b
23 changed files with 595 additions and 324 deletions
31
.github/workflows/ci.yml
vendored
Normal file
31
.github/workflows/ci.yml
vendored
Normal 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
6
.gitignore
vendored
|
|
@ -3,3 +3,9 @@ dist/
|
||||||
src-tauri/target/
|
src-tauri/target/
|
||||||
src-tauri/gen/
|
src-tauri/gen/
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
|
# local reference copy of the original macOS Numi bundle — not ours to ship
|
||||||
|
Contents/
|
||||||
|
|
||||||
|
# one-off debugging artifacts
|
||||||
|
scripts/smoke-*
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,7 @@ snapshot price) plus the mirror list in `src-tauri/src/main.rs`.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
`npm test` runs ~80 vitest cases over the engine (`src/engine/*.test.ts`):
|
`npm test` runs ~94 vitest cases over the engine (`src/engine/*.test.ts`):
|
||||||
every expression class, both languages, deterministic injected rates.
|
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.
|
UI is exercised manually; the engine is where the complexity lives.
|
||||||
|
|
|
||||||
|
|
@ -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
|
- **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.
|
(`rent * 3 = $1,500`). **Ctrl+Shift+C** copies the whole sheet with answers.
|
||||||
- **Drag the divider** to resize the results column.
|
- **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
|
- **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
|
deleted sheets sit in a bin for 14 days (configurable) — restore one by
|
||||||
dragging the file back in. Settings → Backups opens the folder.
|
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:
|
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)
|
- [Rust](https://rustup.rs/) (stable, MSVC toolchain)
|
||||||
- [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)
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,12 @@
|
||||||
<option value="">1000</option>
|
<option value="">1000</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</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>
|
<label><span data-i18n="language">Language</span>
|
||||||
<select id="set-lang">
|
<select id="set-lang">
|
||||||
<option value="en">English</option>
|
<option value="en">English</option>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "summarum",
|
"name": "summarum",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"description": "Summarum — natural language calculator notebook for Windows",
|
"description": "Summarum — natural language calculator notebook for Windows",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
383
src-tauri/Cargo.lock
generated
383
src-tauri/Cargo.lock
generated
|
|
@ -464,6 +464,12 @@ version = "1.0.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg_aliases"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chrono"
|
name = "chrono"
|
||||||
version = "0.4.45"
|
version = "0.4.45"
|
||||||
|
|
@ -507,16 +513,6 @@ dependencies = [
|
||||||
"version_check",
|
"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]]
|
[[package]]
|
||||||
name = "core-foundation"
|
name = "core-foundation"
|
||||||
version = "0.10.1"
|
version = "0.10.1"
|
||||||
|
|
@ -540,9 +536,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
|
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.0",
|
"bitflags 2.13.0",
|
||||||
"core-foundation 0.10.1",
|
"core-foundation",
|
||||||
"core-graphics-types",
|
"core-graphics-types",
|
||||||
"foreign-types 0.5.0",
|
"foreign-types",
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -553,7 +549,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.0",
|
"bitflags 2.13.0",
|
||||||
"core-foundation 0.10.1",
|
"core-foundation",
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -898,15 +894,6 @@ version = "1.2.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
|
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]]
|
[[package]]
|
||||||
name = "endi"
|
name = "endi"
|
||||||
version = "1.1.1"
|
version = "1.1.1"
|
||||||
|
|
@ -1041,15 +1028,6 @@ version = "0.2.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
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]]
|
[[package]]
|
||||||
name = "foreign-types"
|
name = "foreign-types"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
|
|
@ -1057,7 +1035,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
|
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"foreign-types-macros",
|
"foreign-types-macros",
|
||||||
"foreign-types-shared 0.3.1",
|
"foreign-types-shared",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1071,12 +1049,6 @@ dependencies = [
|
||||||
"syn 2.0.117",
|
"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]]
|
[[package]]
|
||||||
name = "foreign-types-shared"
|
name = "foreign-types-shared"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
|
|
@ -1302,8 +1274,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"wasi",
|
"wasi",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1313,9 +1287,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi 5.3.0",
|
"r-efi 5.3.0",
|
||||||
"wasip2",
|
"wasip2",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1497,25 +1473,6 @@ dependencies = [
|
||||||
"syn 2.0.117",
|
"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]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.12.3"
|
version = "0.12.3"
|
||||||
|
|
@ -1620,7 +1577,6 @@ dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"h2",
|
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"httparse",
|
"httparse",
|
||||||
|
|
@ -1644,22 +1600,7 @@ dependencies = [
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
]
|
"webpki-roots",
|
||||||
|
|
||||||
[[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",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1680,11 +1621,9 @@ dependencies = [
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"socket2",
|
"socket2",
|
||||||
"system-configuration",
|
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"tracing",
|
"tracing",
|
||||||
"windows-registry",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -2101,6 +2040,12 @@ version = "0.4.32"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
|
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lru-slab"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "markup5ever"
|
name = "markup5ever"
|
||||||
version = "0.38.0"
|
version = "0.38.0"
|
||||||
|
|
@ -2175,23 +2120,6 @@ dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "ndk"
|
name = "ndk"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
|
|
@ -2473,49 +2401,6 @@ dependencies = [
|
||||||
"pathdiff",
|
"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]]
|
[[package]]
|
||||||
name = "option-ext"
|
name = "option-ext"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
|
|
@ -2742,6 +2627,15 @@ version = "0.2.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
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]]
|
[[package]]
|
||||||
name = "precomputed-hash"
|
name = "precomputed-hash"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
|
|
@ -2829,6 +2723,61 @@ dependencies = [
|
||||||
"memchr",
|
"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]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.45"
|
version = "1.0.45"
|
||||||
|
|
@ -2850,6 +2799,35 @@ version = "6.0.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
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]]
|
[[package]]
|
||||||
name = "raw-window-handle"
|
name = "raw-window-handle"
|
||||||
version = "0.6.2"
|
version = "0.6.2"
|
||||||
|
|
@ -2944,29 +2922,26 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
"encoding_rs",
|
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"h2",
|
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
"hyper",
|
"hyper",
|
||||||
"hyper-rustls",
|
"hyper-rustls",
|
||||||
"hyper-tls",
|
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
"mime",
|
|
||||||
"native-tls",
|
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"quinn",
|
||||||
|
"rustls",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_urlencoded",
|
"serde_urlencoded",
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-native-tls",
|
"tokio-rustls",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
|
|
@ -2974,6 +2949,7 @@ dependencies = [
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
|
"webpki-roots",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -3083,6 +3059,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
|
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"once_cell",
|
"once_cell",
|
||||||
|
"ring",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"rustls-webpki",
|
"rustls-webpki",
|
||||||
"subtle",
|
"subtle",
|
||||||
|
|
@ -3095,6 +3072,7 @@ version = "1.14.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
|
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"web-time",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -3130,15 +3108,6 @@ dependencies = [
|
||||||
"winapi-util",
|
"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]]
|
[[package]]
|
||||||
name = "schemars"
|
name = "schemars"
|
||||||
version = "0.8.22"
|
version = "0.8.22"
|
||||||
|
|
@ -3196,29 +3165,6 @@ version = "1.2.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
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]]
|
[[package]]
|
||||||
name = "selectors"
|
name = "selectors"
|
||||||
version = "0.36.1"
|
version = "0.36.1"
|
||||||
|
|
@ -3638,27 +3584,6 @@ dependencies = [
|
||||||
"syn 2.0.117",
|
"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]]
|
[[package]]
|
||||||
name = "system-deps"
|
name = "system-deps"
|
||||||
version = "6.2.2"
|
version = "6.2.2"
|
||||||
|
|
@ -3680,7 +3605,7 @@ checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.0",
|
"bitflags 2.13.0",
|
||||||
"block2",
|
"block2",
|
||||||
"core-foundation 0.10.1",
|
"core-foundation",
|
||||||
"core-graphics",
|
"core-graphics",
|
||||||
"crossbeam-channel",
|
"crossbeam-channel",
|
||||||
"dbus",
|
"dbus",
|
||||||
|
|
@ -4199,16 +4124,6 @@ dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "tokio-rustls"
|
name = "tokio-rustls"
|
||||||
version = "0.26.4"
|
version = "0.26.4"
|
||||||
|
|
@ -4593,12 +4508,6 @@ dependencies = [
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "vcpkg"
|
|
||||||
version = "0.2.15"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "version-compare"
|
name = "version-compare"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
|
|
@ -4786,6 +4695,16 @@ dependencies = [
|
||||||
"wasm-bindgen",
|
"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]]
|
[[package]]
|
||||||
name = "web_atoms"
|
name = "web_atoms"
|
||||||
version = "0.2.4"
|
version = "0.2.4"
|
||||||
|
|
@ -4842,6 +4761,15 @@ dependencies = [
|
||||||
"system-deps",
|
"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]]
|
[[package]]
|
||||||
name = "webview2-com"
|
name = "webview2-com"
|
||||||
version = "0.38.2"
|
version = "0.38.2"
|
||||||
|
|
@ -5027,17 +4955,6 @@ dependencies = [
|
||||||
"windows-link 0.1.3",
|
"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]]
|
[[package]]
|
||||||
name = "windows-result"
|
name = "windows-result"
|
||||||
version = "0.3.4"
|
version = "0.3.4"
|
||||||
|
|
@ -5638,6 +5555,26 @@ dependencies = [
|
||||||
"zvariant",
|
"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]]
|
[[package]]
|
||||||
name = "zerofrom"
|
name = "zerofrom"
|
||||||
version = "0.1.8"
|
version = "0.1.8"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "summarum"
|
name = "summarum"
|
||||||
version = "1.0.0"
|
version = "1.1.0"
|
||||||
description = "Summarum — natural language calculator notebook for Windows"
|
description = "Summarum — natural language calculator notebook for Windows"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
|
|
@ -16,7 +16,7 @@ tauri-plugin-opener = "2"
|
||||||
tauri-plugin-dialog = "2"
|
tauri-plugin-dialog = "2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
reqwest = { version = "0.12", features = ["json"] }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,12 @@
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
use tauri::menu::{Menu, MenuItem};
|
use tauri::menu::{Menu, MenuItem};
|
||||||
use tauri::tray::TrayIconBuilder;
|
use tauri::tray::TrayIconBuilder;
|
||||||
use tauri::{AppHandle, Emitter, Manager, WindowEvent};
|
use tauri::{AppHandle, Emitter, Manager, WindowEvent};
|
||||||
|
use tauri_plugin_opener::OpenerExt;
|
||||||
|
|
||||||
const RATES_TTL_SECS: u64 = 3600;
|
const RATES_TTL_SECS: u64 = 3600;
|
||||||
const SAMPLE_JS: &str = include_str!("../extension-sample.js");
|
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("..")
|
!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]
|
#[tauri::command]
|
||||||
fn save_file(app: AppHandle, name: String, contents: String) -> Result<(), String> {
|
fn save_file(app: AppHandle, name: String, contents: String) -> Result<(), String> {
|
||||||
if !safe_name(&name) {
|
if !safe_name(&name) {
|
||||||
return Err("bad file name".into());
|
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]
|
#[tauri::command]
|
||||||
|
|
@ -152,7 +160,7 @@ async fn fetch_rates(app: AppHandle, force: bool) -> Result<RatesPayload, String
|
||||||
Ok(payload) => {
|
Ok(payload) => {
|
||||||
let cache = RatesCache { fetched_at: now_secs(), payload: payload.clone() };
|
let cache = RatesCache { fetched_at: now_secs(), payload: payload.clone() };
|
||||||
if let Ok(raw) = serde_json::to_string(&cache) {
|
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)
|
Ok(payload)
|
||||||
}
|
}
|
||||||
|
|
@ -179,7 +187,7 @@ fn load_documents(app: AppHandle, dir: Option<String>) -> Result<Option<String>,
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn save_documents(app: AppHandle, dir: Option<String>, contents: String) -> Result<(), String> {
|
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
|
// ---------- 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> {
|
fn open_backups_folder(app: AppHandle, dir: Option<String>) -> Result<(), String> {
|
||||||
let bdir = docs_dir(&app, &dir).join("backups");
|
let bdir = docs_dir(&app, &dir).join("backups");
|
||||||
fs::create_dir_all(&bdir).ok();
|
fs::create_dir_all(&bdir).ok();
|
||||||
std::process::Command::new("explorer")
|
app.opener()
|
||||||
.arg(bdir)
|
.open_path(bdir.to_string_lossy(), None::<&str>)
|
||||||
.spawn()
|
.map_err(|e| e.to_string())
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- data folder migration
|
// ---------- 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 old = docs_dir(&app, &old_dir);
|
||||||
let new = PathBuf::from(&new_dir);
|
let new = PathBuf::from(&new_dir);
|
||||||
fs::create_dir_all(&new).map_err(|e| e.to_string())?;
|
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(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
if strategy == "use_existing" {
|
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())?;
|
fs::copy(&old_docs, new.join("documents.json")).map_err(|e| e.to_string())?;
|
||||||
}
|
}
|
||||||
// merge backups (skip files that already exist in the target)
|
// merge backups (skip files that already exist in the target)
|
||||||
|
let mut all_copied = true;
|
||||||
for sub in ["backups", "backups/deleted"] {
|
for sub in ["backups", "backups/deleted"] {
|
||||||
let from = old.join(sub);
|
let from = old.join(sub);
|
||||||
let to = new.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) {
|
if let Ok(entries) = fs::read_dir(&from) {
|
||||||
for entry in entries.flatten() {
|
for entry in entries.flatten() {
|
||||||
let dest = to.join(entry.file_name());
|
let dest = to.join(entry.file_name());
|
||||||
if entry.path().is_file() && !dest.exists() {
|
if entry.path().is_file() && !dest.exists() && fs::copy(entry.path(), dest).is_err() {
|
||||||
fs::copy(entry.path(), dest).ok();
|
all_copied = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// originals removed only after everything is 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();
|
||||||
}
|
}
|
||||||
fs::remove_dir_all(old.join("backups")).ok();
|
if all_copied {
|
||||||
|
fs::remove_dir_all(old.join("backups")).ok();
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -317,7 +329,7 @@ fn read_text_file(path: String) -> Result<String, String> {
|
||||||
.and_then(|e| e.to_str())
|
.and_then(|e| e.to_str())
|
||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
.to_lowercase();
|
.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());
|
return Err("unsupported file type".into());
|
||||||
}
|
}
|
||||||
let meta = fs::metadata(&p).map_err(|e| e.to_string())?;
|
let meta = fs::metadata(&p).map_err(|e| e.to_string())?;
|
||||||
|
|
@ -365,11 +377,16 @@ fn load_extensions(app: AppHandle) -> Vec<ExtensionScript> {
|
||||||
out
|
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]
|
#[tauri::command]
|
||||||
fn get_launch_file() -> Option<String> {
|
fn get_launch_file() -> Option<String> {
|
||||||
let arg = std::env::args().nth(1)?;
|
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()
|
fs::read_to_string(arg).ok()
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
|
|
@ -379,11 +396,29 @@ fn get_launch_file() -> Option<String> {
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn open_extensions_folder(app: AppHandle) -> Result<(), String> {
|
fn open_extensions_folder(app: AppHandle) -> Result<(), String> {
|
||||||
let dir = extensions_dir(&app);
|
let dir = extensions_dir(&app);
|
||||||
std::process::Command::new("explorer")
|
app.opener()
|
||||||
.arg(dir)
|
.open_path(dir.to_string_lossy(), None::<&str>)
|
||||||
.spawn()
|
.map_err(|e| e.to_string())
|
||||||
.map_err(|e| e.to_string())?;
|
}
|
||||||
Ok(())
|
|
||||||
|
/// 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) {
|
fn toggle_window(app: &AppHandle) {
|
||||||
|
|
@ -401,7 +436,7 @@ fn toggle_window(app: &AppHandle) {
|
||||||
fn main() {
|
fn main() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
|
.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) {
|
if let Ok(content) = fs::read_to_string(path) {
|
||||||
app.emit("open-file", content).ok();
|
app.emit("open-file", content).ok();
|
||||||
}
|
}
|
||||||
|
|
@ -432,7 +467,8 @@ fn main() {
|
||||||
load_extensions,
|
load_extensions,
|
||||||
open_extensions_folder,
|
open_extensions_folder,
|
||||||
get_launch_file,
|
get_launch_file,
|
||||||
read_text_file
|
read_text_file,
|
||||||
|
exit_app
|
||||||
])
|
])
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
let handle = app.handle().clone();
|
let handle = app.handle().clone();
|
||||||
|
|
@ -446,7 +482,7 @@ fn main() {
|
||||||
.show_menu_on_left_click(false)
|
.show_menu_on_left_click(false)
|
||||||
.on_menu_event(move |app, event| match event.id.as_ref() {
|
.on_menu_event(move |app, event| match event.id.as_ref() {
|
||||||
"show" => toggle_window(app),
|
"show" => toggle_window(app),
|
||||||
"quit" => app.exit(0),
|
"quit" => request_quit(app),
|
||||||
_ => {}
|
_ => {}
|
||||||
})
|
})
|
||||||
.on_tray_icon_event(|tray, event| {
|
.on_tray_icon_event(|tray, event| {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Summarum",
|
"productName": "Summarum",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"identifier": "app.summarum.calc",
|
"identifier": "app.summarum.calc",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
|
|
@ -23,7 +23,7 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"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": {
|
"bundle": {
|
||||||
|
|
|
||||||
|
|
@ -88,22 +88,40 @@ export function startOfToday(): number {
|
||||||
return d.getTime();
|
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 {
|
export function addToDate(ms: number, amount: number, unitId: string): number {
|
||||||
const baseId = unitId.includes(":") ? unitId.split(":")[1] : unitId;
|
const baseId = unitId.includes(":") ? unitId.split(":")[1] : unitId;
|
||||||
const d = new Date(ms);
|
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)) {
|
if (baseId === "month" && Number.isInteger(amount)) {
|
||||||
|
const day = d.getDate();
|
||||||
|
d.setDate(1);
|
||||||
d.setMonth(d.getMonth() + amount);
|
d.setMonth(d.getMonth() + amount);
|
||||||
|
d.setDate(Math.min(day, lastDayOfMonth(d)));
|
||||||
return d.getTime();
|
return d.getTime();
|
||||||
}
|
}
|
||||||
if (baseId === "year" && Number.isInteger(amount)) {
|
if (baseId === "year" && Number.isInteger(amount)) {
|
||||||
|
const day = d.getDate();
|
||||||
|
d.setDate(1);
|
||||||
d.setFullYear(d.getFullYear() + amount);
|
d.setFullYear(d.getFullYear() + amount);
|
||||||
|
d.setDate(Math.min(day, lastDayOfMonth(d)));
|
||||||
return d.getTime();
|
return d.getTime();
|
||||||
}
|
}
|
||||||
return ms; // caller falls back to ms math for everything else
|
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 {
|
export function isCalendarUnit(unitId: string, amount: number): boolean {
|
||||||
const baseId = unitId.includes(":") ? unitId.split(":")[1] : unitId;
|
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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -142,3 +142,68 @@ describe("crypto", () => {
|
||||||
expect(r).toMatch(/^\$/);
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -195,7 +195,11 @@ function evalBin(op: "plus" | "minus" | "mul" | "div" | "mod" | "pow", l: Value,
|
||||||
case "div":
|
case "div":
|
||||||
if (l.kind === "percent") throw new EvalError("percent div number");
|
if (l.kind === "percent") throw new EvalError("percent div number");
|
||||||
return qty(q.value.div(p.div(100)), q.unit);
|
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");
|
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 {
|
function evalBit(op: string, l: Value, r: Value): Value {
|
||||||
if (l.kind !== "quantity" || r.kind !== "quantity") throw new EvalError("bitwise needs integers");
|
if (l.kind !== "quantity" || r.kind !== "quantity") throw new EvalError("bitwise needs integers");
|
||||||
|
if (!l.value.isFinite() || !r.value.isFinite()) throw new EvalError("bitwise needs finite integers");
|
||||||
const a = BigInt(l.value.toDecimalPlaces(0).toFixed(0));
|
const 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)) throw new EvalError("bad shift");
|
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") {
|
if (op === "of" || op === "off" || op === "on") {
|
||||||
const p = l.kind === "percent" ? l.value : asScalar(l).mul(100);
|
const p = l.kind === "percent" ? l.value : asScalar(l).mul(100);
|
||||||
switch (op) {
|
const factor =
|
||||||
case "of": return qty(rv.mul(p).div(100), unit);
|
op === "of" ? p.div(100)
|
||||||
case "off": return qty(rv.mul(new Decimal(1).sub(p.div(100))), unit);
|
: op === "off" ? new Decimal(1).sub(p.div(100))
|
||||||
case "on": return qty(rv.mul(new Decimal(1).add(p.div(100))), unit);
|
: 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") {
|
if (op === "as_pct_of" || op === "as_pct_off" || op === "as_pct_on") {
|
||||||
const lv = l.kind === "quantity" ? l.value : asScalar(l);
|
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 {
|
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");
|
||||||
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" });
|
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" }>;
|
||||||
|
|
@ -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");
|
if (op === "minus" && l.kind !== "date") 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;
|
||||||
if (isCalendarUnit(span.unit.id, amount)) {
|
if (isCalendarUnit(span.unit.id, amount)) {
|
||||||
ms = addToDate(date.ms, amount, span.unit.id);
|
ms = addToDate(date.ms, amount, span.unit.id);
|
||||||
} else {
|
} 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 {
|
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");
|
if (block.length === 0) throw new EvalError("nothing to aggregate");
|
||||||
block.reverse();
|
block.reverse();
|
||||||
let acc = block[0];
|
let acc = block[0];
|
||||||
|
let counted = 1;
|
||||||
for (let i = 1; i < block.length; i++) {
|
for (let i = 1; i < block.length; i++) {
|
||||||
const item = block[i];
|
const item = block[i];
|
||||||
try {
|
try {
|
||||||
acc = numericAdd(acc, item, 1);
|
acc = numericAdd(acc, item, 1);
|
||||||
|
counted++;
|
||||||
} catch {
|
} catch {
|
||||||
// skip lines with incompatible dimensions, as Numi does
|
// 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;
|
return acc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,12 @@ function group(intPart: string, sep: string): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatNumber(v: Decimal, s: EngineSettings): 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();
|
const str = rounded.toString();
|
||||||
if (str.includes("e") || str.includes("E")) return str;
|
if (str.includes("e") || str.includes("E")) return str;
|
||||||
const [int, frac] = str.split(".");
|
const [int, frac] = str.split(".");
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,7 @@ export class SumEngine {
|
||||||
|
|
||||||
const tokens = tokenize(line, this.reg);
|
const tokens = tokenize(line, this.reg);
|
||||||
const knownVars = new Set(vars.keys());
|
const knownVars = new Set(vars.keys());
|
||||||
const parsed = parseLine(tokens, knownVars);
|
const parsed = parseLine(tokens, knownVars, line);
|
||||||
|
|
||||||
let value: Value | null = null;
|
let value: Value | null = null;
|
||||||
if (parsed.expr) {
|
if (parsed.expr) {
|
||||||
|
|
@ -101,11 +101,14 @@ export class SumEngine {
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
value = evaluate(parsed.expr, ctx);
|
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) {
|
} catch (e) {
|
||||||
if (!(e instanceof EvalError)) throw e;
|
if (!(e instanceof EvalError)) console.warn("evaluate failed:", e);
|
||||||
value = null;
|
value = null;
|
||||||
}
|
}
|
||||||
|
if (value?.kind === "quantity" && !value.value.isFinite()) value = null;
|
||||||
|
if (value && parsed.assign) vars.set(parsed.assign, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
lineValues.push(value);
|
lineValues.push(value);
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,14 @@ export interface Lex {
|
||||||
}
|
}
|
||||||
|
|
||||||
const WORD_RE = /^[\p{L}_]+/u;
|
const WORD_RE = /^[\p{L}_]+/u;
|
||||||
// Order matters: grouped thousands first, then plain decimal/exponent, then leading-dot.
|
// Number forms, tried in order. Space/NBSP-grouped numbers may use a decimal
|
||||||
const NUM_RE = /^(?:\d{1,3}(?:,\d{3})+(?:\.\d+)?|\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|\.\d+)/;
|
// 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[] {
|
export function lexLine(text: string): Lex[] {
|
||||||
const out: Lex[] = [];
|
const out: Lex[] = [];
|
||||||
|
|
@ -46,12 +52,31 @@ export function lexLine(text: string): Lex[] {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const num = NUM_RE.exec(rest);
|
if (ch >= "0" && ch <= "9") {
|
||||||
if (num && (ch >= "0" && ch <= "9")) {
|
let raw: string | null = null;
|
||||||
const raw = num[0];
|
let cleaned = "";
|
||||||
out.push({ type: "num", raw, start: i, end: i + raw.length, value: new Decimal(raw.replace(/,/g, "")), repr: "decimal" });
|
const sp = NUM_SPACE_GROUPED_RE.exec(rest);
|
||||||
i += raw.length;
|
const cg = sp ? null : NUM_COMMA_GROUPED_RE.exec(rest);
|
||||||
continue;
|
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)) {
|
if (ch === "." && /^\.\d/.test(rest)) {
|
||||||
const m = /^\.\d+/.exec(rest)!;
|
const m = /^\.\d+/.exec(rest)!;
|
||||||
|
|
|
||||||
|
|
@ -38,14 +38,24 @@ export interface ParsedLine {
|
||||||
expr: Node | null;
|
expr: Node | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseLine(tokens: Token[], knownVars: Set<string>): ParsedLine {
|
const VAR_NAME_RE = /^[\p{L}_][\p{L}\d_]*$/u;
|
||||||
let assign: string | undefined;
|
|
||||||
let toks = tokens;
|
|
||||||
|
|
||||||
// `x = expr` / `x is expr`
|
export function parseLine(tokens: Token[], knownVars: Set<string>, line: string): ParsedLine {
|
||||||
if (toks.length >= 2 && toks[0].t === "word" && toks[1].t === "assign") {
|
let assign: string | undefined;
|
||||||
assign = toks[0].raw;
|
// a defined variable shadows whatever its name tokenized as (unit "m", agg "sum"...)
|
||||||
toks = toks.slice(2);
|
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
|
// Noise filtering: drop junk and unknown words, but keep words that follow
|
||||||
|
|
@ -106,6 +116,29 @@ class Parser {
|
||||||
}
|
}
|
||||||
continue;
|
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 before = this.i;
|
||||||
const node = this.parseBit();
|
const node = this.parseBit();
|
||||||
if (node) items.push(node);
|
if (node) items.push(node);
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
// for the tokenizer. Longest phrase wins; case-sensitive entries (symbols
|
// for the tokenizer. Longest phrase wins; case-sensitive entries (symbols
|
||||||
// like "m"/"M", "kB") are tried before the case-insensitive ones.
|
// like "m"/"M", "kB") are tried before the case-insensitive ones.
|
||||||
import { Decimal, Dimension, NumeralRepr, Unit, Value } from "./types";
|
import { Decimal, Dimension, NumeralRepr, Unit, Value } from "./types";
|
||||||
import { lexPhrase } from "./lexer";
|
import { Lex, lexLine } from "./lexer";
|
||||||
import * as vocab from "./vocab";
|
import * as vocab from "./vocab";
|
||||||
import { UNIT_DATA, SI_PREFIXES, DATA_SI_PREFIXES, IEC_PREFIXES, SCALE_DATA } from "./unitdata";
|
import { UNIT_DATA, SI_PREFIXES, DATA_SI_PREFIXES, IEC_PREFIXES, SCALE_DATA } from "./unitdata";
|
||||||
import { EXTRA_UNITS, CRYPTO } from "./extraunits";
|
import { EXTRA_UNITS, CRYPTO } from "./extraunits";
|
||||||
|
|
@ -38,6 +38,11 @@ interface PhraseEntry {
|
||||||
lexemes: string[]; // raw, as in the phrase
|
lexemes: string[]; // raw, as in the phrase
|
||||||
lower: string[];
|
lower: string[];
|
||||||
caseSensitive: boolean;
|
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;
|
payload: Payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -68,10 +73,16 @@ export class Registry {
|
||||||
completions: Completion[] = [];
|
completions: Completion[] = [];
|
||||||
|
|
||||||
addPhrase(phrase: string, payload: Payload, opts: { caseSensitive?: boolean } = {}): void {
|
addPhrase(phrase: string, payload: Payload, opts: { caseSensitive?: boolean } = {}): void {
|
||||||
const lexemes = lexPhrase(phrase);
|
const lexs = lexLine(phrase);
|
||||||
if (lexemes.length === 0) return;
|
if (lexs.length === 0) return;
|
||||||
|
const lexemes = lexs.map((l) => l.raw);
|
||||||
const caseSensitive = opts.caseSensitive ?? lexemes.every((l) => l.length <= 2);
|
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 key = entry.lower[0];
|
||||||
const list = this.byFirst.get(key);
|
const list = this.byFirst.get(key);
|
||||||
if (list) {
|
if (list) {
|
||||||
|
|
@ -88,16 +99,20 @@ export class Registry {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Match the longest phrase at position i of the lexeme stream.
|
* 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]);
|
const list = this.byFirst.get(lowers[i]);
|
||||||
if (!list) return null;
|
if (!list) return null;
|
||||||
for (const e of list) {
|
for (const e of list) {
|
||||||
if (i + e.lexemes.length > raws.length) continue;
|
if (i + e.lexemes.length > lexes.length) continue;
|
||||||
let ok = true;
|
let ok = true;
|
||||||
for (let k = 0; k < e.lexemes.length; k++) {
|
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;
|
ok = false;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -222,6 +237,8 @@ export function buildRegistry(): Registry {
|
||||||
// units, SI/IEC prefixes, area/volume templates
|
// units, SI/IEC prefixes, area/volume templates
|
||||||
interface LengthPhrase { phrase: string; unit: Unit; caseSensitive?: boolean }
|
interface LengthPhrase { phrase: string; unit: Unit; caseSensitive?: boolean }
|
||||||
const lengthPhrases: LengthPhrase[] = [];
|
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) {
|
for (const d of UNIT_DATA) {
|
||||||
const variants = vocab.variants(d.category, `${d.id}.variants`);
|
const variants = vocab.variants(d.category, `${d.id}.variants`);
|
||||||
|
|
@ -276,6 +293,14 @@ export function buildRegistry(): Registry {
|
||||||
reg.unitsById.set(pUnit.id, pUnit);
|
reg.unitsById.set(pUnit.id, pUnit);
|
||||||
for (const ph of csPhrases) reg.addPhrase(ph, { t: "unit", unit: pUnit }, { caseSensitive: true });
|
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 });
|
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") {
|
if (d.dimension === "length") {
|
||||||
for (const ph of csPhrases) lengthPhrases.push({ phrase: ph, unit: pUnit, caseSensitive: true });
|
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 });
|
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
|
// Area/volume from templates: "square %@" / "cubic %@" applied to every length phrase
|
||||||
const applyTemplates = (
|
const applyTemplates = (
|
||||||
templates: string[],
|
templates: string[],
|
||||||
|
|
|
||||||
|
|
@ -32,8 +32,7 @@ const SYM_OPS: Record<string, Token["t"] | "plus" | "minus" | "mul" | "div" | "p
|
||||||
export function tokenize(line: string, reg: Registry): Token[] {
|
export function tokenize(line: string, reg: Registry): Token[] {
|
||||||
void SYM_OPS;
|
void SYM_OPS;
|
||||||
const lexes = lexLine(line);
|
const lexes = lexLine(line);
|
||||||
const raws = lexes.map((l) => l.raw);
|
const lowers = lexes.map((l) => l.raw.toLowerCase());
|
||||||
const lowers = raws.map((r) => r.toLowerCase());
|
|
||||||
const tokens: Token[] = [];
|
const tokens: Token[] = [];
|
||||||
let i = 0;
|
let i = 0;
|
||||||
while (i < lexes.length) {
|
while (i < lexes.length) {
|
||||||
|
|
@ -46,7 +45,7 @@ export function tokenize(line: string, reg: Registry): Token[] {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const m = reg.match(raws, lowers, i);
|
const m = reg.match(lexes, lowers, i);
|
||||||
if (m) {
|
if (m) {
|
||||||
const end = lexes[i + m.length - 1].end;
|
const end = lexes[i + m.length - 1].end;
|
||||||
const s = { start: lx.start, end };
|
const s = { start: lx.start, end };
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,9 @@ const dict: Record<string, Record<string, string>> = {
|
||||||
useExisting: "Use existing",
|
useExisting: "Use existing",
|
||||||
replaceMine: "Replace with mine",
|
replaceMine: "Replace with mine",
|
||||||
folderChanged: "Data folder changed",
|
folderChanged: "Data folder changed",
|
||||||
|
folderError: "Could not change the data folder",
|
||||||
|
hotkeyFailed: "Hotkey is taken by another app",
|
||||||
|
decimalsep: "Decimal separator",
|
||||||
},
|
},
|
||||||
ru: {
|
ru: {
|
||||||
settings: "Настройки",
|
settings: "Настройки",
|
||||||
|
|
@ -78,6 +81,9 @@ const dict: Record<string, Record<string, string>> = {
|
||||||
useExisting: "Использовать их",
|
useExisting: "Использовать их",
|
||||||
replaceMine: "Заменить моими",
|
replaceMine: "Заменить моими",
|
||||||
folderChanged: "Папка данных изменена",
|
folderChanged: "Папка данных изменена",
|
||||||
|
folderError: "Не удалось сменить папку данных",
|
||||||
|
hotkeyFailed: "Хоткей занят другой программой",
|
||||||
|
decimalsep: "Десятичный разделитель",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
58
src/main.ts
58
src/main.ts
|
|
@ -4,7 +4,7 @@ import { SumEngine } from "./engine";
|
||||||
import { SumEditor } from "./ui/editor";
|
import { SumEditor } from "./ui/editor";
|
||||||
import {
|
import {
|
||||||
AppData, DocMeta, SettingsData, defaultSettingsData,
|
AppData, DocMeta, SettingsData, defaultSettingsData,
|
||||||
loadAppData, saveAppData, loadSettings, saveSettings,
|
loadAppData, saveAppData, flushAppData, onAppQuit, loadSettings, saveSettings,
|
||||||
fetchRates, loadExtensionScripts, openExtensionsFolder, isTauri,
|
fetchRates, loadExtensionScripts, openExtensionsFolder, isTauri,
|
||||||
getLaunchFile, onOpenFile, onFileDrop,
|
getLaunchFile, onOpenFile, onFileDrop,
|
||||||
setDataDir, runBackups, backupDeletedSheet, openBackupsFolder,
|
setDataDir, runBackups, backupDeletedSheet, openBackupsFolder,
|
||||||
|
|
@ -13,9 +13,12 @@ import {
|
||||||
import type { LineResult } from "./engine";
|
import type { LineResult } from "./engine";
|
||||||
import { setLang, detectLang, t } from "./i18n";
|
import { setLang, detectLang, t } from "./i18n";
|
||||||
import { runExtensions } from "./extensions";
|
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;
|
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`);
|
document.documentElement.style.setProperty("--editor-font-size", `${settings.fontSize}px`);
|
||||||
$("#editor-wrap").style.setProperty("--results-width", `${settings.resultsWidth}%`);
|
$("#editor-wrap").style.setProperty("--results-width", `${settings.resultsWidth}%`);
|
||||||
setLang(settings.language);
|
setLang(settings.language);
|
||||||
engine.updateSettings({ precision: settings.precision, groupSeparator: settings.groupSeparator });
|
engine.updateSettings({
|
||||||
|
precision: settings.precision,
|
||||||
|
groupSeparator: settings.groupSeparator,
|
||||||
|
decimalSeparator: settings.decimalSeparator,
|
||||||
|
});
|
||||||
editor?.refresh();
|
editor?.refresh();
|
||||||
syncTitleField();
|
syncTitleField();
|
||||||
$<HTMLElement>("#sidebar").classList.toggle("hidden", !settings.sidebarVisible);
|
$<HTMLElement>("#sidebar").classList.toggle("hidden", !settings.sidebarVisible);
|
||||||
|
|
@ -127,6 +134,7 @@ function bindSettingsUI(): void {
|
||||||
const themeSel = $<HTMLSelectElement>("#set-theme");
|
const themeSel = $<HTMLSelectElement>("#set-theme");
|
||||||
const precision = $<HTMLInputElement>("#set-precision");
|
const precision = $<HTMLInputElement>("#set-precision");
|
||||||
const groupSep = $<HTMLSelectElement>("#set-groupsep");
|
const groupSep = $<HTMLSelectElement>("#set-groupsep");
|
||||||
|
const decimalSep = $<HTMLSelectElement>("#set-decimalsep");
|
||||||
const langSel = $<HTMLSelectElement>("#set-lang");
|
const langSel = $<HTMLSelectElement>("#set-lang");
|
||||||
const hotkey = $<HTMLInputElement>("#set-hotkey");
|
const hotkey = $<HTMLInputElement>("#set-hotkey");
|
||||||
const autostart = $<HTMLInputElement>("#set-autostart");
|
const autostart = $<HTMLInputElement>("#set-autostart");
|
||||||
|
|
@ -136,6 +144,7 @@ function bindSettingsUI(): void {
|
||||||
themeSel.value = settings.theme;
|
themeSel.value = settings.theme;
|
||||||
precision.value = String(settings.precision);
|
precision.value = String(settings.precision);
|
||||||
groupSep.value = settings.groupSeparator;
|
groupSep.value = settings.groupSeparator;
|
||||||
|
decimalSep.value = settings.decimalSeparator;
|
||||||
langSel.value = settings.language;
|
langSel.value = settings.language;
|
||||||
hotkey.value = settings.hotkey;
|
hotkey.value = settings.hotkey;
|
||||||
autostart.checked = settings.autostart;
|
autostart.checked = settings.autostart;
|
||||||
|
|
@ -146,13 +155,19 @@ function bindSettingsUI(): void {
|
||||||
settings.theme = themeSel.value as SettingsData["theme"];
|
settings.theme = themeSel.value as SettingsData["theme"];
|
||||||
settings.precision = Math.max(0, Math.min(15, Number(precision.value) || 2));
|
settings.precision = Math.max(0, Math.min(15, Number(precision.value) || 2));
|
||||||
settings.groupSeparator = groupSep.value;
|
settings.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.language = langSel.value;
|
||||||
settings.fontSize = Math.max(10, Math.min(32, Number(fontSize.value) || 15));
|
settings.fontSize = Math.max(10, Math.min(32, Number(fontSize.value) || 15));
|
||||||
settings.resultsWidth = Math.max(20, Math.min(60, Number(resultsWidth.value) || 42));
|
settings.resultsWidth = Math.max(20, Math.min(60, Number(resultsWidth.value) || 42));
|
||||||
applySettings();
|
applySettings();
|
||||||
void saveSettings(settings);
|
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);
|
el.addEventListener("change", save);
|
||||||
}
|
}
|
||||||
resultsWidth.addEventListener("input", save); // live while sliding
|
resultsWidth.addEventListener("input", save); // live while sliding
|
||||||
|
|
@ -188,10 +203,15 @@ function bindSettingsUI(): void {
|
||||||
if (!keyName) return;
|
if (!keyName) return;
|
||||||
const combo = [...mods, keyName].join("+");
|
const combo = [...mods, keyName].join("+");
|
||||||
const old = settings.hotkey;
|
const old = settings.hotkey;
|
||||||
settings.hotkey = combo;
|
if (await registerHotkey(old, combo)) {
|
||||||
hotkey.value = combo;
|
settings.hotkey = combo;
|
||||||
await registerHotkey(old, combo);
|
hotkey.value = combo;
|
||||||
void saveSettings(settings);
|
void saveSettings(settings);
|
||||||
|
} else {
|
||||||
|
// combo is taken by another app — restore the old one
|
||||||
|
await registerHotkey(null, old);
|
||||||
|
toast(t("hotkeyFailed"));
|
||||||
|
}
|
||||||
hotkey.blur();
|
hotkey.blur();
|
||||||
});
|
});
|
||||||
autostart.addEventListener("change", async () => {
|
autostart.addEventListener("change", async () => {
|
||||||
|
|
@ -224,7 +244,13 @@ function bindSettingsUI(): void {
|
||||||
if (ans === null) return;
|
if (ans === null) return;
|
||||||
strategy = ans === "a" ? "use_existing" : "overwrite";
|
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;
|
settings.dataDir = picked;
|
||||||
setDataDir(picked);
|
setDataDir(picked);
|
||||||
await saveSettings(settings);
|
await saveSettings(settings);
|
||||||
|
|
@ -273,8 +299,8 @@ function normalizeKeyName(e: KeyboardEvent): string | null {
|
||||||
|
|
||||||
// ---------- tauri integration
|
// ---------- tauri integration
|
||||||
|
|
||||||
async function registerHotkey(old: string | null, combo: string): Promise<void> {
|
async function registerHotkey(old: string | null, combo: string): Promise<boolean> {
|
||||||
if (!isTauri()) return;
|
if (!isTauri()) return true;
|
||||||
try {
|
try {
|
||||||
const gs = await import("@tauri-apps/plugin-global-shortcut");
|
const gs = await import("@tauri-apps/plugin-global-shortcut");
|
||||||
if (old) await gs.unregister(old).catch(() => {});
|
if (old) await gs.unregister(old).catch(() => {});
|
||||||
|
|
@ -289,8 +315,10 @@ async function registerHotkey(old: string | null, combo: string): Promise<void>
|
||||||
await win.setFocus();
|
await win.setFocus();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("hotkey registration failed", e);
|
console.warn("hotkey registration failed", e);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -417,11 +445,12 @@ async function boot(): Promise<void> {
|
||||||
await runBackups(settings.dataDir, settings.deletedRetentionDays);
|
await runBackups(settings.dataDir, settings.deletedRetentionDays);
|
||||||
|
|
||||||
const stored = await loadAppData(settings.dataDir);
|
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;
|
data = stored;
|
||||||
} else {
|
} else {
|
||||||
const id = uid();
|
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;
|
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);
|
await registerHotkey(null, settings.hotkey);
|
||||||
if (settings.autostart) await applyAutostart(true);
|
if (settings.autostart) await applyAutostart(true);
|
||||||
|
void onAppQuit(() => flushAppData());
|
||||||
void refreshRates();
|
void refreshRates();
|
||||||
setInterval(() => void refreshRates(), 60 * 60 * 1000);
|
setInterval(() => void refreshRates(), 60 * 60 * 1000);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ export interface SettingsData {
|
||||||
theme: "system" | "light" | "dark";
|
theme: "system" | "light" | "dark";
|
||||||
precision: number;
|
precision: number;
|
||||||
groupSeparator: string;
|
groupSeparator: string;
|
||||||
|
decimalSeparator: string;
|
||||||
language: string;
|
language: string;
|
||||||
hotkey: string;
|
hotkey: string;
|
||||||
autostart: boolean;
|
autostart: boolean;
|
||||||
|
|
@ -34,6 +35,7 @@ export const defaultSettingsData: SettingsData = {
|
||||||
theme: "system",
|
theme: "system",
|
||||||
precision: 2,
|
precision: 2,
|
||||||
groupSeparator: ",",
|
groupSeparator: ",",
|
||||||
|
decimalSeparator: ".",
|
||||||
language: "en",
|
language: "en",
|
||||||
hotkey: "Ctrl+Alt+N",
|
hotkey: "Ctrl+Alt+N",
|
||||||
autostart: false,
|
autostart: false,
|
||||||
|
|
@ -93,6 +95,7 @@ export async function loadAppData(dataDir: string): Promise<AppData | null> {
|
||||||
}
|
}
|
||||||
|
|
||||||
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let pendingData: AppData | null = null;
|
||||||
let currentDataDir = "";
|
let currentDataDir = "";
|
||||||
|
|
||||||
export function setDataDir(dataDir: string): void {
|
export function setDataDir(dataDir: string): void {
|
||||||
|
|
@ -101,16 +104,33 @@ export function setDataDir(dataDir: string): void {
|
||||||
|
|
||||||
export function saveAppData(data: AppData): void {
|
export function saveAppData(data: AppData): void {
|
||||||
// debounce: autosave on every keystroke without disk churn
|
// debounce: autosave on every keystroke without disk churn
|
||||||
|
pendingData = data;
|
||||||
if (saveTimer) clearTimeout(saveTimer);
|
if (saveTimer) clearTimeout(saveTimer);
|
||||||
saveTimer = setTimeout(async () => {
|
saveTimer = setTimeout(() => void flushAppData(), 400);
|
||||||
const raw = JSON.stringify(data);
|
}
|
||||||
try {
|
|
||||||
if (isTauri()) await invoke("save_documents", { dir: dirArg(currentDataDir), contents: raw });
|
/** write whatever is pending right now (used before quitting) */
|
||||||
else localStorage.setItem("numi.documents", raw);
|
export async function flushAppData(): Promise<void> {
|
||||||
} catch (e) {
|
if (saveTimer) {
|
||||||
console.warn("saveAppData failed", e);
|
clearTimeout(saveTimer);
|
||||||
}
|
saveTimer = null;
|
||||||
}, 400);
|
}
|
||||||
|
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
|
// ---------- backups & data folder
|
||||||
|
|
|
||||||
|
|
@ -118,19 +118,22 @@ export class SumEditor {
|
||||||
) {
|
) {
|
||||||
this.resultsEl = resultsEl;
|
this.resultsEl = resultsEl;
|
||||||
|
|
||||||
|
let engineOptions: { label: string; type: string; detail?: string }[] | null = null;
|
||||||
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;
|
||||||
if (word.to - word.from < 2 && !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,
|
label: c.label,
|
||||||
type: c.type === "function" ? "function" : c.type === "keyword" ? "keyword" : "constant",
|
type: c.type === "function" ? "function" : c.type === "keyword" ? "keyword" : "constant",
|
||||||
detail: c.detail,
|
detail: c.detail,
|
||||||
}));
|
}));
|
||||||
|
const options = [...engineOptions];
|
||||||
// document variables
|
// document variables
|
||||||
const text = ctx.state.doc.toString();
|
const text = ctx.state.doc.toString();
|
||||||
for (const m of text.matchAll(/^\s*([\p{L}_][\p{L}\d_]*)\s*=/gmu)) {
|
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 };
|
return { from: word.from, options, validFor: /^[\p{L}_]*$/u };
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue