Replaces `write_text_file` and `write_image_file` with backend commands
`save_text_file_dialog` and `save_image_file_dialog`. Instead of allowing
the frontend to pass an arbitrary absolute path, the Tauri backend now invokes
the OS save dialog directly, eliminating the risk of arbitrary file write
if the frontend is compromised.
Co-authored-by: Horizontal42 <75383519+Horizontal42@users.noreply.github.com>
Replaces `write_text_file` and `write_image_file` with backend commands
`save_text_file_dialog` and `save_image_file_dialog`. Instead of allowing
the frontend to pass an arbitrary absolute path, the Tauri backend now invokes
the OS save dialog directly, eliminating the risk of arbitrary file write
if the frontend is compromised.
Co-authored-by: Horizontal42 <75383519+Horizontal42@users.noreply.github.com>
.jules/ and test_perf.ts kept showing up in Jules/Bolt-generated PRs
(agent notes and a throwaway benchmark script) — ignore them so they
can't land on main by accident. Also picked up the pending
src-tauri/.cargo/ ignore that was sitting uncommitted.
highlightField and varHighlightField rebuilt from scratch on every
CodeMirror transaction, including ones that touch neither the doc nor
the selection (e.g. the setResults effect firing after evaluation).
varHighlightField now only rebuilds when the doc or selection actually
changed; highlightField remaps existing decorations through doc edits
and only does a full rebuild when new results arrive.
resolving was an array checked with .includes() on every xref lookup
during resolution — an O(n) membership test where only membership
ever mattered, not order. Set makes it O(1).
invalidate() re-scans every non-dirty sheet's text with a regex on
every pass of its while loop, even though the text hasn't changed
between passes — cache the parsed refs per sheet id, keyed by text
identity, so repeated passes reuse the same result.
fetchNeededHistoricalRates awaited each date's fetch sequentially even
though they're independent requests — a sheet referencing several
historical dates paid for that latency serially instead of in parallel.
Toolbar buttons (☰ 🔍 ⚙ ✕ ─ ⤓ +) and the untitled doc-title/search
inputs had no accessible name beyond a title tooltip, which screen
readers don't reliably announce.
Covers detectDateOrder, resolveZone, addToDate and isCalendarUnit —
none of it had direct unit coverage before, only indirectly through
the engine's date-arithmetic tests.
releaseDraft: true (needed so build assets land on the same release
object across the two matrix jobs) was never followed by a step that
un-drafted it — the notes job set the changelog but left the release
sitting invisible on GitHub, which also means the auto-updater's
"latest release" lookup never found it (GitHub excludes drafts).
Confirmed against the v1.2.0 run: build + notes both succeeded, but
the release stayed in draft with no publish date. Publish it in the
same step that writes the notes.
The manual rebind flow in Settings already toasts hotkeyFailed when a
combo is taken by another app, but the initial registerHotkey() call
at boot discarded its success/failure result — if the saved hotkey
couldn't be registered (already bound elsewhere, etc.), there was no
error and no indication the shortcut just wasn't active.
appearance:none on the <input> alone didn't strip the native chrome
in every Chromium build — some keep ::-webkit-slider-runnable-track
styled natively unless that pseudo-element gets its own
appearance:none. Made the track's styling explicit there instead of
relying on it inheriting from the input.
Tauri's window-level file-drop hook (needed for dragging a .numi file
in from Explorer) intercepts WebView2's own drag-and-drop session, so
native HTML5 draggable="true" elements never get a valid drop target
inside the webview — the OS always shows "not allowed", confirmed
with an actual mouse-drag simulation before this fix.
Replaced with plain pointer tracking (mousedown/mousemove/mouseup),
the same pattern already used for the results-column divider — full
control over the cursor and the drag-over indicator, no dependency on
the webview's native DnD stack, and it doesn't touch the unrelated
OS file-drop feature at all.
Move-up/move-down felt disconnected from the result — clicking a
button and having two rows silently swap position doesn't read as
motion. Dropped both buttons for native HTML5 drag-and-drop: drag a
sheet, an accent line shows where it'll land, drop to reorder. Still
respects the pinned/unpinned grouping — dragging across that boundary
is a no-op, same as the old up/down buttons.
#market-info had no CSS at all — different font size and color from
#rates-info right next to it, and its own hardcoded English "Market"
text ignored the language setting. Merged its styling into the same
rule as #rates-info and gave it the same .spin pulse while a fetch
is in flight, so a market refresh is as visible as a rates refresh.
It was still the bare OS-native slider — flat gray track everywhere
else in Settings is themed. Now a thin themed track with an accent
thumb that grows slightly while dragging.
Settings, export menu, modal, and search all drove their entrance
with a @keyframes animation gated on :not(.hidden), triggered by a
display:none toggle — animations don't run when a property changes
away from display:none, only when the element (re)enters the render
tree. Closing just flipped straight to display:none with nothing to
watch. Replaced with opacity/transform transitions plus a delayed
visibility flip (visibility switches instantly on open, only after
the fade completes on close) — same visual effect, correct both ways.
The status bar just dimmed the "Rates" text to 0.5 opacity and left
it static for the whole fetch — easy to miss as feedback that
anything is happening. A slow pulse reads as "working" instead of
just "disabled".
Doc title, search input, and settings inputs/selects either zeroed
out the default outline or never set one — Tab-only navigation had
no visible indicator of what's focused. :focus-visible keeps mouse
clicks silent and only rings for keyboard focus.
Default Windows scrollbars were chunky and out of place next to the
minimal chrome elsewhere. WebView2 is Chromium under the hood, so a
plain ::-webkit-scrollbar override works everywhere without JS.
Sidebar collapse now transitions width instead of snapping via
display:none. Export menu, the modal, and the search overlay all get
a short pop-in (scale + fade) matching the settings panel's motion.
Buttons, doc-list items, and status-bar text ease their hover/color
changes instead of snapping.
Left alone on purpose: the results overlay (re-rendered on every
keystroke — animating it would feel laggy, not smooth), the
autocomplete tooltip (CodeMirror's own timing), and the column-resize
drag handle (wants instant feedback while dragging).
Settings panel pops in (scale + fade, 0.15s) instead of appearing
instantly; switching tabs fades the new panel in (0.12s); the tab
buttons themselves transition their background/color on hover and
selection instead of snapping. CSS-only — @keyframes animations run
even from display:none, so no JS timing/exit-animation complexity.
Updates tab, below the check-update button. Reads from
tauri.conf.json via getVersion() in a real build (the source of
truth for what's actually installed); falls back to package.json's
version under vite dev, where there's no Tauri runtime to ask.
The flat settings list had grown to 14 fields plus footer buttons —
split it into General / Format / Data / Updates tabs (same pill style
as the pinned-doc sidebar, active tab gets the selection color).
Wired the date-format setting into the engine, and reworked update
checks per an actual bug report: a boot-only check is easy to miss on
a tray app that can run for weeks without a cold restart, and every
failure was swallowed silently with no logging at all. Now: failures
are logged (console.warn) and successful "no update" checks say why
(console.info, current version vs. latest published release) so the
next report has something to go on; checks also rerun every 6 hours;
Settings has both a manual "Check for updates" button (reports back
via toast) and a toggle to turn automatic checking off entirely —
manual checks keep working either way.
Both were self-contained enough to live in their own modules —
src/ui/search.ts (Ctrl+F overlay, text and result-value queries) and
src/ui/export.ts (render the sheet + results to a PNG, clipboard or
file). Trims main.ts by about 150 lines.
Also fixes a real bug while it's in there: the PNG export's clipboard
fallback did btoa(String.fromCharCode(...bytes)), which blows the
call stack on large sheets (spreading a big array as call arguments).
Chunks the conversion in 0x8000-byte pieces instead.
v7/finance/quote returns 401 now — Yahoo stopped issuing anonymous
clients the cookie+crumb pair it requires. v8 chart still answers
without auth, one symbol per request instead of one bulk call.
Also dropped SBER/GAZP from the tracked symbols; Yahoo delisted
Russian tickers in 2022, so those two always missed anyway.
api.frankfurter.app 301-redirects to frankfurter.dev now, and the old
host is flaky over reqwest/rustls-tls — talk to the new one directly
(Rust backend, the vite-dev web fallback, and the CSP allowlist).
Separately, a sheet reopened on boot with "on 2024-01-01" in it never
fetched historical rates at all: the fetch only ran from switchDoc()
and the editor's onChange, and the editor's initial text is seeded
directly, bypassing both.
Besides ISO, the lexer now accepts D.M.YYYY / M/D/YYYY literals
(31.12.2024, 12/31/2024) with a round-trip validity check so
out-of-range components (2026-13-45, 32.13.2024) fall back to plain
arithmetic instead of silently rolling over to a nearby valid date —
that rollover bug affected ISO literals too, not just the new forms.
Day/month order for ambiguous literals and display layout both come
from a new dateFormat setting (system/iso/dmy/mdy); "system" resolves
once via Intl against the OS locale. Intl.DateTimeFormat instances
are now cached by options key instead of rebuilt on every render.
App-side wiring (settings UI, persistence) comes in a follow-up commit.
The 290-line function is now registerCoreVocab / registerUnits /
registerAreaVolume / registerCurrencies / registerExtraUnits /
registerCrypto / registerCompletions, called in the same order —
phrase-collision priority depends on that order, unchanged. Pure
reorganization, no behavior change.
rawLine.indexOf("//") matched the "//" inside "https://..." too, so
any line with a URL lost everything after it. A comment now only
starts at the beginning of a line or after whitespace.
Two independent traps. The noise filter only keeps word tokens right
after a conversion token (in/as/...); any other token type resets
that, so the "/" in a slash-form IANA id ("Europe/Berlin") reset it
before the parser ever saw "Berlin". The word-collecting loop in
parseTarget() had the same blind spot even once the token was there.
Both needed the same exception, or "Europe/Berlin" still degrades to
just "Europe".
"exp" was also listed as a scientific-notation alias ("123456 in
exp"). Phrases register first-come-first-served, and that alias was
registered before the exp() function, so the function itself never
got the phrase — exp(1) parsed as a bare (1) and returned 1. Drop
exp from the scientific-notation aliases; sci/exponent/exponential
still work.
"2 kg * 500 g" multiplied the raw numbers (1000) and slapped the
left unit on top, giving "1000 kg" instead of "1 kg". Convert the
right operand into the left unit first for same-dimension products;
cross-dimension multiplication ($12 * 3, 5 hours * 2) is unaffected.
evalCall checked args[0] and threw "needs an argument" before ever
falling into the random case, which is the one built-in that's
valid with zero args. Dispatch it first.
In an A<->B cycle, exportsFor(B) run while A is mid-resolution would
compute a wrong export set (assignments dropped by the nested
"circular reference" error) and cache it anyway. Later queries then
served that stale, cycle-broken result even after the cycle-causing
outer call had unwound, giving inconsistent error messages across
repeated evaluations of the same cyclic setup.
Skip caching whenever any line in the result carries a "circular
reference" error - that's a precise, local signal the pass was
cycle-affected, so the sheet gets re-evaluated fresh until an edit
actually breaks the cycle. Normal (non-cyclic) caching is untouched.
Also adds a positive-path test confirming LineResult.error stays
undefined on an ordinary successful line, and a workspace-level test
proving a genuine cycle now reports a stable error across repeated
queries.