Compare commits

..
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 55850841d3 Release 0.14.2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:25:30 -04:00
Brandon ThomasandClaude Opus 5.5 1f73f62c4f Flatpak bundle: skip the debuginfo pass manifest-wide (runner has no eu-strip)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:25:28 -04:00
Brandon Thomas fb1e59ff61 Merge remote-tracking branch 'origin/main' into land-1030b 2026-10-30 23:16:31 -04:00
Brandon ThomasandClaude Opus 5.5 8f59487cbe Release 0.14.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:15:39 -04:00
Brandon ThomasandClaude Opus 5.5 71d4e2311d Flatpak bundle: skip the debuginfo pass for cups-client (runner has no eu-strip)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:15:37 -04:00
Brandon Thomas 213fe1150e Review fixes: one Depth table for the BINOM, BETA and HARMEAN notes; summarise the function-parity header 2026-10-30 23:07:11 -04:00
Brandon Thomas 0db7d97f2e Merge PR #3008
# Conflicts:
#	docs/function-parity.md
2026-10-30 23:06:47 -04:00
Brandon Thomas 4d1233d0a2 Merge PR #3007
# Conflicts:
#	docs/function-parity.md
2026-10-30 23:06:47 -04:00
Brandon Thomas 07c8201786 Merge PR #3006
# Conflicts:
#	docs/function-parity.md
2026-10-30 23:06:47 -04:00
Brandon Thomas 31d5cc5b41 Merge PR #3003
# Conflicts:
#	crates/functions/src/stat/tests.rs
#	docs/function-parity.md
2026-10-30 23:06:47 -04:00
Brandon Thomas c0fcc7de16 Merge PR #3002
# Conflicts:
#	docs/function-parity.md
2026-10-30 23:06:47 -04:00
Brandon Thomas 5390e230d8 Merge PR #3001
# Conflicts:
#	crates/functions/src/stat/tests.rs
#	docs/function-parity.md
2026-10-30 23:06:46 -04:00
Brandon Thomas c9eb797d8b Merge PR #3000
# Conflicts:
#	crates/functions/src/stat/tests.rs
#	docs/function-parity.md
2026-10-30 23:06:46 -04:00
Brandon Thomas 5441691ca3 Merge PR #2999
# Conflicts:
#	crates/functions/src/stat/tests.rs
#	docs/function-parity.md
2026-10-30 23:04:26 -04:00
Brandon Thomas d3f54debd9 Merge PR #2998
# Conflicts:
#	docs/function-parity.md
2026-10-30 23:04:10 -04:00
Brandon Thomas 64ea0cb69c Merge PR #2997
# Conflicts:
#	crates/functions/src/text.rs
#	docs/function-parity.md
2026-10-30 23:04:01 -04:00
Brandon Thomas f887ecf1cf Merge PR #2996
# Conflicts:
#	docs/function-parity.md
2026-10-30 23:03:41 -04:00
Brandon Thomas ae598a74fe Merge PR #2995
# Conflicts:
#	docs/function-parity.md
2026-10-30 23:03:41 -04:00
Brandon Thomas bc0fffbe97 Merge PR #2994
# Conflicts:
#	docs/function-parity.md
2026-10-30 23:03:40 -04:00
Brandon Thomas 1ebef5a694 Merge PR #2992
# Conflicts:
#	docs/function-parity.md
2026-10-30 23:03:39 -04:00
Brandon Thomas f812c53814 Merge PR #2991
# Conflicts:
#	docs/function-parity.md
2026-10-30 23:03:38 -04:00
Brandon Thomas b775b3ce32 Merge PR #2990
# Conflicts:
#	docs/function-parity.md
2026-10-30 23:03:38 -04:00
Brandon Thomas 76aed738e3 Merge PR #2989 2026-10-30 23:03:37 -04:00
Brandon Thomas a44d3d5ee5 Merge PR #2982 2026-10-30 23:03:36 -04:00
Brandon Thomas 2bf5dbac84 Merge PR #2983 2026-10-30 23:03:36 -04:00
Brandon Thomas c269ff483d Merge PR #2993 2026-10-30 23:03:35 -04:00
Brandon ThomasandClaude Opus 5.5 15f6297001 Release 0.14.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:59:24 -04:00
Harrison Booth 006b7962bd Preserve F distribution tail accuracy 2026-10-30 21:14:19 -05:00
Harrison Booth 7ea3a4c2c3 Merge upstream main into harmonic mean branch 2026-10-30 20:59:56 -05:00
Harrison Booth cae0c9abdb Merge main into function updates 2026-10-30 20:59:41 -05:00
Harrison Booth ed81d7e34e Merge upstream main into binomial distribution branch 2026-10-30 20:59:39 -05:00
Harrison Booth 8f7dff0d04 Merge main into function updates 2026-10-30 20:59:31 -05:00
Harrison Booth 7b7e734524 Merge main into function updates 2026-10-30 20:59:20 -05:00
Harrison Booth 6d5ab57f84 Merge upstream main into Weibull distribution branch 2026-10-30 20:59:18 -05:00
Harrison Booth a04629576d Merge main into function updates 2026-10-30 20:59:12 -05:00
Harrison Booth 3174020a70 Merge main into function updates 2026-10-30 20:59:02 -05:00
Harrison Booth 781149a593 Merge main into function updates 2026-10-30 20:58:53 -05:00
Harrison Booth 322298b202 Merge upstream main into gamma distribution branch 2026-10-30 20:58:48 -05:00
Harrison Booth 11d6ff1b79 Merge main into function updates 2026-10-30 20:58:38 -05:00
Harrison Booth eefa7c4a54 Merge main into function updates 2026-10-30 20:58:24 -05:00
Harrison Booth 45939a8a41 Merge remote-tracking branch 'upstream/main' into gc-build-g718x
# Conflicts:
#	docs/function-parity.md
2026-10-30 20:56:57 -05:00
Harrison Booth 70da8bb3ae Cover strict upper tail in F inverse 2026-10-30 20:53:15 -05:00
Harrison Booth a0da53f576 Match T DIST zero degree errors by mode 2026-10-30 20:53:00 -05:00
Harrison Booth ca44a5d68f Avoid overflow in t density normalization 2026-10-30 20:52:05 -05:00
Harrison Booth 3b61d660e9 Return NUM for invalid T distribution degrees 2026-10-30 20:51:47 -05:00
Harrison Booth cca8a58bfa Stabilize F distribution CDF at extreme degrees 2026-10-30 20:51:07 -05:00
Harrison Booth d3cd393dba G719: return NA for empty harmonic mean input 2026-10-30 20:25:09 -05:00
Harrison Booth 8985685833 G718: match beta density endpoint errors 2026-10-30 20:24:21 -05:00
Harrison Booth 962a5df841 G717: cap binomial trial counts 2026-10-30 20:23:32 -05:00
Harrison Booth fea4c03ca2 G709: use stable tail at F median 2026-10-30 20:18:56 -05:00
Harrison Booth 1d9096e5ec G709: stabilize F upper tail at extreme degrees 2026-10-30 20:05:47 -05:00
Harrison Booth 8e740d97e1 G707: stabilize decimal boundary cases 2026-10-30 19:58:17 -05:00
Harrison Booth a9bacdca4c G711: keep T.DIST validation isolated 2026-10-30 19:56:36 -05:00
Cédric Legrand 53d6de3c16 docs(releasing): M13: correct outdated prerequisites 2026-10-31 01:49:44 +01:00
Harrison Booth 6aa2cb8223 G709: invert upper F probabilities through complementary tail 2026-10-30 19:49:32 -05:00
Harrison Booth 83e6408b71 G707: align signed output boundary regression 2026-10-30 19:47:30 -05:00
Harrison Booth 1b1224c67a G713: simplify zero-shape boundary 2026-10-30 19:42:50 -05:00
Harrison Booth 213b76af10 Expand bit shift boundary regressions 2026-10-30 19:39:26 -05:00
Harrison Booth 4e5c86d842 G711: preserve small density term at huge degrees 2026-10-30 19:39:05 -05:00
Harrison Booth ad69d4cbdd G710: update zero-degree test error 2026-10-30 19:39:00 -05:00
Harrison Booth 32dbf6a224 G705: fix single-character case conversions 2026-10-30 19:37:03 -05:00
Harrison Booth 11b54ebe40 G708: retain dollar sign when rounding to zero 2026-10-30 19:36:23 -05:00
Harrison Booth 9e710199b1 Move base source parity note to history 2026-10-30 19:36:06 -05:00
Harrison Booth 5ee65a8005 Tighten UNICHAR regressions and parity history 2026-10-30 19:35:50 -05:00
Harrison Booth 48c6b57060 G714: return zero Weibull density at zero 2026-10-30 19:34:59 -05:00
Harrison Booth ec2efe3246 G713: correct gamma density at zero 2026-10-30 19:34:36 -05:00
Harrison Booth 5ed6a1b9c7 G707: enforce fixed and dollar output limits 2026-10-30 19:34:32 -05:00
Harrison Booth 5d89b9efdf Move bit shift parity note to revision history 2026-10-30 19:34:19 -05:00
Harrison Booth f1ff38504a G712: return division error for zero T inverse probability 2026-10-30 19:34:12 -05:00
Harrison Booth 0435ff50f9 G711: stabilize large-degree t density 2026-10-30 19:33:50 -05:00
Harrison Booth 550085f8b8 G710: return division error for zero T degrees 2026-10-30 19:33:10 -05:00
Harrison Booth 3a27743a38 Move places parity note to revision history 2026-10-30 19:32:48 -05:00
Harrison Booth dc691d52db G709: accept F distribution boundary degrees 2026-10-30 19:32:22 -05:00
Harrison Booth bd8cf4e14a G706: avoid binary noise in fixed decimal text 2026-10-30 19:32:19 -05:00
Harrison Booth dc75555ee9 G705: match Excel single-character casing 2026-10-30 19:30:32 -05:00
Harrison Booth 4d725e6bc1 Reject Unicode noncharacters in UNICHAR 2026-10-30 19:24:46 -05:00
Harrison Booth 9fccec28af Handle zero-valued oversized bit shifts 2026-10-30 19:24:22 -05:00
Harrison Booth 52d317c8ff Validate places for negative base conversions 2026-10-30 19:23:51 -05:00
Harrison Booth 00359ee93a Merge current main into G701 branch 2026-10-30 19:23:09 -05:00
Harrison Booth c895acf1f7 Fix base conversion whitespace validation 2026-10-30 19:22:39 -05:00
Dylan M. Taylor 1f4c67ef0a fix(flatpak): allow reading launcher version pins (#2985)
* fix(flatpak): allow reading launcher version pins

* fix(flatpak): allow reading launcher version pins
2026-10-30 20:12:32 -04:00
Michael HatzandClaude Opus 5.5 8cf8425745 Print checkbox test: check that the unchecked box is outlined
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:52:22 +01:00
NetterSilverandClaude Haiku 5.5 59a8b14d4d Print structured refs to columns starting with @ in double brackets
Found while testing crates/formula/src/printer.rs:164 (write_struct_ref, single column without specifiers)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 22:26:49 +00:00
Michael HatzandClaude Opus 5.5 8f93ba2e9c Print checkbox cells as boxes, not TRUE/FALSE (#2972)
A checkbox cell drew a box on screen but printed and exported to PDF
its value as TRUE or FALSE. The print path now draws the box as the
grid does: white with a grey outline when unchecked, green with a
white tick when checked. Other values in a checkbox cell still print
as text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:14:20 +01:00
Brandon Thomas 75f7056e1b Review fixes: keep Plutonium-fream's contributor entry, order ui-parity changelog 2026-10-30 15:42:48 -04:00
Brandon Thomas d0cc17852e Merge PR #2919
# Conflicts:
#	docs/ui-parity.md
2026-10-30 15:42:31 -04:00
Brandon Thomas d43baa2ceb Merge PR #2896
# Conflicts:
#	contributors/people.toml
2026-10-30 15:42:31 -04:00
Brandon Thomas 15ea238fb7 Merge PR #2883
# Conflicts:
#	contributors/people.toml
#	docs/ui-parity.md
2026-10-30 15:42:31 -04:00
Brandon Thomas b2f984a884 Merge PR #2627 2026-10-30 15:42:31 -04:00
Brandon Thomas 4afcaacd44 Merge PR #2620 2026-10-30 15:42:31 -04:00
Brandon Thomas 885a5dd6bc Merge PR #2970 2026-10-30 15:42:31 -04:00
Brandon Thomas 39020af9e9 Merge PR #2955 2026-10-30 15:42:31 -04:00
Brandon Thomas 7abaeba33c Merge PR #2888 2026-10-30 15:42:31 -04:00
Brandon Thomas ecd72eccc4 Merge PR #2851 2026-10-30 15:42:31 -04:00
Brandon Thomas ea0da03e12 Merge PR #2817 2026-10-30 15:42:31 -04:00
Brandon Thomas 569fb8b4c6 Merge PR #2795 2026-10-30 15:42:31 -04:00
Brandon Thomas dec916081a Merge PR #2794
# Conflicts:
#	docs/function-parity.md
2026-10-30 15:42:30 -04:00
Brandon Thomas da3e38750a Merge PR #2769 2026-10-30 15:42:10 -04:00
Brandon Thomas 3e4a2ccc9a Merge PR #2726 2026-10-30 15:42:10 -04:00
Brandon Thomas 27d5930d6b Merge PR #2708 2026-10-30 15:42:10 -04:00
Brandon Thomas 412571bc28 Merge PR #2670 2026-10-30 15:42:10 -04:00
Brandon Thomas 6f77f68fbc Merge PR #2666 2026-10-30 15:42:10 -04:00
Brandon Thomas c305315610 Merge PR #2719 2026-10-30 15:42:10 -04:00
Brandon Thomas e4d8aae522 Merge PR #2870 2026-10-30 15:42:10 -04:00
Brandon Thomas 92256075f2 Merge PR #2722 2026-10-30 15:42:10 -04:00
Brandon Thomas 9e2d5ef17f Merge PR #2718 2026-10-30 15:42:10 -04:00
Brandon Thomas 559a0369c3 Merge PR #2713 2026-10-30 15:42:10 -04:00
Aimar Mendez 1d38a25c8d macOS menu test: use the whole native menu, so clippy passes on a Mac
e6df6f42 removed the #[allow(unused_imports, dead_code)] on the
native_menu module that tests/mac_menu_appkit.rs builds in. That test
only compiles on macOS, so `cargo xtask ci` has failed at clippy on
every Mac since, while the Linux gate stayed green:

- the test called only `install`, leaving `poll`, `sync_windows`,
  `select_theme`, `WINDOW_ITEM` and most `NativeMenu` fields unused;
- the harness = false build has cfg(test) but drops #[test] functions,
  so the unit tests' `use super::*` was an unused import.

The test now also polls the menu with a one-workbook session and checks
that the Window menu lists that workbook (#716), which uses the rest of
the module. The unit tests name `super::` items instead of a glob import.

Verified on macOS 27 (arm64): `cargo xtask ci` passes all 10 steps (with
CRAFT_FONTS_DIR at the gate's craft-fonts commit), and the new check
fails when the poll is removed (Window menu's last item "" vs "Book1").
2026-10-30 18:19:50 +00:00
MarekSpodBiedry 6f067cce8a Refusal to shift cells off the worksheet names GridCraft, not Excel (#2941) 2026-10-30 14:59:45 +01:00
Dylan M. Taylor 68a4b1a1c3 UPD.1: honor app pretend versions in update checks (#2934) 2026-10-30 02:29:28 -04:00
Dylan M. Taylor f6bcd77b01 Update prompt shows the app icon and accent color instead of a letter (#2928)
UPDATE_APP (native builds; craft-update is a native-only dependency) carries our 128 px icon and accent (#1f9d55, its colour in ArtCraft Launcher). The updater, the self-update
helper and craft-update's startup prompt all get them through crate::UPDATE_APP; the
prompt draws the icon, the accent and "by ArtCraft" (craft-update's default developer).

craft-update is pinned to storytold/craft-libs main at 1065653e7 (#434).
2026-10-30 01:28:55 -04:00
Dylan M. Taylor 400e5acd6c CI: workflows only call craft-common-actions at @main (#2921)
AGENTS.md (and CLAUDE.md where present) gets a CI workflows section: workflows only call
craft-common-actions at @main, with no other jobs; feature and fix PRs don't touch CI and
link or open a craft-common-actions issue (a PR only for a small fix that is safe for
every app and does not increase CI time); legacy jobs
aren't added to and move only as their own task, staying until a shared workflow runs them.

windows-arm64.yml moves from its SHA pin to @main. The pinned revision is on
craft-common-actions main and the workflow (and the actions it calls) is identical
there, so nothing changes in behaviour.
2026-10-29 22:10:15 -04:00
KameronandClaude Fable 5.1 7d38697f71 Test: Command released in the same frame as the arrow press still jumps
Covers the case the fix exists for: an automation keystroke presses and
releases within one frame, so the frame-wide modifier state is already
empty and only the press event carries Command. With the modifiers read
from `i.modifiers` this assertion fails (B3 instead of B1).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-29 15:52:37 -07:00
Dylan M. Taylor 39a10b1342 packaging: Flatpak finds and prints to CUPS printers (#2913)
* packaging: Flatpak finds and prints to CUPS printers

The Print dialog listed no printers in the Flatpak: the runtime has libcups but not
lpstat/lp, the sandbox couldn't reach the CUPS socket, and driverless network printers
(lpstat -e) need Avahi. Bundle lpstat/lp/lpoptions from CUPS 2.4.20 (Apache-2.0,
statically linked, licence installed) and grant --socket=cups and
--system-talk-name=org.freedesktop.Avahi in both manifests.

* packaging: Flatpak reads the user's CUPS settings (~/.cups, read-only)

Review (CodeRabbit): without it the sandbox's lp/lpstat ignore the user's default
printer and saved options (lpoptions) and a print server named in their client.conf.

* packaging: Flatpak CUPS tools reach the host socket when client.conf names a custom path

Review: Flatpak mounts the host's CUPS socket at /var/run/cups/cups.sock whatever
path ~/.cups/client.conf or CUPS_SERVER names, so lp/lpstat/lpoptions now run through
a wrapper that points a socket path missing in the sandbox there. Server names
(remote servers) and sockets that exist are left alone. Tested in the sandbox:
CUPS_SERVER=/run/custom/cups.sock lists the host's printers through the wrapper and
none without it; a host name is passed through unchanged.

* packaging: redirect a custom CUPS socket only when it is the one Flatpak mounted

Review (CodeRabbit): Flatpak picks the socket it mounts at /var/run/cups/cups.sock
with stricter rules than CUPS (flatpak-run-cups.c: a socket CUPS_SERVER, else the
first line spelled exactly "ServerName /path" in ~/.cups/client.conf, else the
default), while CUPS reads the keyword in any case and takes the last ServerName. The
wrapper now redirects only when CUPS' socket is the one Flatpak chose, so a job can't
reach a different server; otherwise CUPS fails on the missing path.

* packaging: never redirect a CUPS_SERVER the sandbox was given

Review (CodeRabbit): Flatpak picks the socket it mounts from the host's CUPS_SERVER,
so one set by flatpak run --env or an override may not be what it mounted. The
wrapper now leaves any CUPS_SERVER alone and redirects only a client.conf socket that
matches Flatpak's choice; otherwise CUPS fails on the missing path instead of
reaching a different server.

* packaging: point CUPS at the mounted socket only when it is the one Flatpak chose, else fail

Review (CodeRabbit): with a custom socket listed first and the default last in
~/.cups/client.conf, Flatpak mounts the custom one at /var/run/cups/cups.sock while
CUPS picks the last line, finds that path, and talks to the custom server. The
wrapper now compares CUPS' socket with Flatpak's choice even when the path exists:
equal, CUPS is pointed at the mount; different, the tool fails with an error instead
of reaching another server.
2026-10-29 18:48:40 -04:00
KameronandClaude Fable 5.1 57e40d2d14 Formula pointing: Ctrl+arrow and Ctrl+Shift+arrow jump to the edge of the data
While a formula reference is being pointed from the keyboard, Ctrl+arrow
(⌘arrow on a Mac) moved the pointed cell by one, and Ctrl+Shift+arrow
extended the pointed range by one cell, so `=COUNTIF(`, a click on the top
of a column and ⌘⇧↓ pointed at two cells instead of the column of data.
Both now go to the edge of the data region, as the same keys do on the
selection outside an edit: `point_move` takes `jump` and uses the engine's
`selection::jump`, which Ctrl+arrow on the grid already uses, so the two
agree (last filled cell of a run, else the next filled cell, else the sheet
edge). A pointed range that reaches the sheet edge is written as whole
columns or rows (`A:B`), as Excel writes it.

The editor used to read Shift from `i.modifiers`, the modifier state when
the frame's input was collected; an arrow pressed and released within one
frame (a control client's `ui.key`, an automation tool's keystroke) had
already lost it. It now reads the modifiers carried by the arrow's own key
event, as the grid's keyboard handler does.

Test: ctrl_arrows_in_point_mode_jump_to_the_edge_of_the_data (down to the
last filled cell, right across the data, down to the sheet edge, Ctrl+Up
without Shift to the next filled cell, then a plain arrow).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-29 15:11:55 -07:00
skylar taylor-barrick 6ba1e19857 Merge pull request #1422 from switch87/fix/1419-clear-skips-filtered-rows
Clear: Delete and the Clear commands skip the rows a filter hides (#1419)
2026-10-29 14:18:46 -06:00
fream 9c6de8abde fix(ui): keep chart source outlines below objects (#2861) 2026-10-29 16:49:42 +01:00
Roman Lisunov 0688e83752 Merge upstream main and retain numpad insertion documentation 2026-10-29 14:45:20 +02:00
NetterSilverandClaude Haiku 5.5 b004335538 Keep Fill Series value equal to stop despite float drift
Found while testing crates/engine/src/fill.rs:595-599

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 12:33:30 +00:00
fream 45cc92ae3c fix(ui): prefill existing hyperlinks in link editor (#2876) 2026-10-29 11:49:33 +01:00
truongitandClaude Opus 5.5 94433a3c4f Document the Agile decryption steps
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 16:51:55 +07:00
Dylan M. Taylor 2ff751791b Merge pull request #478 from prxnvt/pr/macros-ui
Macro interface: Automate › Macros, Macros and Record Macro dialogs, step editor with a debugger
2026-10-29 05:10:50 -04:00
truongitandClaude Opus 5.5 9e0f14cab9 Open Excel's password-protected workbooks again: accept a hash-size integrity key (#2712)
Since #2425 checks Agile encryption's dataIntegrity, every workbook Excel
encrypts fails with "the decrypted data failed its integrity check". The
reader keyed the HMAC with the decrypted encryptedHmacKey cut to the salt
size (16 bytes), as [MS-OFFCRYPTO] 2.3.4.14 describes it, but Excel's key
is as long as the hash: 64 bytes for SHA-512. The check now accepts a key
of either length, so spec-literal writers still open and a modified
package still fails.

The test encryptor writes the integrity key as Excel does, so every
encrypted fixture now has Excel's layout; a new test covers both key
lengths, a key of neither length, and tampering with each.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:42:37 +07:00
Dylan M. Taylor 355840a2a2 Merge branch 'prxnvt/pr/macros-ui' into stack/record
Brings in the macro interface: the Macros dialog and editor, the
Automate ribbon section, drag-and-drop of macro steps, and the
macro overlay on the grid. Ported into the split module layout on main.

Clippy gate: moved wildcard imports to explicit lists, named the
test edit tables, boxed the overlay layer tuple, switched the editor
to craft_ui::ComboBox per clippy.toml, and simplified an is_some_and
in the LP solver.
2026-10-28 23:07:55 -04:00
AnhNgoo bc1e19f1f6 docs(grid): document cell painting and clipping behavior 2026-10-29 10:02:14 +07:00
AnhNgoo d1f063636a test(grid): preserve clipping for fully fitting wrapped text 2026-10-29 09:51:22 +07:00
AnhNgoo 6af06e9d30 fix(grid): preserve full-cell clipping for fitting wrapped text 2026-10-29 09:38:42 +07:00
AnhNgoo f6b900dd5a Merge upstream main and preserve wrapped text clipping fix 2026-10-29 09:18:43 +07:00
Dylan M. Taylor 3fc35e5d3b Merge branch 'stack/debug' into stack/record
# Conflicts:
#	crates/engine/src/cmd/extra.rs
2026-10-28 22:15:51 -04:00
Dylan M. Taylor 3923ab3dca Merge branch 'stack/check' into stack/debug
# Conflicts:
#	crates/engine/src/cmd/extra.rs
2026-10-28 22:08:49 -04:00
Dylan M. Taylor 70bcab56e2 Merge branch 'stack/run' into stack/check 2026-10-28 22:04:06 -04:00
Dylan M. Taylor 708481b615 Merge branch 'stack/tokens' into stack/run
# Conflicts:
#	crates/engine/src/cmd/edit.rs
#	crates/engine/src/cmd/insert.rs
2026-10-28 22:03:59 -04:00
Dylan M. Taylor a1a491b10c Merge branch 'stack/engine' into stack/tokens 2026-10-28 21:59:06 -04:00
Dylan M. Taylor 7bf8ed7495 Merge remote-tracking branch 'origin/main' into stack/engine
# Conflicts:
#	crates/engine/src/cmd/extra.rs
2026-10-28 21:59:05 -04:00
AnhNgoo 9e23c98f3a test(grid): address CodeRabbit feedback for wrapped text clipping 2026-10-29 07:53:37 +07:00
Harrison Booth f01c02cc41 fix: place AutoSum caret inside empty formula 2026-10-28 15:16:55 -05:00
Dylan M. Taylor 146161b4b4 Honor CARGO_BUILD_TARGET in FreeBSD packaging for aarch64 release builds (#2847)
* Honor CARGO_BUILD_TARGET in FreeBSD packaging for aarch64 release builds

Cross-built FreeBSD aarch64 archives were not possible: package.sh took the
arch from uname -m (the x86_64 VM host), built for the host, read binaries
from target/release, and ran the aarch64 gridcraft-cli to print its version.

When CARGO_BUILD_TARGET is set, build with --target, read from
target/<triple>/release, name the archive by the target's arch (aarch64 for
aarch64-unknown-freebsd, x86_64 otherwise), and skip running the binary.
Unset behavior is unchanged.

* Reject unsupported CARGO_BUILD_TARGET in FreeBSD packaging

Only x86_64-unknown-freebsd and aarch64-unknown-freebsd are valid; any other
value now fails before the build or packaging.
2026-10-28 15:35:58 -04:00
AnhNgoo f9b869b3a4 fix(grid): prevent partial wrapped text lines in fixed-height rows 2026-10-28 17:03:09 +07:00
AnhNgoo c22f46628c fix(desktop): load preferences before creating startup workbook 2026-10-28 10:47:42 +07:00
AnhNgoo a81cb0f21e test(engine): cover default workbook author and preservation 2026-10-28 10:22:31 +07:00
Harrison Booth ff7ed4b57c Move the ECMA.CEILING revision entry into the revision history table 2026-10-27 21:28:25 -05:00
Harrison Booth 3b73ac9b91 Document ECMA.CEILING as not on Microsoft's list 2026-10-27 21:21:14 -05:00
Harrison Booth 87342cb308 HYPERLINK tests: use the Arg helpers 2026-10-27 21:08:35 -05:00
Harrison Booth 0f78a6b126 HYPERLINK without a friendly name returns the link as text
Excel: HYPERLINK(1) is the text "1" (ISTEXT is TRUE); with a friendly
name the result keeps that value's type.
2026-10-27 21:08:23 -05:00
Harrison Booth 28a8e67e7a functions: add ECMA.CEILING (CEILING under a second name) 2026-10-27 20:57:33 -05:00
Dmussi 8b35f2ac12 Format status bar label and translate its tooltip 2026-10-27 19:20:18 -04:00
RomanLis91andClaude Opus 5.5 a8e03ef40f fix(keyboard): Ctrl with the numpad's + inserts rows, columns or cells, as in Excel
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJaF5jevKidVoJcnTyBFub
2026-10-27 23:40:35 +02:00
AnhNgoo fa6ccc6ae5 fix(engine): set default workbook author (#2448) 2026-10-27 16:10:46 +07:00
Higgs32584andClaude Opus 5.5 a38aed68cb Security: control channel enforces its 10 s token deadline and caps waiting connections (#2717)
authenticate() set the read timeout on the writing half of the socket while it read the
token through a try_clone; on Windows the clone has its own timeout, so a peer that never
sent a line held its thread forever (and a per-read timeout let a byte-at-a-time peer
stretch it elsewhere). The first line now has one total deadline, set on the handle being
read and shortened before each read. At most 16 connections may wait to authenticate (more
are closed on accept, as #2174 suggested), and accept errors back off instead of spinning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 00:55:44 -04:00
Higgs32584andClaude Opus 5.5 7d828c01f7 Security: cap the work of lifted lookups, needles x lookup vector, with #NUM! (#2716)
A lookup lifted over an array of needles searched its whole lookup vector once per needle
with no total cap, so =ROWS(XMATCH(SEQUENCE(1048576),-SEQUENCE(1048576))) in a 1.6 KB file
ran for hours and hung file.open. VLOOKUP, HLOOKUP, LOOKUP, MATCH, XLOOKUP and XMATCH now
return #NUM! past 5e8 needle x entry steps, the pattern MMULT uses. Other lifted functions
are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 00:40:18 -04:00
Higgs32584andClaude Opus 5.5 4cc2c44678 Security: web links open with noopener as a window feature, not in the window name (#2715)
window.open(url, "_blank,noopener") named the new window "_blank,noopener" and passed no
features, so a page opened from a workbook link kept window.opener and could navigate the
editor tab. Pass "noopener,noreferrer" as the features argument instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 00:39:40 -04:00
Higgs32584andClaude Opus 5.5 ea5292c94a fix(engine): gate the temp-name symlink test to unix
`save_fails_upfront_when_the_temp_name_is_taken` calls
`std::os::unix::fs::symlink` but was only gated on non-wasm, so the
engine crate's lib tests did not compile on Windows. Gate it `cfg(unix)`
like the neighbouring permissions test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 23:09:30 -04:00
Harrison Booth 0edf2b850a Fix the spill test: clone the workbook out of its Arc (#2053) 2026-10-26 17:34:04 -05:00
Harrison Booth 2b6e088a6d Engine test: copying a sheet that holds a spill matches a full recalc (#2053) 2026-10-26 17:29:12 -05:00
Harrison Booth acc32227cb IMSQRT: match the zero real part by pattern (clippy redundant_guards) 2026-10-26 13:03:51 -05:00
Harrison Booth 1fadf98a10 IMSQRT: reproduce Excel's stray real part for negative reals
Excel computes IMSQRT through the polar form, so IMSQRT("-4") is
1.22514845490862E-16+2i rather than 2i. Tests assert strings read back
from Excel; the function-parity doc lists the remaining IM* differences.
2026-10-26 12:58:54 -05:00
Harrison Booth f4f48f3b23 CSV export values: choose the date or time shape from the format section that formats the value 2026-10-26 12:51:42 -05:00
Harrison Booth 9595fc8ee7 CSV export values: pick the ISO shape from the format's date and time parts; guard the file test on wasm32 2026-10-26 12:42:23 -05:00
Harrison Booth 9515c29969 CSV export: file.exportCsv values:true writes raw values for automation (#2648)
The default stays Excel's displayed text, byte for byte.
2026-10-26 12:34:18 -05:00
Florent Siegenthaler 8cfe970ff8 Declare the UI languages to AppKit so the open and save panels are translated 2026-10-26 09:24:52 +01:00
prxnvt e40291fc3d Macro interface: follow per-window dialogs and egui 0.37 accessibility
Main now keeps a dialog with its own workbook window, so the step editor is
parked with its workbook rather than drawn over another one, and closing that
workbook drops the editor without drawing it again: forget_closed_editor
clears it each frame. The Macros, Record Macro and editor text boxes are
labelled by their captions, and the tests pick the question's Cancel and the
chart menu's Delete by role, as the formula bar and ribbon share those names.
2026-10-26 00:38:32 -04:00
prxnvt c801e50e40 Merge #2152 (f8e1f8df, with main at f6b0cec6) into #478
# Conflicts:
#	ROADMAP.md
#	crates/engine/src/io.rs
#	crates/ui-egui/src/dialogs.rs
#	crates/ui-egui/src/lib.rs
#	docs/target-app-parity.md
#	docs/ui-parity.md
2026-10-26 00:32:55 -04:00
prxnvt f8e1f8dfc0 Merge #2145 (4d26e395, with main at f6b0cec6) into #2152
# Conflicts:
#	crates/engine/src/io.rs
#	crates/engine/src/lib.rs
#	docs/target-app-parity.md
2026-10-26 00:29:13 -04:00
prxnvt 4d26e395fc Merge #2550 (d4096eee, with main at f6b0cec6) into #2145
# Conflicts:
#	crates/engine/src/lib.rs
2026-10-26 00:26:35 -04:00
prxnvt d4096eee5a Merge #2144 (34eee860, with main at f6b0cec6) into #2550 2026-10-26 00:24:57 -04:00
prxnvt 34eee86053 Merge #2151 (6781aa49, with main at f6b0cec6) into #2144
# Conflicts:
#	crates/engine/src/io.rs
#	crates/engine/src/lib.rs
2026-10-26 00:24:11 -04:00
prxnvt 6781aa4927 Merge main (f6b0cec6) into #2151 2026-10-26 00:21:54 -04:00
prxnvt 483276f719 Merge #465 (3772f2c6, with main at 42bbf6ba) into #2151 2026-10-26 00:21:42 -04:00
prxnvt 3772f2c61a Macros: write the macro names into xlsx again after the main merge
The merge with main kept main's xlsx line in save_bytes_as, which dropped
macros::with_names, so an xlsx save carried no macros.
2026-10-26 00:21:00 -04:00
Pranav 1cd45cc909 Merge branch 'main' into pr/macros-ui 2026-10-25 23:54:09 -04:00
Pranav e85297e26c Merge branch 'main' into pr/macros-engine 2026-10-25 23:49:20 -04:00
Pranav b552b7a929 Merge branch 'main' into pr/macros-record 2026-10-25 23:49:08 -04:00
Pranav 910b50c1dd Merge branch 'main' into pr/macros-run 2026-10-25 23:48:13 -04:00
Dmussi 848b0e658a Click a status bar statistic to copy its value 2026-10-25 23:24:26 -04:00
prxnvt 8fd6ec124b Engine: a macro shortcut is one letter, alone or after Ctrl+Shift
`parse_shortcut` took the last letter of any spelling, so `"Ctrl+Alt+M"` was saved as
Ctrl+Shift+M. It now takes a bare letter or one after Ctrl+Shift, Cmd+Shift or ⌘⇧, and refuses
other modifiers.

- Test: `shortcuts_parse_to_one_letter_and_avoid_reserved_and_taken_ones`.
2026-10-25 06:48:57 -04:00
prxnvt 299beb5085 Merge #2145 (bf6a8520) into #2152
# Conflicts:
#	crates/engine/src/record.rs
2026-10-25 06:48:57 -04:00
prxnvt ffacf7405f Docs: Record Macro's shortcut example uses a letter a macro may take
The example was K, which GridCraft uses (Group), so a caller who copied it was refused; it is M
now, in the command's description and on the macro page.
2026-10-25 06:48:57 -04:00
prxnvt 59d85ca138 UI: the macro editor refuses a taken name in the person's words
Saving over a macro now needs `replace: true`, and the engine's refusal says so in terms for
programs. A new draft whose name another macro has (or a kept one from a newer version) is
refused by the editor first, with "A macro called “three” already exists. Choose another name."

- Test: `a_new_macro_never_replaces_one_with_its_name`.
2026-10-25 06:48:57 -04:00
prxnvt ea5eb2ae3c UI: Macro Options saves over the macro it changes
Saving a macro over one with the same name now needs `replace: true`, so Macro Options, which
changes the shortcut and description of a macro that exists, failed to save. It passes
`replace: true`; `options_set_a_shortcut_that_runs_the_macro` covers it.
2026-10-25 06:48:57 -04:00
prxnvt c442da9221 Engine: stopping a recording says when it left the chosen shortcut off
Record Macro checks the Ctrl+Shift letter when recording starts, but
another macro can take it before recording stops; the save then leaves
the shortcut off, and nothing said so. The result now names it in
shortcutDropped, the toast after Stop says the macro was saved without
it, and the macro page describes it.
2026-10-25 06:48:57 -04:00
prxnvt c142c70dbe UI: the macro editor keeps an input to a shape a macro may declare
The format now refuses a single-cell input with a header, several column types or more than one
row or column, a least above its most, and more column types than columns; the editor could make
each of these, and the save then failed.

- Switching an input to "single cell" sets one row and one column, takes the header off and keeps
  at most one column type; "Use current selection as example" does the same for one cell.
- Setting "at least" above "at most" moves "at most" up (and the other way round), and lowering
  the columns' "at most" drops the column types past it.
- Test: `an_edited_input_keeps_a_shape_a_macro_may_declare` (each result passes `check_limits`).
2026-10-25 06:48:57 -04:00
prxnvt 1e2492b4d6 Tests: the large-macro editor test reads the UI clock
Clippy refuses std::time::Instant::now, which traps on wasm; the test
that bounds the step list's frame time reads gridcraft_ui_egui::now_ms
instead.
2026-10-25 06:48:57 -04:00
prxnvt 60c20095e6 UI: the Macros dialog closes when another workbook becomes active
The dialog's rows and its Options pane hold the macros of the workbook
it opened on, but Run, Delete and Options act on the active workbook.
When an agent made another workbook active, the dialog stayed open over
it, and OK in Options would write the first macro's shortcut and
description onto the other workbook's macro of the same name. The
dialog now keeps its workbook and closes when another one becomes
active.
2026-10-25 06:48:57 -04:00
prxnvt a414b608d7 Docs: the macro page says where a question's answer runs, and its long lines wrap
The Questions entry says that an answer runs in the workbook that asked
and does nothing while another one is active. The lines the restack
joined in the recording paragraph and the Macros dialog entry are
wrapped again.
2026-10-25 06:48:57 -04:00
prxnvt a0fd599fe6 UI: the macro step editor reads each step in place
step_at copied the whole step list to look up one step, and the step
list calls it for every row on every frame, so a macro at the 10,000
step limit (which a recording can reach, or a file can carry) took about
ten seconds a frame. The lookup now walks the list without copying it:
five frames of that macro take well under a second. A test opens a
macro at the limit with a generous time bound.
2026-10-25 06:48:57 -04:00
prxnvt 0fdb4d03e4 UI: a macro question is answered only in the workbook that asked it
The Macros dialog's Delete question and the questions a macro run asks
(approve it, overwrite or move the data in the way) keep the command
and run it again with the answer. When another workbook became active
before the answer (an agent can switch workbooks under the prompt),
the answer ran there: Delete removed the other workbook's macro of the
same name. A question now keeps its workbook, and an answer given while
another one is active does nothing and says why.
2026-10-25 06:48:57 -04:00
prxnvt baf01c4211 UI: the macro step editor fits a narrow window
The editor's window had a minimum size of 660 by 380 and its toolbar
was one row of about 680 pixels, so in a window 600 pixels wide Run to
Here and Stop were off screen, and at 480 the inspector's tabs were cut
off. The window is now never larger than the app's window, the name row,
the toolbar and the inspector's tabs wrap (the toolbar's buttons are
disabled in place rather than in a scope of their own, which kept them
from wrapping), the inspector keeps room for its tabs, and its content
scrolls sideways instead of widening the window. Tests check the editor
at 600 and 480 pixels, and the Macros and Record Macro dialogs, which
already fit.
2026-10-25 06:48:57 -04:00
prxnvt f73e7b7e19 UI: closing the window asks about the macro step editor first
A window close (the close button, Alt+F4, quitting) let the app go when
every workbook was saved, losing the editor's unsaved steps, and with an
unsaved workbook it made that workbook active under the editor and asked
for a Save Changes question the editor's window would not let open, so
nothing happened. The close now waits for the editor: it asks about its
debug run and unsaved steps as its own close button does, Cancel keeps
the app open, and once the editor has closed the window close goes on
and asks about unsaved workbooks as before.
2026-10-25 06:48:57 -04:00
prxnvt 12072c5529 UI: a dialog asked for over the macro step editor says why it did not open
The step editor keeps its window until it closes itself, so a dialog
asked for while it is open is not shown. That was silent: File > Close
on a changed workbook asked for its Save Changes question and nothing
happened. The request now shows a message that says to close the
editor first.
2026-10-25 06:48:57 -04:00
prxnvt a18ecc81aa UI: the control channel's dialog cancel closes the macro step editor as its own close does
ui.dialog.cancel emptied the dialog slot, so with the step editor open
it went without its cleanup: the draft stayed behind, the Check overlays
stayed on the grid, the selection the reference boxes moved was not put
back and a debug run went on with no controls. The cancel now closes
the editor the way its close button does, and is refused, with the
reason, while the editor holds unsaved steps or a debug run, which only
its window asks about.
2026-10-25 06:48:57 -04:00
prxnvt a206178209 UI: the macro step editor works only on the workbook it opened in
The macro.* commands act on the active workbook, and the editor stays
open while another one becomes active (File > New, Switch Window, an
agent). A new draft saved then went into that other workbook, and Check,
Run and Debug ran there. The editor now keeps the workbook it opened in:
while another one is active it says so, its controls are disabled,
Save, Check, Run and starting Debug refuse with a message that names
the workbook, the grid overlays are hidden and closing does not put the
selection back on the wrong sheet. Closing the workbook closes the
editor.
2026-10-25 06:48:57 -04:00
prxnvt 73b0edd085 UI: the Macros dialog offers Trust Scripts for scripts from a file
The Macros dialog that replaces the scripts-only list keeps its Trust
Scripts row (#2178): scripts that came with an opened file are listed as
disabled until the row's button trusts them for the session, and Run
works after it. A test covers the row, the trust and the run, and the
macro page describes it.
2026-10-25 06:48:57 -04:00
prxnvt aa86b0c327 UI: merge fallout from the UI kit and the recorder heads
Icon buttons no longer carry an accessible name: the tests that came
with main since the branch was written (menu hover, popup dismissal, the
File menu, More Colors) find Copy, Save and Cancel by label, and the
Quick Access Toolbar and formula bar icons gave those labels twice. The
macro tests need the names of large, small and toggle ribbon buttons
only. The chart menu test takes the menu's Delete, which is drawn after
the ribbon's Home > Cells > Delete. The Trust Scripts row ported into
the Macros dialog is formatted.
2026-10-25 06:48:57 -04:00
prxnvt cec19d6af6 UI: a reference box's pick ends when the box leaves the screen
Choosing another step or inspector tab hid the picking box but left the pick in place, so a grid
selection made later for another reason landed in the box when its step was shown again, and the
draft was dirtied with a reference nobody chose. A pick now ends in any frame that does not draw
its box.
2026-10-25 06:48:57 -04:00
prxnvt 3d6a09ec75 UI: a reference box stops picking when a macro action starts, and debugging never fills one
A reference box that had focus kept picking from the grid while Check, Run or a debugger action
ran. The selections those make landed in the box and dirtied the draft, and a later Check could
save that draft. The pick now ends when an action starts, and while a debug run is active a
selection on the grid is never taken as a pick.

Also: the parity assessment said an .xlsm's VBA project is dropped on open; it is kept unchanged
on save and never run.
2026-10-25 06:48:57 -04:00
prxnvt b9a8fa304c UI: Change in the macro step editor picks only the commands its list shows
In Change mode the picker hid the Repeat, If and Stop rows but still indexed the full
list, so Enter with an empty search (index 0, nothing highlighted) picked Repeat and
turned the command step into a Repeat step; a search for "if" or "stop" did the same.
The control rows are now dropped from the list before it is indexed, drawn or chosen.
2026-10-25 06:48:57 -04:00
prxnvt c523a4c48a UI: another dialog never replaces the open macro step editor
The step editor lives in the app's one dialog slot, so Format Cells, the Macros
dialog or New Macro opened while it was up took its place. The draft was lost
(New Macro started a fresh one), and a closed-over editor left its grid overlays and
its debug run behind. While the editor is open, requests for other dialogs are now
ignored; the editor closes itself as before.
2026-10-25 06:48:57 -04:00
prxnvt eb7b2b93f4 UI: the macro editor remembers a rename even when the save after it fails
Save renames the stored macro first and then saves the draft. When the save failed
(a draft too large to keep in the workbook, say), saved_as still held the old name,
so every later Save, Check, Run and debug start tried to rename a macro that no
longer existed. saved_as now takes the new name as soon as the rename succeeds.
2026-10-25 06:48:57 -04:00
prxnvt bca9a10f8a UI: saving a new macro never replaces one with the same name
A new draft's name can be edited to that of a macro already in the workbook. Save, and Check and
Run (which save first), then sent macro.save with its default replace: true and the draft took
the other macro's place. A new draft now saves with replace: false, so the clash is reported and
the existing macro stays as it was.
2026-10-25 06:48:57 -04:00
prxnvt 1443ec417c UI: macro step editor with a debug bar and grid overlays
New Macro (Automate › Macros) and Step Into, Edit and Create in the
Macros dialog open a resizable step editor. It works on a draft of the
macro and saves it with macro.save.

The step list shows each step's number, a breakpoint dot in the gutter
(click to set or clear), a category icon, a summary, an error marker
and an enabled checkbox; Repeat bodies and If branches are indented
under their step, and rows drag to reorder within their list. Add Step
opens a command picker (control steps first; commands a macro may
never run are left out) and fills in the command's required
parameters; Duplicate and Delete act on the selected step.

The inspector's Details tab edits the selected step. Reference
parameters are reference boxes that take a typed reference or a
selection on the grid, with a Rel/Abs switch between a token relative
to the example input and an absolute reference, and token suggestions.
The Inputs tab sets each input's shape, header and column types (Use
current selection as example infers them); the Output tab sets the
anchor and the conflict rule.

The debug bar has Check (a dry run that colours the grid: inputs blue,
writes green, writes over data red), Run, Step, Continue, Run to Here
and Stop, which asks whether to keep the changes or undo them. The
debugger receives the editor's breakpoints with breakpointsOnly, so a
dot cleared while paused takes effect at once; the current step is
selected, its parameters are shown as they will run, and its target is
outlined on the grid. While a macro is debugged, the status bar says so
and Show reopens the editor.

List drag and drop lives in a small dnd module. The debugger icons are
drawn in code, and the breakpoint and sheet overlay colours are theme
tokens covered by the contrast test.
2026-10-25 06:48:57 -04:00
prxnvt 5512c1a269 UI: Automate › Macros group, Record Macro and Macros dialogs, recording status, macro shortcuts
The Automate tab gains a Macros group: Macros opens the Macros dialog,
Record Macro opens the Record Macro dialog (name, Ctrl+Shift shortcut
letter, description, Use Relative References) and turns into Stop
Recording while recording, Use Relative References sets how the next
recording starts, and Run Macro runs the macro run last; its menu lists
the recent macros, then every macro. While recording, the status bar
shows a red dot, "Recording · N steps" and Stop.

The Macros dialog (Automate › Macros, View › Macros, Alt+F8) lists the
workbook's macros with their shortcuts, then its Automate scripts, and
says when the file carries a VBA project, which is kept and not run.
Run, Delete (which asks first) and Options (shortcut letter and
description) act on the selected row. The dialog keeps the result of
view.macros, so the scripts and the VBA entry are listed as before.

A macro that needs approval asks with Run, and one whose output would
land on data asks with Overwrite, Shift Down and Shift Right; each
button runs the command again with its answer. Ctrl+Shift+letter
(Cmd+Shift on a Mac) runs the macro with that shortcut; the letters
GridCraft uses keep their meaning. The View › Macros keytips (WMR, WMV)
now open the Record Macro and Macros dialogs.

Icons for macros, recording and relative references are drawn in code,
the recording dot is a theme token covered by the contrast test, and
large and small ribbon buttons and icon buttons carry accessible names.
2026-10-25 06:48:57 -04:00
prxnvt 7c66aaa2d3 Engine: Record Macro takes a name, shortcut and description
macro.record checks name, shortcut and description when recording
starts and keeps them for the save, so a name that is taken, too long
or holds line breaks, or a Ctrl+Shift letter that GridCraft or another
macro uses, is refused before anything is recorded. With empty
parameters it records under the next MacroN, as before. A name or
description given when stopping still wins, and a shortcut that another
macro took while recording is dropped.

The shortcut rules (macros::parse_shortcut, shortcut_text,
shortcut_conflict, RESERVED_SHORTCUTS and check_new_name) live in the
engine, so the Record Macro dialog, Macro Options and agents agree.
2026-10-25 06:48:57 -04:00
prxnvt bf6a8520b8 Merge the footprint pull request (65634c2b, with #2144 and #2151) into #2145 2026-10-25 06:48:03 -04:00
prxnvt 65634c2bb7 Merge #2144 (b9aa4a6a) into the footprint pull request 2026-10-25 06:48:03 -04:00
prxnvt 0533480c97 Engine: recorded steps carry a number, so a group's rollback finds its own after others
A group remembered the recording's length when it began, which another workbook's rollback (or a
recording started again inside the group) made point at the wrong steps. Each kept step now has
a number, and each recording one of its own; a group remembers both, and its rollback takes back
the steps of its workbook numbered from its mark, or all of them when the recording started
inside the group.

- Test: `rollbacks_take_back_their_own_recorded_steps_in_any_order` (groups on two workbooks
  rolled back in turn, and a recording started again inside a group).
2026-10-25 06:48:03 -04:00
prxnvt b9aa4a6a9c Merge #2151 (0533480c) into #2144 2026-10-25 06:48:03 -04:00
prxnvt ca2be34e8a Engine: a rolled-back group takes what its script runs ran out of the recording too
Taking back the steps a rolled-back group recorded left what a taken-back script or macro run
ran (`Recorder::ran`) under its old index, where a later run could pick it up, and left it out of
the size count. `cut_back` now drops it with its step, moves a kept run's entry to the step's new
index and counts both again.

- Test: `a_rolled_back_script_run_takes_what_it_ran_out_of_the_recording`.
2026-10-25 06:29:23 -04:00
prxnvt 9a22c4b2f2 Merge the footprint pull request (24f0c3df, with #2144 and #2151) into #2145 2026-10-25 06:28:06 -04:00
prxnvt 24f0c3df92 Merge #2144 (1a00ce19) into the footprint pull request 2026-10-25 06:28:06 -04:00
prxnvt 1a00ce19fb Merge #2151 (0a929eaf) into #2144 2026-10-25 06:28:06 -04:00
prxnvt 0a9305f784 Merge #2145 (9a22c4b2) into #2152 2026-10-25 06:28:06 -04:00
prxnvt 0a929eaf68 Engine: a rolled-back group takes back only the steps recorded in its workbook
A rollback took back every step recorded since its group began, so an edit recorded in another
workbook while the group was open was lost from the recording although the edit stayed. It now
takes back only the steps recorded in the group's own workbook.

- Test: the other workbook's edit in `stop_action_runs_only_on_its_own_and_is_not_recorded`.
2026-10-25 06:27:53 -04:00
prxnvt 2e8284c191 Engine: a panic during a dry run puts the journal pause and the touch log back at any depth
The pause of a dry run was cleared only by a panic at the top level. A command that started a
dry run, nested in another, and panicked left journaling paused for the rest of the session (and
the touch log open) once the outer command finished. Each command now keeps the pause count it
started with, and its panic guard puts it back, closing the touch logs, whatever its depth.

- Test: `a_dry_run_cut_short_by_a_panic_leaves_journaling_on`.
2026-10-25 06:26:51 -04:00
prxnvt f0b667aa02 Merge the footprint pull request (361fff98, with #2144 and #2151) into #2145 2026-10-25 06:02:18 -04:00
prxnvt ec58ec5f4b Merge #2145 (f0b667aa) into #2152 2026-10-25 06:02:18 -04:00
prxnvt 361fff982a Engine: a dry run journals nothing, so the journal and Record Actions' start stay as they were
Every nested command a dry run executed was journaled. Past the journal's limits that evicted
the person's own oldest entries, and the restore could only cut back what was left, so one
`macro.check` of a long macro could empty the journal and move a Record Actions recording's
start. While `Saved` holds the session's state, commands are not journaled
(`Session::journal_paused`), and the restore puts the journal and the recording start back
exactly; the count of dropped entries it relied on is gone. The fallible calls of a dry run come
before the pause, and a panic at the top level turns journaling back on.

- Test: `a_dry_run_leaves_the_journal_and_the_recording_start_as_they_were` (a full journal, a
  Record Actions start, and a check of 12,000 nested steps; replaces the test that accepted the
  loss).
2026-10-25 06:02:18 -04:00
prxnvt fe14c0d86e Merge #2151 (0aae1ffe) into #2144 2026-10-25 06:00:19 -04:00
prxnvt 891986bf66 Merge #2144 (fe14c0d8) into the footprint pull request 2026-10-25 06:00:19 -04:00
prxnvt 0aae1ffeea Engine: an xlsx save refuses a macro that skipped the check, as a JSON save does
`with_names` checked only the size of each item, so a macro built in code past the model's
limits (a name with a line break) was written to the file and dropped the next time it opened.
The xlsx save now runs `check` on every macro first.

- Test: the macro past the limits in `a_macro_built_in_code_goes_through_the_size_check_too`.
2026-10-25 06:00:13 -04:00
prxnvt bfcf50aad3 Engine: rolling back an undo group takes back the steps it recorded
Stop Action (or any rollback) put a group's edits back but left the steps recorded while the
group was open in the recording, so playing the recording back made edits the workbook no longer
had. A group remembers where the recording was when it began, and its rollback cuts the
recording back to there.

- Test: `stop_action_runs_only_on_its_own_and_is_not_recorded`, which kept the rolled-back edit
  before.
2026-10-25 06:00:13 -04:00
prxnvt f225cd1026 Engine: Check Macro waits while a macro is being debugged
`macro.check` is a query, so the debugger's lock let it through, but its dry run runs the macro's
steps, which the lock refuses: the check reported the lock as the failure of a step. It now waits
with "Finish or stop the macro first", as other commands that change the workbook do.

- Test: `between_steps_only_looking_around_runs_and_the_run_continues_where_it_was`.
2026-10-25 05:16:37 -04:00
prxnvt 9fe25835d4 Merge #2145 (f225cd10) into #2152 2026-10-25 05:16:37 -04:00
prxnvt f6f4585c93 Engine: a dry run puts the document's revision back
The dry run's rollback counts as a change of the document and moves its revision on, which front
ends key their caches on, so a `macro.check` that changed nothing (and a shift it tried and took
back) looked like a change. `Saved` keeps the revision and puts it back with the rest.

- Test: the revision in `shifting_makes_room_and_is_checked_again`.
2026-10-25 04:14:12 -04:00
prxnvt 91bb5eded8 Merge the footprint pull request (f6f4585c, with #2144) into #2145 2026-10-25 04:14:12 -04:00
prxnvt 5aa10132aa Merge #2145 (91bb5ede, with the footprint pull request and #2144) into #2152 2026-10-25 04:14:12 -04:00
prxnvt 267d5bc503 Merge #2144 (90a46da4) into the footprint pull request
# Conflicts:
#	crates/engine/src/cmd/macros.rs
2026-10-25 04:13:22 -04:00
prxnvt 90a46da496 Engine: macro.run's description lists only what it returns here
`shifted` comes with the footprint in the pull request above; here a shift rule stops the run as
`fail` does, which the description now says.
2026-10-25 04:13:05 -04:00
prxnvt 00b22d5e33 Docs: the macro page keeps what the pull requests below wrote
The merge of #2145 took this branch's macro page whole instead of merging it, which left out the
page's newer text from below: the debugging section as #2145 rewrote it (the lock, Stop Action,
parameters, the time limit for each action), the time limit for each debugger action in the
limits, the check after a shift, and moves that rewrite formula references. The page is now the
merge of both, with this branch's own scope line.
2026-10-25 03:55:52 -04:00
prxnvt fdd26876fb Merge #2145 (227078f5, with the footprint pull request and #2144) into #2152
# Conflicts:
#	docs/macros.md
2026-10-25 03:21:39 -04:00
prxnvt 227078f56a Merge the footprint pull request (dadffdb3, with #2144) into #2145
# Conflicts:
#	docs/macros.md
2026-10-25 03:21:31 -04:00
prxnvt dadffdb33f Engine: macro.check shifts and checks again, as the run does, before it says ok
Under `shiftDown` or `shiftRight` with conflicts, `macro.check` looked only for the refusals made
before a shift, so it said `ok` for a run the second footprint stops (an anchor that moves with
the shifted data). It now shifts the data inside an undo group, takes the footprint again, rolls
both back and puts the session back, and reports what still stands in the way.

- Test: the `Named` case and a shift that makes room in `shifting_makes_room_and_is_checked_again`.
2026-10-25 03:21:15 -04:00
prxnvt e9effe5dbd Merge #2144 (198fe482) into the footprint pull request
# Conflicts:
#	docs/macros.md
2026-10-25 03:20:11 -04:00
prxnvt 198fe482a1 Engine: the run's check of each step finds the inputs on their sheet after a sheet moved
- The check for writes over data compared the inputs' sheet with the sheet's index when the run
  started, while the inputs follow their cells: after a step inserted a sheet in front, a step
  writing into its own input on the moved sheet was stopped as writing over data. It now uses the
  sheet's index now. Test: the input case in
  `inputs_and_the_output_follow_their_cells_when_a_step_moves_them`.
- Docs: the page says `macro.run` checks the macro, its inputs and parameters and the policy
  before it runs, and that a formula whose references a move rewrites counts as changed too.
2026-10-25 03:19:59 -04:00
prxnvt 43134b308e Docs: catalog count 276 / 292 in the parity summary; macro storage names; the macro page's scope
- target-app-parity.md: the summary table and the method say 276 / 292 and 292 unique ids, as
  `cargo xtask parity` measures; Last updated and revision history.
- architecture.md: macros are kept as `_gc_macro_<n>_<k>` (CodeRabbit 4366060044).
- macros.md: the intro says the page covers the engine and its commands, without implying
  later work.
2026-10-25 03:19:35 -04:00
prxnvt 460a72d3bb Engine: a recording keeps what ran and plays back as it was recorded
- A script step that only opened a dialog (a menu-style call that left the workbook unchanged)
  is left out of the script run's steps, by the recorder hook's rule; a script run whose steps
  the recording couldn't keep is left out and listed in `skipped`, not read from the workbook
  as the script stands when recording stops (a_script_step_that_only_opened_a_dialog_is_left_out_of_the_recording).
- A script's toggles keep the state they set (`"on": true`), as a top-level Bold does
  (a_script_run_records_the_state_its_toggles_set).
- A recorded macro.run keeps the steps it ran, as a script run does, so saving or deleting the
  macro before the recording stops changes nothing (a_recorded_macro_run_keeps_the_steps_that_ran).
- The example input's shape and column types are inferred when recording starts, not from the
  data the recording changed (the_recorded_input_is_inferred_as_it_was_when_recording_started).
- Recorded text that playback would read as a token stays text: a reference parameter that reads
  as one becomes {"$text": …}, and `{{` becomes `{{{{`
  (recorded_text_that_reads_as_a_token_plays_back_as_text). The resolver (below #2152) gains
  `{"$text": …}`, text passed as it is (text_marked_as_text_is_never_a_token).
- A recording over the macro size limit is cut at the limit and the steps left out are counted
  in `dropped` (a_recording_past_the_macro_size_is_cut_and_counted).
- A macro recorded in a workbook opened from a file is approved for the session, since the
  person recorded it (a_macro_recorded_in_a_workbook_from_a_file_runs_without_asking).
- macro.record refuses parameters it would ignore: name or description when starting, relative
  when stopping, stop: false while recording, others beside cancel: true, and wrong kinds
  (record_refuses_parameters_it_would_ignore).
- Undo while recording keeps the undone step, as in Excel; documented
  (undo_while_recording_keeps_the_undone_step_as_excel_does).
- Docs: macros.md Recording and Tokens; module comments no longer say scripts are read.
2026-10-25 03:19:35 -04:00
prxnvt 8b018d89cd Model: the undo budget counts each macro's whole JSON, parameters included
- Macros::estimated_bytes counted names, descriptions and step counts but not step parameters,
  so each save, rename or delete (which copies the macro list an undo step shares) could keep a
  4 M-character parameter per step unseen by the 128 MB budget. It now counts each macro's JSON
  as it is written, without building it.
- This changes the macro storage layer below #2152, the smallest change that makes the budget
  hold (answers CodeRabbit thread 4366060037).
- Test: re_saving_a_large_macro_keeps_the_undo_history_within_its_budget.
2026-10-25 03:19:03 -04:00
prxnvt bc0d3035fb Engine: kept macros count toward the macro limit and keep their names; saving over a macro needs replace: true; renaming checks the size
- macro.save and a stopped recording count the macros kept in a newer format with the readable
  ones, so a save never holds more than the next open keeps; the message names the kept count
  (kept_macros_count_toward_the_limit_and_keep_their_names, which saves and reopens as xlsx and
  JSON).
- The names of kept macros are taken: macro.save and macro.rename refuse them, unique_name and
  next_name step around them (same test).
- macro.save no longer replaces by default: `replace` defaults to false, the error names the
  existing macro and says to pass `replace: true`, and a `replace` that is not a boolean is
  refused (saving_over_a_macro_needs_replace).
- macro.rename runs the whole macro check, size included, so a longer name can't take a macro
  past what an xlsx file holds (renaming_checks_the_macro_size).
- Docs: macros.md, the management commands and kept macros.
2026-10-25 03:18:26 -04:00
prxnvt f66ebd6fb2 Docs: debugging macros: actions, start options, the lock and the time limit per action
- The start options are those of `macro.run`, refused as it refuses them.
- The time limit applies to each debugger action; the step and parameter limits to the whole run.
- The lock lists what runs between steps, and says the sheet and selection are put back.
- Stop Action ends the run; the debugger is never a step and never recorded.
- The answers' fields, including `changedCells`, `shifted`, `wroteFiles` and `message`.
2026-10-25 03:17:42 -04:00
prxnvt 7dad14e77e Engine: the macro debugger locks its workbook to looking around, ends with Stop Action and checks its parameters
- `macro.debug` refuses to run as a step of a script or macro, and is never recorded (the
  recording buffer and Record Actions' journal both leave it out); a debugger whose undo group
  something else closed locks nothing, and its next action says the run had ended instead of
  reporting a keep or a rollback. Tests: `the_debugger_runs_on_its_own_and_is_never_recorded`,
  `a_debugger_whose_group_was_closed_elsewhere_locks_nothing`.
- Stop Action is no longer refused under the debugger: it rolls the run back, ends the debugger
  and names the macro. Test: `stop_action_stops_a_macro_being_debugged` (was
  `stop_action_waits_while_a_macro_is_being_debugged`).
- Between steps the debugged workbook runs only `macro.debug`, queries and an allow-list of
  commands that look around (selection, sheet changes, Go To, Find, Copy, zoom, Switch Window,
  Close, Stop Action); everything else is refused, saving and view modes included. The run's
  active sheet and selection are put back before its next step. Test:
  `between_steps_only_looking_around_runs_and_the_run_continues_where_it_was`.
- Only the top-level `macro.debug` call rolls the run back after a panic; a panic nested in a step
  is that step's error. Test: `a_panic_inside_a_step_is_that_steps_error_and_the_run_goes_on`.
- `keep` and `breakpointsOnly` must be true or false, breakpoints must be step paths of the macro
  (at most 10,000), and the action is checked before breakpoints change. Test:
  `debugger_parameters_are_checked_before_anything_changes`.
- Answers: `executed` counts the run's own steps (not the footprint's), `shifted` and `wroteFiles`
  are reported, a rollback's message names the files that stay, `changed` lists deleted and
  renamed sheets and at most 500 entries with `changedCells`, and a Repeat over whole rows or
  columns shows the part in use. Tests: `the_debuggers_answers_count_its_own_steps_and_say_what_stays`,
  `the_debugger_lists_deleted_and_renamed_sheets_and_caps_the_list`,
  `the_debugger_shows_a_repeat_over_whole_columns_as_the_run_goes_over_them`.
2026-10-25 03:17:42 -04:00
prxnvt d309c85c31 Engine: Stop Action waits while a macro is being debugged
`automate.stopAction` rolls back the undo groups open on the workbook. While a macro was being
debugged it rolled back the debugger's own group and left the debugger set, pointing at a group
that was gone. It is now refused under the debugger's lock with "Finish or stop the macro first",
as edits are; `macro.debug {action: "stop"}` ends the run.

- Test: `stop_action_waits_while_a_macro_is_being_debugged`.
2026-10-25 02:52:43 -04:00
prxnvt 60e0039524 Merge #2145 (ce2ab9a4, with the footprint pull request and #2144) into #2152
# Conflicts:
#	docs/macros.md
2026-10-25 02:50:32 -04:00
prxnvt ce2ab9a473 Merge the footprint pull request (c715c36c, with #2144) into #2145
# Conflicts:
#	docs/macros.md
2026-10-25 02:50:17 -04:00
prxnvt c715c36c07 Merge #2144 (2cf5b808) into the footprint pull request
# Conflicts:
#	docs/macros.md
2026-10-25 02:49:50 -04:00
prxnvt 2cf5b808b3 Docs: the macro page's conflict rules wrap at the page's width 2026-10-25 02:49:42 -04:00
prxnvt 3708c80168 Engine: macro.check says whether the run would go ahead; the question names what the answer covers
- `ok` in `macro.check` meant only that the dry run hit no error. It now says whether
  `macro.run` with the same parameters would go ahead without a question, and `errors` says
  why not: a failing step, a Stop step that rolls back, data written over under `ask` or `fail`,
  a shift that can't make room (the refusals `make_room` makes, now in `shift_refusal`), or a
  run past the step or parameter limit once the dry runs before it count. `macro.check` takes
  `onConflict` and `overwrite` as `macro.run` does, reports the rule it used, and refuses the
  parameters `macro.run` refuses.
- After a partial footprint, the question under `ask` says where the check stopped and that
  overwriting also lets the later steps write over data unchecked.
- The dry runs and the run share the parameter budget as they share the step budget.
- A dry run puts back the Go To list (`edit.goTo` with `record: true`).
- The footprint groups the touch log by sheet once, instead of filtering a million entries once
  per sheet.
- Docs: what `ok` means; RAND's sequence moves on after a check, as after any recalculation.
- Tests: `the_check_says_whether_the_run_would_go_ahead`,
  `the_question_after_a_partial_footprint_says_what_the_answer_covers`,
  `a_dry_run_puts_back_the_go_to_list`, the shared parameter budget, and `ok` under `ask` in
  `conflicts_ask_fail_or_overwrite`.
2026-10-25 02:49:35 -04:00
prxnvt 567ed0abc9 Merge #2144 (65cbcdd6) into the footprint pull request
# Conflicts:
#	crates/engine/src/cmd/mod.rs
#	crates/engine/src/lib.rs
#	crates/engine/src/macros/run.rs
#	crates/engine/src/tests/macros.rs
#	docs/macros.md
2026-10-25 02:45:42 -04:00
prxnvt 65cbcdd6bd Engine: a macro run names the files it kept, refuses parameters it would ignore, and bounds its work
- A run that rolls back after writing a file no longer says "Nothing was changed.": the message
  says the workbook was put back and names the step whose file stays (`StepError::wrote_files`,
  also after a Stop with rollback).
- `macro.run` refuses an input the macro doesn't have (a misspelt input bound the selection),
  an input that isn't reference text, and `onConflict`, `overwrite`, `approve` or `timeLimit` of
  the wrong kind, which it used to ignore; `null` still counts as not given.
- A step no longer copies itself, and Repeat and If no longer copy their bodies, on every pass:
  a body is copied once per run. A run passes its commands at most 256 MB of parameters, all
  steps together, which bounds a run's work on the web build, where there is no clock.
- Every step except an approved file step runs inside the scripts' file sandbox, so a command
  that touches a file under a parameter other than `path` can't do so from a macro.
- Docs: moving cells that hold data counts as writing over them.
- Tests: `run_parameters_that_would_be_ignored_are_refused`,
  `a_run_passes_a_bounded_amount_of_parameters`, and the rollback message in
  `each_step_is_checked_for_writes_over_data`.
2026-10-25 02:44:15 -04:00
prxnvt ad79b071ce Engine: a macro's inputs, output and Repeat ranges follow their cells when a step moves them
Inputs, the output and a Repeat's range were bound once, by sheet index and coordinates. A step
that inserted a sheet in front of an input's sheet sent every later token to the new sheet, and
rows inserted above an input left the tokens on the cells that moved into its place; the run
wrote into the wrong cells and reported success.

- Commands record the structural edits they make as they rewrite references (`Ctx::edits`; while
  a macro step runs, `DocState::edits` collects them).
- After each step the machine matches the sheets as the run's guard does and moves the inputs,
  the output and the ranges of the Repeats in progress by those edits, with the formula engine's
  own adjustment.
- A step that deletes an input's or the output's cells, or their sheet, or changes the size of a
  range a Repeat is going over, stops the run, which rolls back.
- Test: `inputs_and_the_output_follow_their_cells_when_a_step_moves_them` (a sheet inserted in
  front, rows inserted above, an input deleted, a Repeat's range grown).
2026-10-25 02:40:21 -04:00
prxnvt d053b1f548 Engine: a run is checked step by step even when its footprint was complete
A footprint is not proof of what the run will do. RAND gives the run other values than the dry
run before it (the recalculation's random sequence goes on), and NOW moves on, so an If can take
another branch in the run and write over data the dry run never reached; under `fail` the run
went ahead and wrote over it.

- Unless the rule is `overwrite`, every run checks each step as it goes, as a run after a
  partial footprint already did; a write the footprint didn't see stops the run, which asks under
  `ask` and fails otherwise, and rolls back.
- Test: `a_run_that_takes_another_path_than_its_check_is_still_checked` (an If on `=RAND()` over
  40 runs under `fail`; before, one of them wrote over B1).
2026-10-25 02:35:02 -04:00
prxnvt 0719458114 Engine: macro.check and the footprint: where a macro writes, worked out before it runs
Split from the pull request below at the review's request; the code is the runner's footprint
as reviewed there, unchanged.

- `macro.run` first runs the macro in dry-run mode on the real workbook (unless the rule is
  `overwrite`) and puts everything back: the workbook, its selection and history through the
  dry run's undo group, and the session state a dry run may touch (`Saved`: the active
  workbook, open documents, the saved mark, selections, unlocked ranges, trust, clipboard,
  preferences, mode, journal and recording, queued requests, format painter, find, ruler,
  hidden windows, drawing tool and the comment panes).
- Cells it would write outside its inputs that hold data, and sheets and objects it would
  delete, are conflicts: `ask` asks before anything runs, `fail` fails, and `shiftDown` and
  `shiftRight` insert cells over the block of every written cell and check again. A shift is
  refused when it would move an input, when the macro deletes sheets or objects, or when the
  touch log is full.
- A dry run stops before a file step and before Solver; the footprint is then partial, and the
  run checks each step as it goes.
- The dry runs and the run share one clock, one time limit and one step budget.
- `macro.check` reports the inputs, the output anchor, approval needed and the footprint.
- Tests: the footprint tests that moved with this code.
2026-10-25 02:33:26 -04:00
prxnvt 5860857937 Engine: a macro run checks each step for writes over data; the check before the run moves to its own pull request
The review asked for the runner to be split. This pull request keeps the policy, the input
checks, the step machine and `macro.run`; working out before a run where a macro writes (the
footprint dry run, shifting data to make room, `macro.check`) moves to the pull request above it.

- Unless the conflict rule is `overwrite`, every run now checks each step as it runs, which the
  run did before only after a partial footprint: a step that writes over cells outside the inputs
  that held data when the run started, or deletes sheets or objects that were there, stops the
  run, which asks (`ask`) or fails, and the run rolls back.
- `shiftDown` and `shiftRight` need the footprint, so here they stop the run as `fail` does.
- Moved: `Footprint`, `Saved`, `footprint`, `diff`, `make_room`, `macro.check`, the dry-run
  branches of the machine, `policy::previewable`, `DocState::touched` and the touch log,
  `Session::journal_dropped` and `truncate_journal`, and the tests of those.
- Test: `each_step_is_checked_for_writes_over_data` (the review's counterexample: a permitted
  file step or Solver, then a write over B1, under `fail` and `ask`).
2026-10-25 02:32:37 -04:00
prxnvt b40fa4e065 Merge #2145 (2fb31f11, with main at 17b9f6ff) into #2152
Main refuses window.* steps in scripts, so the recorder test's script step that a macro may not
run is app.commands.
2026-10-25 02:29:03 -04:00
prxnvt 2fb31f11f2 Merge #2144 (8ea4e279, with main at 17b9f6ff) into #2145 2026-10-25 02:26:03 -04:00
prxnvt 8ea4e27918 Merge #2151 (63a29800, with main at 17b9f6ff) into #2144
Main adds UiRequest::InsertName (Paste Name, Use in Formula), which puts a name in the cell
editor; a macro step drops it, as it drops a request to start editing a cell.
2026-10-25 02:26:02 -04:00
prxnvt 63a29800a3 Merge main at 17b9f6ff into #2151 2026-10-25 02:19:57 -04:00
prxnvt b995c7c9a0 Engine: a recorded script run keeps the steps that ran
The recorder expanded an automate.runScript step from the workbook as it
stood when the recording stopped, so a script edited or deleted during the
recording changed the saved macro, or dropped the step. The run now leaves
the steps it ran with the recorder (Recorder::ran, by step index, within
the byte budget), and the conversion reads those, falling back to the
workbook only when they could not be kept.
2026-10-25 02:18:58 -04:00
prxnvt 1e34a76428 Merge #2145 (c225caf3, with #2144, #2151, #465 at 3f8e6c28 and main at 6ead1fb5, where #462 is merged) into #2152
# Conflicts:
#	crates/engine/src/cmd/extra.rs
#	docs/architecture.md
2026-10-25 02:18:55 -04:00
prxnvt c225caf30a Merge #2144 (9331b05f, with #2151, #465 at 3f8e6c28 and main at 6ead1fb5, where #462 is merged) into #2145 2026-10-25 02:18:21 -04:00
prxnvt 9331b05f62 Merge #2151 (9fdf03cf, with #465 at 3f8e6c28 and main at 6ead1fb5, where #462 is merged) into #2144
# Conflicts:
#	crates/engine/src/lib.rs
#	docs/macros.md
2026-10-25 02:17:32 -04:00
prxnvt 9fdf03cfb5 Merge #465 (3f8e6c28, with main at 6ead1fb5, where #462 is merged) into #2151 2026-10-25 02:16:47 -04:00
prxnvt 3f8e6c2884 Engine: a JSON save refuses a macro that skipped the check, as an xlsx save does
Every path that adds a macro runs macros::check, and an xlsx save of a
macro that skipped it fails (the stash refuses the oversized item). A
JSON save wrote such a macro instead, and the next open dropped it with
a warning. json_bytes now checks each macro first, so the save fails in
either format with the check's message and the macro stays in the open
workbook. Tests that build hostile JSON files write them directly.
2026-10-25 00:27:42 -04:00
prxnvt e88760b480 Engine: an item too large to read takes no place in a stash listing
stash::items capped the items at MAX_ITEMS before reading them, so one
over the size limit took a place (a readable item past the cap was then
left out with fewer than MAX_ITEMS listed) and one past the cap was
counted in `more` though it could never be listed. Items are now read
first: the first MAX_ITEMS readable ones are listed and only readable
ones past them are counted.
2026-10-25 00:10:14 -04:00
prxnvt e285194643 Docs: a kept macro is saved back unchanged, not byte for byte 2026-10-24 23:45:04 -04:00
prxnvt 4c11929fe2 Tests: a visible legacy name of the macro storage shape survives a save
The stash reads a legacy _sc_<key> name as data whether or not it is
hidden, so writing key macro_0 removed a person's visible _sc_macro_0
name with no word. The index allocator steps around that key as well;
this test fails without it.
2026-10-24 23:45:04 -04:00
prxnvt da439b71ed Tests: a visible clash, a newer macro and a broken input together, through both formats
One file with a visible name of the storage shape, a macro in a newer
format and one whose input declaration breaks the rules, saved and
reopened xlsx then JSON and JSON then xlsx: the visible name stays, the
newer macro comes back whole and numbered around the name, the broken
one is left out once, and the readable one survives.
2026-10-24 23:45:04 -04:00
prxnvt 3905a4318e Engine: a macro in a newer format is kept unread and saved back unchanged
A macro whose format version is above this build's was dropped on open,
so the next save removed it from the file, and the warning said only
that it couldn't be opened. It is now kept as the file held it
(Workbook::macros_unread, shared between snapshots), never read, listed
or run, and written back unchanged by JSON and xlsx saves, numbered
after the readable macros and around visible names of the storage
shape. Kept macros count toward the 1,000-macro limit after the
readable ones; one too large for an xlsx item is left out. The warning
says it is kept and saved back; damaged and over-limit macros keep
their own warning.
2026-10-24 23:45:04 -04:00
prxnvt 471a40bd72 Model: every field a macro file sets has a limit, and an input declaration is checked as a whole
check_limits capped the name, description, notes, step count, depth and
input names, and left the shortcut, permissions, output anchor, default
range, column types, command ids, Repeat ranges, If cells and values and
Stop messages bounded only by the item size; the input declaration was
stored unchecked (rows [5, 2], a cell input with cols [2, 5]).

Now each of them has a limit (docs/macros.md lists them), one-line
fields refuse control characters, and a command id is letters, digits,
. and _ (every registered id passes, tested). An If value may be null
(Empty and NotEmpty ignore it); Repeat.max stays the runner's to bound.
An input's least rows and columns can't pass its most, a cell input is
one row and one column with no header and at most one column type, and
no input lists more column types than columns. check_limits takes every
struct apart field by field, so a new field doesn't compile there until
its limit is decided. A macro from a file that breaks a rule is left
out with the usual warning, never changed to fit.
2026-10-24 23:39:14 -04:00
prxnvt ac36eee23d Engine: a JSON workbook with no sheets keeps its macros
The JSON reader replaces a workbook with an empty sheet list by a new
one before macros::load reads the file's macro entries, so they were
lost with no warning. The entries are carried across the reset. The
reset itself, which also starts the names and styles afresh, is
unchanged.
2026-10-24 23:35:32 -04:00
prxnvt 3c722f4d86 Engine: a visible name of the macro storage shape no longer blocks an xlsx save
with_names numbered macros 0..N, and stash::set_many refuses to write a
hidden name beside a visible one of the same name, so a visible
_gc_macro_0_0 (from a file, or defined after the file opened, when the
stored names are no longer in the workbook) made every xlsx save of a
workbook with macros fail. The index is GridCraft's choice: macros now
take the free indices from 0 up, skipping every key a visible
workbook-scope name of the storage shape uses (compared as the stash
reads names), in their order. No arithmetic depends on a number from
the file.
2026-10-24 23:35:03 -04:00
prxnvt 54bd4eca9d Engine: a run that edited the workbook before opening a dialog is recorded
The recorder left out every call that opened a dialog, so a script with an
edit before a menu-style step lost that edit from the recording. A call is
left out only when it opened a dialog and changed nothing (the dialog's
confirming call is the step); one that changed the workbook is kept.
2026-10-24 02:42:45 -04:00
prxnvt 6cd4cebfb7 Engine: Stop Action runs on its own and is not recorded
Run from inside another command, automate.stopAction rolled back that
command's own group, pulling the workbook from under the rest of its run; and
the recording buffer kept it, so a recording could replay the stop inside the
run it was part of. Nested, the command is refused and the group stays; the
recorder leaves it out (record::NOT_RECORDED).
2026-10-24 02:18:07 -04:00
prxnvt f040b4ecaa Engine: stash listings say how many items were past the cap
stash::items and stash::keys listed the first 10,000 items and let the rest
vanish without a word. They return a Listing { found, more } now, so a caller
can say what it did not list.
2026-10-24 02:18:07 -04:00
prxnvt fddf2d9a31 Merge main at 6ead1fb5 (#462 merged) into #465
# Conflicts:
#	crates/engine/src/cmd/stash.rs
2026-10-24 02:10:51 -04:00
prxnvt 95c7e70ce6 Merge #462 (3b25aa0d, with main at b8771d51) into #465
# Conflicts:
#	crates/engine/src/cmd/stash.rs
2026-10-24 01:53:52 -04:00
prxnvt 3b25aa0d16 Docs: the panic guard's note says what happens to a group the command ended
It keeps a step from the group's start to the restored workbook, so Undo
still returns to the state before the group; the note said the history came
back exactly.
2026-10-24 01:41:03 -04:00
prxnvt 4a294aefa9 Engine: the name commands use the stash's own rule for what holds its data
The guard asked for a hidden name, but the stash reads a legacy _sc_<key>
name whether it is hidden or not, so a visible one could be removed or
replaced without force. stash::holds_item is the one rule (item_of), shared
by the guard; the test covers a visible legacy name.
2026-10-24 01:41:03 -04:00
prxnvt b09a5502e3 UI: the toolbar's Undo and Redo follow the commands' own availability
The ribbon enabled them from the history lengths alone, so while an undo
group was open on the document (an action spanning several calls) a click
ran edit.undo and got its refusal. The buttons now ask cmd::can_undo and
cmd::can_redo, the undo list is empty while a group is open, and the two
buttons carry a name and their enabled state for assistive technology. A UI
test opens a group, clicks both and checks the history and their state.
2026-10-24 01:30:15 -04:00
prxnvt b44a5139ab Engine: the name commands refuse only names that hold GridCraft's data
The guard refused any name of the stash's shape, so a visible name a person
had defined, such as _gc_rate_0, could not be edited or deleted from Name
Manager, which cannot pass force. It now refuses a name only when the
workbook holds a hidden workbook-scope item under it; a visible name of the
shape is the person's own, as the stash itself treats it. The test covers
such a name. The query comment says what the copy skip relies on: a query
runs nothing that changes the history.
2026-10-24 01:19:07 -04:00
prxnvt f2a7252f93 Merge main into #462 2026-10-24 01:07:18 -04:00
prxnvt 9bbba198c6 Engine: a document's maker says where its content came from
`Trust` defaulted to `Local`, and `DocState::new` set it, so a
document was trusted unless its maker remembered to mark it. Every
current path that reads a file goes through `file.open`, which marks
it `FromFile`, but a later way of building a document from bytes
(recovery, templates, a new import) would have come out trusted by
omission.

`DocState::new` now takes the `Trust`, and `Trust` has no default.
New workbooks and samples pass `Local`, `file.open` passes
`FromFile`, a new window passes the trust of its workbook, and the
tests that build documents from bytes pass `FromFile`.
2026-10-24 01:04:51 -04:00
prxnvt 37bf09e94a Engine: queries take no copy of the undo history
`execute` copied the document's undo and redo history on every call,
so a panic could put it back: up to 100 entries per stack, each with
a label and a selection. Queries paid for it too, although no query
runs a command or changes the history, and front ends and agents run
queries all the time.

The copy is now skipped for queries (commands that neither journal
nor make undo steps). A test runs every query, with empty and with
typical parameters, and checks the history is unchanged, so a query
that ever changes it fails there. Commands that can change the
history, nested ones included, still take the copy: a nested command
that panics is restored at its own level, and the outer command can
carry on, so relying on the outermost copy would weaken that restore.
2026-10-24 01:04:51 -04:00
prxnvt 4668fc30ae Engine: the name commands leave GridCraft's data names alone
`formulas.defineName {"name": "_gc_macro_1_0"}` replaced the hidden
chunk of a stored item with a visible name: the item vanished from
`stash::get` and `stash::keys`, and its other chunks stayed behind as
orphan hidden names. `formulas.deleteName` could remove chunks the
same way, and `formulas.createFromSelection` with `replace` could
replace one from a cell label. `stash::set` refused a clash with a
visible name, but nothing guarded the other direction.

Defining, renaming or deleting a name that `stash::item_key`
recognises (`_gc_<key>_<n>` and legacy `_sc_<key>`) is now refused
with an error that names `force: true`, which lets the call go ahead.
Create from Selection lists such labels in `skipped` unless `force`
is true. The params docs of the three commands say so.
2026-10-24 01:04:51 -04:00
prxnvt b90710794a Engine: list stash items in one pass over the names
Each `stash::get` looks at every defined name, so listing the items
with `keys` and then reading each with `get` took time quadratic in
the number of names. A file with 20,000 hidden `_gc_macro_<i>_0`
names took about ten seconds in a debug build, and the macro list
reads its items this way from data that comes from the file.

`stash::items(wb, prefix)` now groups the names by item in one pass
and returns each item's text by key, and `keys` lists from the same
pass. Both return at most `MAX_ITEMS` (10,000) items, the first keys
in sorted order, so a file with any number of hidden names can't make
a listing unbounded. A test counts the names each listing looks at.
2026-10-24 00:55:07 -04:00
prxnvt 0fa6e78863 Engine: stash items keep characters XML can't carry
The xlsx writer drops characters XML 1.0 can't carry: controls other
than tab, line feed and carriage return, and U+FFFE and U+FFFF. A
stash item holding one lost it on save without a warning, and so did
a JSON item, since serde_json leaves U+FFFE and U+FFFF unescaped: a
stored macro parameter changed silently.

`chunk_formulas` now writes each such character, and the marker
itself, as the noncharacter U+FDD0 followed by four hex digits, and
`get` turns them back for `_gc_` items. A marker that isn't followed
by four hex digits naming a character is read as it is. An escape
counts as five units against the 255-unit literal limit and is never
split across literals, and the guard on the stored size allows the
longest item even when every character is escaped. Legacy `_sc_`
items are read unchanged.
2026-10-24 00:53:38 -04:00
prxnvt 2e72407fc6 Engine: the recorder skips a call that only opened a dialog
A menu-style call with no parameters, such as `file.open`,
`data.fromTextCsv` or `home.formatCells` with `{}`, only queues its
dialog and succeeds. The recorder kept it as a step with empty
parameters, and then kept the dialog's confirming call as well, so
playing the recording back ran the command twice, or failed on the
empty step when played back headless. Only `execute_headless` took
the step back, and only for the open and save-as dialogs.

`execute` now compares the queued UI requests before and after a
top-level command and does not record one that queued a dialog. The
special case in `execute_headless` goes (it keeps turning a missing
file into an error), and with it `Recorder::truncate`, which had no
other caller.
2026-10-24 00:51:37 -04:00
prxnvt 2b33fcf881 Engine: a failed command rolls back the undo groups it opened
A command that opened an undo group and then returned an error (a `?`
between `group_begin` and `group_end`) left the group open for good:
Undo and Redo refused with "finish or stop the running action first",
later edits made no undo steps, and nothing could stop the group.

`execute` now rolls back every group a failed command opened, as
`group_rollback` does, restoring the workbook, selection, undo and
redo history to where each group began. Only a command that succeeds
keeps a group it opened, which is what a debugger's start needs.

`Session::abort_groups(doc)` and the menu-less command
`automate.stopAction` roll back the groups left open on a document,
for a debugger the person abandons or a caller that dropped its
token. The command is not undoable and works while a group is open,
and the Undo refusal now names it.
2026-10-24 00:49:43 -04:00
prxnvt 359cce4bc3 Tests and docs: merge fallout from main and the lower branches
automate.allScripts reports trusted since #2178, and scripts reopened from
a file are not trusted yet, so the side-by-side test expects the flag and
compares names and steps after the round trip. cargo xtask parity gives
276 / 292 now (upstream added a command), so the ROADMAP and ui-parity
summaries say so, and this branch's revision rows (275 / 291 to 276 / 292)
sit at the top of their tables with the date they land.
2026-10-24 00:36:36 -04:00
prxnvt ee8e5d35c0 Merge #2145 (bac5b9c6, with #2144 at 2215934c, #2151 at 44a2f4e2, #465 at 673af7ec, #462 at 712bb766 and main at 530a8cc8) into #2152
# Conflicts:
#	ROADMAP.md
#	docs/architecture.md
#	docs/macros.md
#	docs/parity-checklist.md
#	docs/ui-parity.md
2026-10-24 00:28:47 -04:00
prxnvt bac5b9c66e Merge #2144 (2215934c, with #2151 at 44a2f4e2, #465 at 673af7ec, #462 at 712bb766 and main at 530a8cc8) into #2145
# Conflicts:
#	crates/engine/src/tests/macros.rs
#	docs/macros.md
2026-10-24 00:10:28 -04:00
prxnvt bb3db53489 Engine: the macro recorder keeps a step's own sheet, stays bounded, and can be cancelled
Six fixes from a review of the recorder, each with a regression test (four
fail against the old recorder; the other two bound work that grew with the
input):

- A step that names another sheet with its sheet parameter (cell.set and
  about fifty other commands) was tokenized as if on the input's sheet, so a
  recorded cell.set {sheet: Two, cell: C4} ran on Two at the input's
  relative place. Such a step keeps its references, like a sheet-qualified
  one.
- Inlining recorded runs of other macros and scripts stops at the macro's
  step limit and counts the rest in dropped, instead of expanding every run
  first (a thousand runs of a long macro made millions of steps).
- macro.record {cancel: true} ends a recording without saving it: the way
  out of a recording whose macro is too large for the workbook, which could
  neither be saved nor ended.
- Formula tokenization reads each reference in the formula and matches it,
  instead of listing every column and row of the example (an example as
  large as a sheet made a million candidates per formula).
- A selection of several areas keeps its active cell as it is; a token for
  the active cell alone put it outside the areas on playback.
- Steps recorded in another workbook are counted in dropped.
2026-10-23 23:56:32 -04:00
prxnvt 2215934c9d Engine: a shift is refused when the footprint cannot follow every write
The touch log keeps at most a million writes; past that, a write that kept a
cell's value is missing from the footprint, so a shift derived from the
block of written cells could split a row the macro writes. The footprint
now says when the log filled up (logFull in macro.check), and a shift is
refused then, as it is when the macro deletes sheets or objects. Regression
test with a million-cell fill in a dry run.
2026-10-23 23:55:39 -04:00
prxnvt 879b96f858 Docs: the footprint dry run is skipped when the conflict rule is overwrite 2026-10-23 23:30:35 -04:00
prxnvt a626705c95 Engine: a dry run puts the journal back even after the journal trimmed itself
Every nested command a dry run executes is journaled, so a long macro pushes
the journal past its limit and the journal drops its oldest entries. The
restore cut the journal back to its old length, which then kept entries from
the dry run and pointed Record Actions at the wrong place. The session now
counts the entries its journal has dropped (journal_dropped) and truncates
with its size bookkeeping (truncate_journal); the restore keeps the old
length less what was dropped meanwhile and moves the recording's start back
by the same count. Regression test with 12,000 nested steps.
2026-10-23 23:30:35 -04:00
prxnvt fb4acb04a2 Engine: closing the debugged workbook ends the debugger
file.close is not an edit, so the debugger's lock let it through, and the
session kept the debugger with a token for a group that went with the
document: the next workbook could not start a debugger, and the state
reported that no document was open. close_document (also the path that
replaces an untouched blank workbook on file.open) now ends a debugger whose
workbook it closes. Regression test.
2026-10-23 23:30:14 -04:00
prxnvt 74ff8ac625 Engine: macro runs refuse a shift that moves an input, stay clean when nothing changed, take the answer however ask was given, and share one step budget
Four fixes from a review of the runner, each with a regression test that
fails without it:

- A shift inserts cells over the block of every written cell, which moves
  that block's columns (or rows) from there on. When a bound input lay in
  the way, its data moved and sheared while the run kept the old coordinates
  and reported success. The shift is now refused, naming the input and its
  place. The output is not counted: it is where the macro writes, and the
  second footprint catches data a shift moved into it.
- A dry run or a refused run left a clean workbook dirty: view.zoom,
  sheet.activate and the view modes move the saved mark to the current
  workbook on a clean document, and the rollback put the workbook back but
  not the mark. Saved keeps and restores it.
- overwrite: true answers the question however ask was given; with an
  explicit onConflict: ask the answer was ignored and the question came back
  forever.
- The dry runs and the run share one step budget as they share one clock;
  each phase counted its own 100,000 steps before. ran reports the run's
  own steps.
2026-10-23 23:13:18 -04:00
prxnvt 461395d0a0 Engine: col and cols keep the header state, so a later data takes the header off
Input.col("date").data resolved to the whole column, header included, because
every method cleared the header state. col and cols keep it; the other
methods still clear it, and only data keeps the header row for col("…").
The token test asks for D3:D11, cols(2..3).data and col(2).data.col(1).
2026-10-23 23:09:02 -04:00
prxnvt 551eda7e8e Engine: the xlsx copy gets an empty macro list, not a copy of the shared one cleared
out.macros shares its list with the workbook being saved, so clear() copied
every macro first.
2026-10-23 23:08:49 -04:00
prxnvt 43b0e39e8e Engine: set_many refuses one key given twice
Two items under one key, in any case, would leave two hidden names of one
name, of which an xlsx file keeps one, and get would mix their chunks. A
caller's mistake, refused before anything changes. Regression test.
2026-10-23 23:08:49 -04:00
prxnvt bbf36000d4 Engine: the recorder counts a dropped step without serializing it
Once a step has been dropped, or the step count is full, push serialized
each later step only to count it; a file.open step holds a whole file. The
checks that need no size come first.
2026-10-23 23:08:36 -04:00
prxnvt 0df540f166 Merge #2151 (44a2f4e2, with #465 at 673af7ec, #462 at 712bb766 and main at 530a8cc8) into #2144
# Conflicts:
#	docs/macros.md
2026-10-23 22:36:10 -04:00
prxnvt 44a2f4e2f1 Merge #465 (673af7ec, with #462 at 712bb766 and main at 530a8cc8) into #2151
# Conflicts:
#	docs/macros.md
2026-10-23 22:08:05 -04:00
prxnvt 00b63468e6 Engine: macro tokens resolve only where they mean a reference, and refuse instead of guessing
Six fixes from a review of the resolver, each with a regression test that
fails without it:

- A bare Row or Column under a general reference key (to, from, at, area,
  source, target, destination, location) stays text: Change Spelling's
  replacement word and a pivot field's area were being rewritten into the
  current row's A1 range. Row.col(2), {{Row}} and {"$ref": "Row"} still
  resolve there, and the bare word still resolves under cell and range keys.
- {{{{ in a string is a literal {{, so text that needs the braces can be
  written; an unknown token is still an error.
- Input.data on a range that is only a header row, and Name: of a table
  with no data rows, are refused instead of reading the header.
- below(0), below(-3) and right(0) are refused like resize(0, 1) is, instead
  of becoming 1.
- A defined name on a sheet whose name holds a dollar sign resolves: the
  dollar anchors are stripped from the reference part only.
- quote_sheet is the engine's one rule (gridcraft_core::quote_sheet), which
  quotes a sheet named like a cell reference, an R1C1 token or a boolean and
  leaves a period unquoted.

docs/macros.md says which keys take a bare token and how to write a literal
brace.
2026-10-23 22:02:45 -04:00
prxnvt 673af7ecdc Merge #462 (712bb766) into #465 2026-10-23 22:00:39 -04:00
prxnvt 4672b6da51 Model: an input name that reads as a cell reference is refused
A macro could declare an input called Q1, AB12 or Sum1, and a step written
with plain A1 text, as a recorded one is, would then resolve to the input's
range instead of the cell. check_limits refuses such names; the docs say so.
Regression test.
2026-10-23 21:48:53 -04:00
prxnvt 712bb766ec Engine: a grouped action that panics is rolled back before the panic goes on
Session::grouped ran its action without a panic guard, so a caller outside
a command that caught the panic found the group still open: Undo and Redo
disabled, the partial edits kept, and the next grouped action nested in the
abandoned group. The group is now rolled back and the panic resumed.
Regression test.
2026-10-23 21:46:25 -04:00
prxnvt 8219c18b92 Engine: macro storage shares the list, saves in one pass, keeps unknown names, checks once
- Workbook::macros is a shared list (Macros, a copy-on-write newtype): a
  workbook copy made for a cell edit or an undo snapshot holds the same list,
  and the undo budget charges a list once a snapshot owns its own copy.
  Before, every mutating command copied every step of every macro, and a
  hundred undo entries kept a hundred copies the budget never saw.
- Saving to xlsx drops the last save's items and writes the macros in one
  pass each (stash::set_many), instead of a scan of every name per macro.
- A hidden _gc_macro_<x>_<k> name with a key this build did not write is
  kept on open and on save, as a visible one is, instead of being dropped
  without a warning.
- macros::check is the one check for every path that adds a macro (JSON, a
  file, built in code): the model's limits and the xlsx size limit.

Regression tests for each.
2026-10-23 21:45:54 -04:00
prxnvt 259b6e67e8 Model: Repeat and If nest four deep, as the limit says
The depth check refused any step list at the fifth level, so a fourth Repeat
or If could not even hold an empty body: three levels nested, not the four
MAX_MACRO_DEPTH and the docs state. The check now counts the Repeat or If
steps themselves, in the model's limits and in the parser's pass over the
JSON. The test asks for four and refuses a fifth.
2026-10-23 21:45:54 -04:00
prxnvt 7ee9d77483 Docs: list every paste that carries a file's trust
The architecture note named formulas and validation only; it now lists the
Paste Special kinds and linked pastes that write formulas, and the kinds that
carry nothing.
2026-10-23 21:36:09 -04:00
prxnvt 3ca1252571 Engine: a group a panicking command ended keeps its step
A group open before a command and ended or rolled back inside it lost its
undo step when the command then panicked: the restored history held no entry
for the group's changes, so Undo skipped the state before the group. The
panic path now pushes the outermost such group's step from its start to the
restored workbook. The group stays ended, because its token went with the
command. Regression test for a group ended and one rolled back.
2026-10-23 21:36:09 -04:00
prxnvt 378ce3a5a0 Docs: the macro page describes this version
The inputs paragraph described how a run binds inputs and the message it
shows, which the runner adds in a later pull request. It now says what the
declaration is for and that this version stores and checks it.
2026-10-23 21:28:05 -04:00
prxnvt cef58da39e Merge #462 (d7c3c183, with main at 530a8cc8) into #465 2026-10-23 21:25:35 -04:00
prxnvt d7c3c18345 Engine: the panic guard restores entries a nested Undo removed
Truncating the undo history to its length at the command's start cannot
bring back an entry that a nested edit.undo popped. The guard now keeps the
undo and redo history as they were and puts both back. The regression test
gains the undo-then-panic case.
2026-10-23 20:47:40 -04:00
prxnvt 8440427c53 Tests: a linked paste of column widths or notes copies its metadata and no cell
The source now carries a note and a column width, and the test checks that
the destination received them while its trust stays Local and no cell is
written.
2026-10-23 20:15:05 -04:00
prxnvt d1e51a4af4 Engine: a later stash chunk on its own is not a listed item
stash::keys reported a key that get read as missing, because only a later
chunk of it existed. keys now counts chunk 0 and legacy names only, as get
does. Regression assertions in the hostile-names test.
2026-10-23 20:08:40 -04:00
prxnvt 456cf73886 Engine: a headless call that fails for want of a file is not recorded
file.open with no file succeeds by queuing a dialog, which execute_headless
turns into an error for callers with no UI; the recording kept the step.
Recorder::truncate forgets it and keeps the byte count right. Regression test.
2026-10-23 20:08:40 -04:00
prxnvt a3b5f8e45c Engine: a command that panics leaves the undo history as it found it
The panic guard restored the workbook and selection but not the history, so
a nested edit made before the panic left a no-op undo step and the redo
history it had cleared stayed lost. The guard now truncates the steps made
during the command and puts the redo history back. Regression test, and the
architecture note says what the guard restores.
2026-10-23 20:08:40 -04:00
prxnvt 6b7c48f119 Engine: a stash chunk number with leading zeros names someone else's name
`_gc_watch_00` parsed as chunk 0, so a hidden one was read as the item's first
chunk and a visible one stopped `_gc_watch_0` from being written. Only the
chunk number as `set` writes it counts now. Regression test.
2026-10-23 20:01:21 -04:00
prxnvt 66e69cb946 Engine: a linked paste carries the file's trust whatever it pastes
Paste Link writes a formula into every cell whatever `what` says, so a linked
paste of formats from a file now makes the destination FromFile as a paste of
formulas does. Column widths and notes write no cells, linked or not, and
still carry nothing. Regression test for the three kinds.
2026-10-23 20:01:21 -04:00
prxnvt 7e383f460a Docs: undo groups at a command's boundaries, and the bound on steps made inside a command 2026-10-23 16:36:52 -04:00
prxnvt 080b6d993b Engine: a grouped action that closes its document succeeds
Session::grouped ended its group after the action, and when the action had closed the group's document (file.close, or file.open taking the place of an untouched new workbook) the group had gone with it, so the action was reported as failed although it had done what it was asked. A group that is gone because its document is gone now counts as ended; a group ended by someone else while the document is open is still an error.
2026-10-23 16:36:34 -04:00
prxnvt 6ab8d97118 Engine: the undo steps made inside a command are bounded while it runs
The history limit is applied once the outermost command has finished, so the lengths its bookkeeping compares stay true (round 3). That left the steps nested commands make unbounded until then: a script of up to 100,000 steps, or a subtotal with a step per group, kept every one of them alive, each with the cells it retains, and before this branch every push trimmed the history at once.

Each nested command now bounds the steps made since the latest mark on its document to the undo limit and byte budget, dropping the oldest of them. The marks are the starts of the running commands, the starts of the open groups and the end of the last group: the positions a command or group collapses its steps by when it finishes, so nothing before them moves. What the bound drops is only what the final trim would drop.
2026-10-23 16:35:45 -04:00
prxnvt 114088d589 Engine: a command's changes after ending a group, or before opening one it leaves open, are not lost or a step of their own
Two boundaries of the group rules were wrong. A command that ended a group open before it was treated as grouped whatever it did next: with an unchanged group (no step) its later edit had no undo entry at all, and with nested edits after the end the entry was the nested command's. The group's step now stays the group's, and the command makes one step of its own, under its name, for what changed after the end, from the workbook the group ended at.

A command that edited and then opened a group it leaves open (a debugger's start) left its nested entries in the history, so one Undo after the group ended brought back a state from inside the command. Such a group now takes the command's start as its own, and its step covers the command's earlier edits.

The record of where the last group ended now holds the workbook and selection it ended at, the history length then and whether it made a step; it lives until the outermost command finishes. A rollback records where it ended too, unless the document is back exactly where the last recorded step left it.
2026-10-23 16:33:42 -04:00
prxnvt 5ca886507c Engine: writing a stash item checks for a clashing visible name in one pass over the names
The check ran once per chunk, each a scan of every defined name: an item of many chunks in a workbook with thousands of names compared every pair. One pass now parses each visible workbook-scope name and refuses when it is a chunk of this key below the chunk count. The behaviour and its test are unchanged.
2026-10-23 16:21:48 -04:00
prxnvt 524826687c Docs: the architecture revision row carries the date the change lands; document trust beside script trust
The header and the revision row said 2026-10-11, older than the row above them. The document trust paragraph now says how DocState::trust (provenance) differs from DocState::scripts_trusted (per-session consent for stored scripts, #2178).
2026-10-23 16:17:57 -04:00
prxnvt a8e8bf851e Engine: the recording buffer is bounded in bytes and keeps the start of a recording
The buffer kept at most 100,000 steps but each step keeps its full parameters, and a file.open step holds the whole file as base64, so a recording of a few opens of a large workbook kept tens of MB alive. Like the journal, the buffer now also stops at 64 MB of steps serialized as JSON. A step that does not fit is counted as dropped, and once a step is dropped every later one is too, so what the buffer holds is always how the recording began, never a sequence with gaps.
2026-10-23 16:17:57 -04:00
prxnvt 18ec913c4e Engine: a command keeps its group's name only for a step that is exactly the group's
The record of the last group step held the document, the history length and a group epoch, and a command reused the group's label when the lengths matched and the epoch was newer. A direct edit after the group (no nested command, so the length still matches) then left the step named after the group alone.

The record now holds the workbook the group's step led to (a weak reference, so it keeps nothing alive). A command keeps the group's name only when the one entry it added starts from the command's own starting workbook and ends at its result, which is provenance by identity rather than by position: an entry a nested command made, a step of an earlier command undone since, and a group step the command edited past all name the step after the command. A later group that rolled back changes nothing, and the rollback no longer needs to clear the record.
2026-10-23 16:17:57 -04:00
prxnvt 602d4450a2 Merge main into #462 2026-10-23 16:10:43 -04:00
prxnvt 67fa9f7b4e Merge main into #2152
# Conflicts:
#	ROADMAP.md
#	docs/architecture.md
#	docs/target-app-parity.md
#	docs/ui-parity.md
2026-10-22 02:39:09 -04:00
prxnvt bca1483e2e Merge main into #2145 2026-10-22 02:37:04 -04:00
prxnvt 38ad2dba83 Merge main into #2144
# Conflicts:
#	crates/engine/src/tests.rs
2026-10-22 02:37:04 -04:00
prxnvt 3eed81318f Merge main into #2151 2026-10-22 02:36:09 -04:00
prxnvt faff24f3aa Merge main into #465
# Conflicts:
#	crates/engine/src/io.rs
2026-10-22 02:36:09 -04:00
prxnvt 66f7993ba5 Merge main into #462
# Conflicts:
#	crates/engine/src/lib.rs
#	crates/engine/src/tests.rs
#	docs/architecture.md
2026-10-22 02:30:25 -04:00
prxnvt bbf1bc441b Engine: stopping a recording waits while a macro in its workbook is being debugged
Stopping a recording saved the macro with a nested macro.save while a debugged run's undo group could be open on the same workbook. Stopping the run without keeping its changes then deleted the macro that macro.record had reported as saved. Stopping now fails with "Finish or stop the macro first" under macro.record before anything changes, and the recording goes on, as when a save fails for another reason, so stopping again after the run ends saves it.
2026-10-22 02:26:33 -04:00
prxnvt ea691f703c Engine: a stopped recording reports the steps cut at the macro step limit in dropped
A recording keeps up to 100,000 commands, and inlined macro and script runs can add more, but a macro holds at most 10,000 steps. Stopping truncated the converted steps to that limit without saying so: dropped counted only the recorder's own overflow, so a 10,005-step recording saved a 10,000-step macro and reported nothing missing. The steps cut at the macro limit are now added to dropped.
2026-10-22 02:26:33 -04:00
prxnvt 3f5f6ea4f1 Engine: a name given when a recording stops never replaces an existing macro
Stopping a recording with a name that a macro already has (in any case) saved over that macro's
steps, inputs and description, with only Undo to get them back. A taken name now gets a suffix
("Tidy (2)"), and the save passes replace: false as a backstop.
2026-10-22 02:26:33 -04:00
prxnvt 20eb282e75 Docs: catalog count 275 / 291 in the roadmap and parity summaries
The catalog went from 274 / 290 to 275 / 291 with View › Macros › Record Macro, and
parity-checklist.md says so, but the ROADMAP.md headline, ui-parity.md and target-app-parity.md
still said 274 / 290 (and 290 catalog ids). They now match, with revision rows.
2026-10-22 02:26:33 -04:00
prxnvt 6a004bf610 Engine: stopping Record Actions waits while a macro in its workbook is being debugged
Stopping Record Actions saves a script into the workbook, which the debugger lock refuses while a
macro there is paused. The refusal came after Record Actions had taken its recording, so the
recording was lost. Stopping is now refused before anything changes, and stopping again after
the run saves the script.
2026-10-22 02:26:32 -04:00
prxnvt c0079aabe9 Docs: a failed macro run rolls back workbook changes; a file it already wrote stays
The intro said a macro run rolls everything back on an error, but a step that exports or prints to a path writes the file before a later step can fail, and the Running section says that file stays. The intro now limits the guarantee to workbook changes.
2026-10-22 02:26:32 -04:00
prxnvt 045cf428f5 Engine: a column header made by a formula matches its token while formulas are shown
`col("Amount")` read the header through the cell's display text, which is the formula while
Show Formulas is on, so a header such as ="Amo"&"unt" matched nothing. The header is now the
cell's value as formatted, whatever the sheet shows.
2026-10-22 02:26:32 -04:00
prxnvt 6312c7cb40 Engine: only hidden names are read as macro storage
A visible workbook-scope name such as `_gc_macro_0_0` matched the storage key and was read as a
macro and removed when the workbook opened. Macro storage is written hidden, so only hidden names
are read back; a person's own name of that shape stays a name.
2026-10-22 02:26:32 -04:00
prxnvt 8797b7183c Engine: stopping a macro recording saves it in the workbook where recording started
Stopping a recording converted and saved it against the active workbook. For example, starting a
recording in Book1 and switching to Book2 before stopping saved the macro into Book2, inferred the
input's header and column types from Book2's cells and looked up inlined macros and scripts
there. The recording is now converted and saved against the workbook where it started, and the
active workbook stays active. If that workbook was closed, stopping returns an error and ends the
recording.
2026-10-22 02:26:32 -04:00
prxnvt f553619b33 Engine: the macro debugger lock also refuses edits that another command runs
Session::execute checked the debugger lock only for top-level undoable commands. A command that is not undoable could run edits as nested commands and skip it: stopping Record Actions while a macro in the same workbook was being debugged saved the script inside the run's undo group, and stopping the run without keeping its changes deleted the script again. The lock now applies at every depth, and only the debugged run's own steps go ahead.
2026-10-22 02:26:32 -04:00
prxnvt f1a46a2b53 Docs: the macro policy summary lists the file commands that do run
The policy summary said file commands never run in a macro, but File >
Properties runs, and exports and printing run: without a path they only
produce bytes, and with one they need the permission and approval. The
summary now names what is denied and what runs, and the paragraph is
wrapped to the file's line length.
2026-10-22 02:26:32 -04:00
prxnvt 0ea5d3ee33 Engine: a Name: token reaches a defined name that holds periods
`Name:` read a name only up to the first period, so `Name:Sales.Q1` looked up `Sales` and
treated `Q1` as a method, and a workbook name called Sales.Q1 could not be used in a macro,
alone or as `Name:Sales.Q1.cell(1,1)`.

The name now runs through periods, and the longest defined name or table that exists and ends
at a period (or the end) is used; what follows is read as methods. So `Name:Data.row` is a
name called Data.row, and `Name:Sales.offset(1,0)` still moves Sales. When no name matches,
the error names the text before the first method, and a bad method after a dotted name is
still refused. Tests cover direct resolution, reference parameters, `$ref` and `{{...}}`,
with and without a chained method.
2026-10-22 02:26:32 -04:00
prxnvt 3c74dfcbb0 Engine: renaming macros with clashing names on open takes near-linear work
Opening a workbook gave each clashing macro name a suffix by trying " (2)", " (3)"... in turn,
and each try scanned every macro accepted so far. A file with 1,000 macros of the same
255-character name made about 300 million name comparisons.

Load now keeps a set of the names in use (ASCII case-insensitive, as find compares them) and,
for each shortened name and suffix width, the next suffix to try, so each suffix is looked up
about once. The result is unchanged: the smallest free suffix, within the name length limit,
and the renamed macro still passes the final checks. unique_name keeps its signature. A test
opens, saves and reopens 1,000 same-name macros in JSON and xlsx and keeps them all, and
another counts lookups instead of timing them.
2026-10-22 02:26:32 -04:00
prxnvt efce63f75f Engine: macro recorder applies the macro policy to the steps of a recorded script run
An Automate script run while recording was replaced by all of its steps except automate.* ones,
without the policy check that top-level commands get. For example, a script that runs
window.activate or macro.list produced a recorded macro that the runner refused on playback. The
steps of a recorded script run, and of an inlined macro run, now go through the same policy as
top-level commands; refused ones are left out and listed as skipped.
2026-10-22 02:26:32 -04:00
prxnvt 62cececa63 Docs: debugging macros 2026-10-22 02:26:32 -04:00
prxnvt 06bbcb504a Engine: macro approvals are keyed on the macro's canonical JSON, not a 64-bit hash
Approvals were stored as (document, FNV-1a hash of the macro's JSON).
FNV-1a is not collision resistant, so a crafted workbook could hold a
harmless macro and a different one with the same hash, and approving
the first would let the second run without asking.

The key is now the macro's canonical JSON text itself (object keys
sorted), so two different macros never share an approval.
macros::hash is replaced by macros::fingerprint.
2026-10-22 02:26:32 -04:00
prxnvt 77150be455 Engine: col("Header") is refused on a range without a header row
col("...") always matched against the first row of the range, whatever
its header flag. On an input declared without a header row,
Input.col("Amount") silently picked the first column whose first data
cell read "Amount". After .data, the natural Input.data.col("Amount")
failed, because the first row was then a data row. The header row is
now carried through data, and col("...") is refused with a message when
there is none (an input without a header, a Name: base, or after any
other method).
2026-10-22 02:26:32 -04:00
prxnvt 7d31c5a4ed Engine: a macro in a newer format is dropped with a warning, not saved back as version 1
Opening a workbook accepted a macro whose format version `v` was above MACRO_VERSION. Serde
dropped the fields this build doesn't know, and the next save wrote the reduced macro back
with `v: 2`, so a newer build would read it as a complete version 2 macro.

A version above MACRO_VERSION is now refused by Macro::check_limits, and parse_macro checks
the raw `v` before reading the steps. Such a macro takes the existing per-macro path: it is
left out with the usual single warning (which now names a newer format), and the rest of the
workbook opens. Tests cover JSON and xlsx hidden names, with current and missing versions as
controls.
2026-10-22 02:26:32 -04:00
prxnvt 3f9223c85f Engine: macro recorder inlines a recorded macro run only when its steps stand alone
A macro run while recording was replaced by every command in it, including both branches of an
If and the body of a Repeat, whenever it had no inputs. For example, recording a run of a macro
with an If played back both the then and the else commands without a condition, and commands
using Row, Column or Output failed on playback. The run is now inlined only when the macro has no
inputs, no output and only plain commands (its disabled steps are left out); otherwise it is left
out and listed as skipped.
2026-10-22 02:26:32 -04:00
prxnvt ccea7a8db3 Engine: macro debugger reports a step that panics as the step's failure
When a debugged step panicked, the nested execute rolled the run back and dropped the debugger
before advance could reattach the machine, so the caller got "No macro is being debugged"
instead of the failed step. For example, stepping into a step whose command panics lost the
step path and the panic message. advance now sees that the run was already rolled back and
returns the usual failed result with the step error.
2026-10-22 02:26:32 -04:00
prxnvt b51d588cac Engine: macros can't open, hide or show windows
The policy denies window.* commands, but the View tab's window commands
are view.*: a macro could run view.newWindow, which adds a window that
the run's rollback doesn't remove, or view.hideWindow and
view.unhideWindow, which change the session's hidden windows.

The policy now denies those three. Arrange All changes nothing in the
engine and Switch Windows only lists windows, so both stay allowed.
2026-10-22 02:26:32 -04:00
prxnvt ac7c1ae639 Engine: a reference on a sheet named like a token stays a reference
is_token looked only at the leading identifier, so on a workbook with a
sheet called Output (or Row, Column, or the name of a declared input),
"Output!A1:B2" was read as the Output token, and the step failed with
"the macro has no output anchor" or "unexpected '!A1:B2'". This hit
reference keys, $ref and {{...}} alike, including references the
recorder writes for other sheets. A base followed by '!' is now a sheet
name, and the text resolves as a plain reference.
2026-10-22 02:26:32 -04:00
prxnvt ee7040a340 Docs: macro format and storage 2026-10-22 02:26:32 -04:00
prxnvt c6dda8562f Engine: macro recorder keeps references on other sheets as they are
The recorder made every unqualified reference relative to the example input, whatever sheet the
step ran on. For example, starting a recording on Sheet1 with B2:C5 selected, activating Sheet2
and setting C4 recorded Input.cell(3,2), so playback wrote Sheet1 instead; a formula entered on
Sheet2 was changed to refer to Sheet1's cells. Each step now keeps the sheet it ran on, and only
references on the input's sheet become tokens.
2026-10-22 02:26:32 -04:00
prxnvt 45e580ad91 Engine: macro.debug steps through a macro as one undo step
macro.debug runs a macro one step at a time. start checks the macro as
macro.run does (policy, approval, inputs, footprint and output rule)
and pauses before the first step; step runs one step; continue runs to
the next breakpoint; runTo runs to a chosen step path, stopping at
breakpoints on the way; stop ends the run and keeps its changes or
rolls them back; state reports where it is. Breakpoints are the steps'
own flags plus a list of paths given with any action (breakpointsOnly
makes the list replace the flags), and they stop in every pass of a
Repeat.

The run lives on the session between calls, inside an undo group that
spans them (group_begin and group_end), so a debugged run is one undo
step "Run Macro: <name>", as a normal run is. A failing step ends the
run and rolls it back; so does a Stop step that asks for a rollback.
While a macro is being debugged, edits of the workbooks the run touched
(its own, and any other a step changed) are refused at the top level
with "Finish or stop the macro first"; other workbooks stay editable,
and selecting and queries still work. Every answer gives the next
step's path, kind, command, parameters as they would run now (tokens
resolved), the Repeat pass, the parameters of the step that just ran,
the steps executed and the cells changed so far. Each action gets its
own time limit.

If a command panics while a step runs, the session's panic guard rolls
the debugged run back as well, so its undo group never stays open.
2026-10-22 02:26:32 -04:00
prxnvt bf456c9ff8 Engine: a macro's footprint dry runs keep to the run's timeLimit
footprint always ran its dry run with the default 60 second limit, so
macro.run with timeLimit 5 could run for over two minutes (a dry run,
then another after a shift) and fail with "ran longer than 60 seconds".

footprint now takes the run's clock and limit. make_way starts the
run's clock before the dry runs, so the checks and the run share one
limit, and macro.check takes timeLimit too.
2026-10-22 02:26:32 -04:00
prxnvt 7a0f591712 Docs: macro tokens 2026-10-22 02:26:32 -04:00
prxnvt 709a995290 Model: one damaged macro no longer stops a JSON workbook opening
Workbook::macros was read straight into typed macros, so one entry this
build couldn't read (a step type from a later build, an unknown
onConflict value, an entry that isn't an object) failed the whole file
with "not a GridCraft JSON workbook". The field is now read as raw
entries into Workbook::macros_read, and macros::load reads each one
with parse_macro, as for xlsx: short-form steps are accepted, the depth
limit is checked before deserializing, and a bad entry is dropped with
the same warning. Only the first 1,000 entries are read, as in xlsx.
2026-10-22 02:26:32 -04:00
prxnvt 252ffa95b6 Engine: macro recorder keeps a recording when saving it fails
Stopping a recording took the recorded commands and where recording started before saving the
macro, so a failed save threw both away. For example, stopping with a name that holds a control
character, or in a workbook at its macro limit, ended the recording, and stopping again with a
good name found nothing to save. A failed save now puts the recording back, so stopping again
saves what was recorded.
2026-10-22 02:26:32 -04:00
prxnvt 4c6962a97c Engine: a partial macro footprint no longer clears later writes; the run checks each step
The footprint dry run stops at an approved file step and at Solver, but
make_way took a partial footprint with no conflicts as proof that the
run was safe, also after a shift. A macro that exported a CSV and then
set B1 ran under onConflict fail or ask and overwrote B1 without asking.

When the footprint is partial and the rule is not overwrite, the run's
machine now checks every step as it runs: a step that writes over
cells, sheets or objects that held data when the run started stops the
run and rolls it back, with a confirmation under ask and an error
otherwise. make_way now returns the machine for the run along with
whether it shifted.
2026-10-22 02:26:32 -04:00
prxnvt 3bc1d91636 Engine: macro tokens, resolved to A1 references when a step runs
Step parameters can name where they act through tokens that are worked
out when the step runs, so a macro made on one block of data works on
another: a declared input (Input, or any other input's name), Output
(the output rule's anchor), Row or Column (the current item of the
Repeat step around the step) and Name:Total (a defined name or a
table's data). Methods narrow or move the range: col(n) or
col("Header"), row(n), cols(a..b), rows(2..), cell(r,c) relative to the
top-left cell, offset(r,c), resize(h,w), data (without the header row),
below(n) and right(n). Methods chain.

A token is written as {"$ref": "..."} anywhere, as the whole string of
a reference parameter (cell, range, target, keys ending in Cell or Range
and similar), or as {{...}} inside any string, which is how a formula
refers to its input. Other text parameters are never read as tokens, so
a cell can still hold the word Row and a chart can still be of type
column.

Every token becomes A1 text, sheet-qualified when it is on another
sheet, before the command runs, so no existing command changes. Plain A1
references in a step stay absolute. A reference that runs past the edge
of the sheet is reported apart from other mistakes, so the footprint
check can tell the two apart.
2026-10-22 02:26:32 -04:00
prxnvt 4966b9c86d Engine: opening a file reads its hidden macro names in one bounded pass
load listed every _gc_macro_ item, then read each one with a scan of
all the workbook's names, and removed each with another full scan.
Damaged items didn't count toward the macro limit, so a file with
20,000 damaged items took 20,000 scans of 20,000 names (seconds, and
far more for a larger file). The names are now taken out of the
workbook and grouped by item in one pass; only the first 1,000 items
are read, and the rest are counted in the warning without being read.
2026-10-22 02:26:32 -04:00
prxnvt 749558f978 Docs: recording macros and the management commands; roadmap, architecture and parity 2026-10-22 02:26:32 -04:00
prxnvt d305566e57 Docs: running and checking macros 2026-10-22 02:26:32 -04:00
prxnvt 7011395fcf Engine: a macro renamed on a name clash keeps its name within the length limit
When a file held two macros with the same name, the second was renamed
"name (2)" without room for the suffix. Two macros with the same
255-character name both opened, but the second name grew to 259
characters, was saved that way, and the next open dropped that macro as
over the limits. unique_name now shortens the name to make room for the
suffix, and load checks the macro again after renaming it.
2026-10-22 02:26:32 -04:00
prxnvt b2740bdf33 Engine: macro recorder and macro.list, get, save, delete, rename, record
Commands: macro.list, macro.get, macro.save (a macro object or its
fields; replace defaults to true), macro.delete, macro.rename and
macro.record, beside macro.run, macro.check and macro.debug. Saving,
renaming and deleting are undoable. The catalog gains View › Macros ›
Record Macro (parity 275 / 291).

The recorder builds on the recording buffer of top-level commands, so
nested commands are never recorded twice and trimming the journal loses
nothing. When it stops, commands a macro may not run are left out and
listed; a macro run while recording is replaced by its steps when it
has no inputs, and an Automate script run by the script's steps;
sheet.activate and selection.set steps are added where the sheet or
selection changed between commands without one, and runs of them
collapse to the last. With relative references (the default) the
selection when recording started is the example input: references in
and near it become tokens (Input.col(2), Input.rows(2..),
Input.col(2).below, Input.row(4).right, Input.cell(r,c)), exact
references in formulas become {{...}}, and the input's width, header
and column types are inferred from the example. A recording replayed on
a longer block of data writes in the matching places.

A toggle such as Bold is recorded with the state it set ("on": true),
as Excel records Bold = True, so a replay sets it instead of flipping
it again.

Automate scripts are unchanged: automate.* keep their commands,
parameters, storage and results, and View › Macros still lists the
scripts and a preserved VBA project.
2026-10-22 02:26:32 -04:00
prxnvt 23c0107511 Engine: macro policy checks a step's parameters after tokens resolve; any path counts as file access
A path given as a token (`{"$ref": "Input"}` or `{{Input}}`) became a file name only when the
step ran, after the policy had passed it, so a macro without permissions or approval could write
a file, even from macro.check. Exports also wrote to blank paths the policy ignored. Any `path`
other than null now needs the permission and approval, and the runner checks the policy again on
the resolved parameters before the command runs.
2026-10-22 02:26:32 -04:00
prxnvt 20d2a3b998 Model: a macro name with control characters is refused on every path
Only parse_macro refused a name holding a line break or other control
character. Macros that came from the macros field of a JSON workbook
were checked with check_limits alone, so a file could bring in a macro
named "a\nb" that macro.save and macro.rename would refuse. The check
now lives in Macro::check_limits, so every path applies it, and such a
macro is dropped with the usual warning when the file is opened.
2026-10-22 02:26:32 -04:00
prxnvt 54c08645b9 Engine: macro.run and macro.check: input checks, footprint dry run, policy
macro.run runs a macro as one undo step ("Run Macro: <name>"); an error
at any step rolls everything back and names the step and its path (3,
2.1 inside a Repeat, 4.then.1 inside an If). It works in four parts:

- Policy (macro_policy). File, app, window, automate and macro commands
  and Undo and Redo never run in a macro. Reading or writing files
  (data.getData, data.fromTextCsv, insert.picture, Page Layout
  Background, or an export with a path) needs the command in the
  macro's permissions and the person's approval. A macro in a workbook
  opened from a file asks before it runs. Approval holds for the
  session until the macro's content changes; it is asked for with the
  new EngineError::Approve and given with approve: true. A step that
  would open a dialog or a web page fails instead.
- Inputs. Each declared input is bound (given, its default, or the
  selection for the first one), whole columns shrink to the used range,
  and the shape and column types are checked with messages that name
  the cells: "Input column 2 (Amount) must hold numbers; C4 holds text".
- Footprint. Unless the macro may overwrite, it first runs in dry-run
  mode on the real workbook inside an undo group that is rolled back,
  with the session's clipboard, journal, windows and the document's
  trust put back. CellStore::diff on every sheet, plus the cells
  commands wrote (DocState::touched), sheets and objects, gives what it
  would write outside its inputs; cells that only recalculated do not
  count. Writing over data, deleting a sheet or deleting an object is a
  conflict: ask (the new EngineError::Confirm, so programmatic runs fail
  unless they pass onConflict or overwrite), fail, overwrite, or shift
  the data down or right with Insert Cells and check again. A step that
  would fail, for example past the edge of the sheet, fails the run
  before anything changes. File steps and Solver stop the dry run early
  and are reported.
- Steps run on a machine with an explicit frame stack: Repeat over the
  rows or columns of a range with Row or Column bound, If on one cell
  with Excel's comparisons, and Stop, which keeps the changes or rolls
  them back. A run executes at most 100,000 steps within a time limit
  (60 s by default, timeLimit to change; the web build has no clock, so
  only the step limit applies there).

macro.check reports the bound inputs, the output anchor, the approval
needed, the footprint and the conflicts without changing anything;
nothing from an unapproved macro runs, not even as a dry run.
2026-10-22 02:26:32 -04:00
prxnvt a4b4872602 Model: macros as typed command lists, saved in the workbook
A macro is plain data in the model (model/src/macros.rs): a name, a
description, an optional shortcut, declared inputs (shape, header,
column types, non-empty, default reference), steps, an output rule
(anchor and what to do on a conflict) and the commands it may run that
read or write files. A step runs a command (id and params) or is one of
three bounded control steps: Repeat over the rows or columns of a range,
If on one cell's value, and Stop with a message. Every step carries
enabled, breakpoint and note flags. These are GridCraft macros, not VBA;
a VBA project in a file stays preserved and is never run.

Workbook::macros holds them. The JSON format keeps the field. In xlsx,
io::save_bytes writes each macro as one chunked hidden-name item,
_gc_macro_<n>, and io::open_bytes takes the items back out of the names,
so the names never linger in the open document.

Limits are checked whenever a macro is read from a file or a command:
at most 1,000 macros, 10,000 steps each, Repeat and If nested 4 deep,
names and notes of bounded length, and at most 4,194,304 characters of
JSON, so that saving a workbook never fails because of a macro; nesting
is checked before the steps are deserialized. What is over or damaged
is dropped with one warning, and clashing names get a suffix.

Automate scripts keep their own storage (_sc_scripts) and are neither
read nor changed by this.
2026-10-22 02:26:32 -04:00
prxnvt 471114e296 Engine: a visible name of the item shape is not workbook data
The item store read, listed and removed every workbook-scope name of the `_gc_<key>_<n>` shape,
visible or not, so saving an item removed a person's own visible name that happened to look like
one. Items are the hidden names the store writes; a legacy `_sc_<key>` name still reads. A visible
`_gc_` name is left alone by every operation, and writing an item that would need that name is
refused before anything changes: a hidden name beside it with the same name is the one an xlsx
file drops, so the item would be lost on save.
2026-10-22 02:24:55 -04:00
prxnvt 2287f63a95 Engine: a group rolled back inside a command keeps the redo history
A command that changed the workbook cleared the redo history even when an open group owned
its step. When the group was then rolled back inside the same command (the debugger's Stop
with Undo Changes), the rollback put the redo history back and the command's bookkeeping cleared
it again. Now only a command that makes a step of its own clears the redo history; a group
clears it when it ends as a step, and a rollback restores it.
2026-10-22 02:00:54 -04:00
prxnvt eb3eaa052d Engine: a command keeps its group's name only for a group it ended itself
The record of the last group step (document, history length, epoch) outlived the Undo of that
step. A later command that opened and closed its own groups at the same history length, after
rolling one back, passed the epoch check (the record's epoch merely differed from the one the
command started with) and took the name of the nested edit it had made instead of its own.

The step must now come from a group ended during the command (an epoch past the command's
starting one), and a rollback drops a record of a step it truncated away.
2026-10-22 01:36:01 -04:00
prxnvt 0457f102a0 Engine: pasting only formats from a file leaves the destination's trust alone
Paste Special › Formats copies cell styles and merged areas. It copies no conditional formats,
formulas or validation, so it carries nothing that runs. It still marked the destination
FromFile: copying a formula cell from an opened file and pasting its formats into a new workbook
made that workbook ask before running its own active content.

Formats pastes now count as passive, like values, column widths and notes. The comment and
docs/architecture.md no longer say that pasted formats carry trust.
2026-10-21 17:27:20 -04:00
prxnvt ab27d35386 Engine: a full undo history keeps one step per command that runs its own undo groups
When the history held 100 steps, group_end pushed its step and dropped the oldest one at once,
so execute's collapse compared against a length that no longer matched. A command that was one
group kept the group's step and added its own, losing two old steps. After test.editThenGroup,
the second Undo brought back the intermediate state after the outside edit.

The history limit now applies after the outermost command has collapsed its steps, and when a
group ends outside any command. A document with an open group is trimmed when the group ends.
2026-10-21 17:25:45 -04:00
prxnvt f4e05534a3 Engine: a command that runs its own undo groups makes one undo step for itself
execute treated any change in the group epoch as 'the group owns the step'. A command that edited outside its own group, or whose group rolled back before it edited, left that edit with no undo step or only part of it. Now only a group that was open before the command (or is still open after) owns the step; a command that opened and closed its own groups replaces their steps with one entry for the command.
2026-10-21 15:23:08 -04:00
prxnvt 83ae63f793 Engine: a stash item with only later chunks reads as missing and falls back to the legacy name
With chunk _gc_k_2 but no _gc_k_0, get returned Some("") and never looked at _sc_k. It now takes the legacy path unless chunk 0 exists.
2026-10-21 15:18:15 -04:00
prxnvt e165eaf3a6 Engine: a linked paste of values from a file carries the file's trust
A linked paste writes formulas even when it pastes values, but the values exception skipped the trust update, so a Local workbook stayed Local after linking to a FromFile one. The exception now applies only to unlinked pastes.
2026-10-21 15:18:15 -04:00
prxnvt 7d1fee8c78 Engine: workbook data in chunked hidden names (cmd/stash.rs)
GridCraft keeps workbook data in hidden defined names so it travels
inside the xlsx file. The existing items write one name holding one
string literal, which past Excel's 255-character literal and
8,192-character formula limits gives a file Excel does not accept.

cmd/stash.rs stores an item `key` as hidden workbook-scope names
_gc_<key>_0, _gc_<key>_1, ..., each a formula of string literals of at
most 255 characters (UTF-16 units, as Excel counts them) joined with &,
at most 8,192 characters per name. Reading concatenates the chunks in
order and stops at a gap; it also accepts the older single _sc_<key>
name. Names are matched case-insensitively and only at workbook scope.
Keys are limited to letters, digits, _ and ., items to 4,194,304
characters, and junk around or inside the literals, duplicate or
out-of-order chunks, non-JSON and over-deep JSON all read safely.
keys(prefix) lists the stored items that start with a prefix.

Custom views, scripts, the watch list and scenarios keep their current
storage; nothing reads or writes the new names yet.

docs/architecture.md describes the engine building blocks added in this
series: undo groups, the recording buffer, document trust and this
storage.
2026-10-21 14:05:48 -04:00
prxnvt b7bce3bc09 Engine: document trust for workbooks opened from files
Active content such as macros must not run only because a workbook was
opened. DocState gets a trust state: Local for workbooks made in the
session (new workbooks and samples) and FromFile for anything opened
with file.open. Active content in a FromFile workbook asks before it
runs. A second window on a workbook shares its trust.

The clipboard carries the trust of its source document. Pasting
formulas, formats (conditional formats hold formulas) or validation
from a FromFile workbook into another workbook makes that workbook
FromFile too; pasting values, column widths or notes carries nothing
active and changes nothing. Sheet moves and copies stay inside one
workbook, so its trust is unchanged. document.inspect reports the trust.
2026-10-21 14:05:48 -04:00
prxnvt d4f1e61c99 Engine: a recording buffer of top-level commands, apart from the journal
A macro recorder needs more than a journal index: the journal is
trimmed past 10,000 entries, it also holds the commands that other
commands run (so playback would run them twice), and it keeps no
selection, which relative references need.

Session now tracks how deeply execute calls are nested. While recording,
a buffer (record.rs) keeps each successful, journaled top-level call with
the document uid, the active sheet (index and name) and the selection it
ran in, then its id and parameters without secrets, as the journal
stores them. Queries, failed commands and the command that starts
recording are not kept. The buffer holds up to 100,000 steps and counts
any it drops. Session::start_recording, stop_recording, is_recording and
depth are the interface.

Automate › Record Actions keeps recording through the journal as before.
2026-10-21 14:05:41 -04:00
prxnvt acfd38f309 Engine: undo groups make several commands one undo step
Session::grouped(label, f) runs f as one undo step on the active
document. On success the group leaves exactly one undo entry holding the
workbook from before the group; on error it restores the workbook,
selection, undo and redo stacks as they were and returns the error.
group_begin, group_end and group_rollback do the same across separate
calls, for an action that runs one step per call.

While a group is open on a document, undoable commands on it add no
undo steps of their own, so a long group neither grows the history nor
pushes older entries past the undo limit, and Undo and Redo are
disabled there. Groups nest: the outermost one makes the step, and a
failing inner group rolls back only its own changes. Closing a document
drops its groups, and a command that panics drops the groups it opened.
history.list reports whether a group is open.

Insert Copied Cells folds its nested steps into one undo step by
counting revisions; inside a group the nested steps push no entries, so
it now leaves the history alone there instead of removing entries from
before the group.
2026-10-21 14:05:41 -04:00
Gert Pellin 9d9911dca1 Merge branch 'main' into fix/1419-clear-skips-filtered-rows 2026-10-15 22:25:42 +02:00
Gert Pellin d280e6ff00 Clear: Delete and the Clear commands skip the rows a filter hides (#1419)
As in Excel, clearing a filtered selection leaves the rows the filter
hides as they are; rows hidden by hand are still cleared. Checked boxes
in hidden rows no longer hold back Delete on the boxes showing.
2026-10-15 04:44:22 +02:00
132 changed files with 17724 additions and 363 deletions
+2 -2
View File
@@ -19,8 +19,8 @@ permissions:
jobs:
windows-arm64:
# Immutable supporting workflow revision until the new install contract lands upstream.
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main
with:
app-name: gridcraft
app-dir: GridCraft
+5
View File
@@ -70,3 +70,8 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
GitHub profiles). Never hand-edit `contributors.json`.
- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
re-verifies who wrote each `people.toml` entry; `--check` only verifies).
## CI workflows
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
+5
View File
@@ -51,3 +51,8 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
## Roadmap
`ROADMAP.md` (committed) is the one-page summary: stage, headline numbers, dimensions, languages, progress log. It follows craftrules `standards/progress-docs.md` and points at the detail in `docs/`: `target-app-parity.md` (authoritative assessment), `gaps.md` (ranked work list: pick from it), `roadmap.md` (current focus, beta gates, milestones), `architecture.md`, and the area checklists `function-parity.md`, `chart-parity.md`, `file-format-parity.md`, `ui-parity.md`, `hardware-parity.md`, `localization-parity.md`. Update the affected docs (and their timestamp lines and revision history) whenever work lands. `cargo xtask parity` regenerates `docs/parity-checklist.md` from `crates/engine/src/catalog.rs`.
## CI workflows
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
Generated
+18 -17
View File
@@ -950,13 +950,14 @@ dependencies = [
[[package]]
name = "craft-update"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=04aa1703b32d299e54f78fcde4182f2b393a9116#04aa1703b32d299e54f78fcde4182f2b393a9116"
dependencies = [
"chrono",
"dunce",
"egui",
"flate2",
"getrandom 0.3.4",
"image",
"log",
"plist",
"semver",
@@ -1935,7 +1936,7 @@ dependencies = [
[[package]]
name = "gridcraft"
version = "0.13.0"
version = "0.14.2"
dependencies = [
"arboard",
"chrono",
@@ -1962,7 +1963,7 @@ dependencies = [
[[package]]
name = "gridcraft-calc"
version = "0.13.0"
version = "0.14.2"
dependencies = [
"gridcraft-core",
"gridcraft-formula",
@@ -1974,7 +1975,7 @@ dependencies = [
[[package]]
name = "gridcraft-chart"
version = "0.13.0"
version = "0.14.2"
dependencies = [
"gridcraft-calc",
"gridcraft-core",
@@ -1985,7 +1986,7 @@ dependencies = [
[[package]]
name = "gridcraft-cli"
version = "0.13.0"
version = "0.14.2"
dependencies = [
"gridcraft-engine",
"gridcraft-mcp",
@@ -1997,7 +1998,7 @@ dependencies = [
[[package]]
name = "gridcraft-core"
version = "0.13.0"
version = "0.14.2"
dependencies = [
"chrono",
"js-sys",
@@ -2008,7 +2009,7 @@ dependencies = [
[[package]]
name = "gridcraft-engine"
version = "0.13.0"
version = "0.14.2"
dependencies = [
"gridcraft-calc",
"gridcraft-chart",
@@ -2030,7 +2031,7 @@ dependencies = [
[[package]]
name = "gridcraft-formula"
version = "0.13.0"
version = "0.14.2"
dependencies = [
"gridcraft-core",
"serde",
@@ -2039,7 +2040,7 @@ dependencies = [
[[package]]
name = "gridcraft-functions"
version = "0.13.0"
version = "0.14.2"
dependencies = [
"gridcraft-core",
"regex",
@@ -2047,7 +2048,7 @@ dependencies = [
[[package]]
name = "gridcraft-mcp"
version = "0.13.0"
version = "0.14.2"
dependencies = [
"gridcraft-engine",
"log",
@@ -2056,7 +2057,7 @@ dependencies = [
[[package]]
name = "gridcraft-model"
version = "0.13.0"
version = "0.14.2"
dependencies = [
"gridcraft-core",
"gridcraft-formula",
@@ -2068,18 +2069,18 @@ dependencies = [
[[package]]
name = "gridcraft-numfmt"
version = "0.13.0"
version = "0.14.2"
dependencies = [
"gridcraft-core",
]
[[package]]
name = "gridcraft-pdf"
version = "0.13.0"
version = "0.14.2"
[[package]]
name = "gridcraft-ui-egui"
version = "0.13.0"
version = "0.14.2"
dependencies = [
"craft-ui",
"craft-update",
@@ -2104,7 +2105,7 @@ dependencies = [
[[package]]
name = "gridcraft-web"
version = "0.13.0"
version = "0.14.2"
dependencies = [
"eframe",
"egui",
@@ -2121,7 +2122,7 @@ dependencies = [
[[package]]
name = "gridcraft-xlsx"
version = "0.13.0"
version = "0.14.2"
dependencies = [
"aes",
"gridcraft-core",
@@ -6402,7 +6403,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.13.0"
version = "0.14.2"
dependencies = [
"gridcraft-engine",
"serde_json",
+3 -2
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/gridcraft", "apps/gridcraft-cli", "xtask"]
[workspace.package]
version = "0.13.0"
version = "0.14.2"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -26,7 +26,8 @@ gridcraft-mcp = { path = "crates/mcp" }
gridcraft-pdf = { path = "crates/pdf" }
# Every craft-libs crate (craft-update and craft-window here, craft-ui in ui-egui, craft-print in
# the app) comes from one storytold/craft-libs revision.
craft-update = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", features = ["egui"] }
# craft-update: app-specific pretend versions apply to startup and manual checks.
craft-update = { git = "https://github.com/storytold/craft-libs", rev = "04aa1703b32d299e54f78fcde4182f2b393a9116", version = "=0.1.0", features = ["egui"] }
# foundation
thiserror = "2"
+7 -3
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** minor (AutoSum over a selected row puts its total to the right, and fills a selection's empty last row and column) · **Target:** Microsoft Excel (Microsoft 365)
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** minor (AutoSum over a selected row puts its total to the right, and fills a selection's empty last row and column; earlier: macro engine and `macro.*` commands; macro interface on the Automate tab) · **Target:** Microsoft Excel (Microsoft 365)
GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX
is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web.
@@ -22,7 +22,7 @@ This page is the summary. The detail is in:
| Number | Value | Kind |
|---|---|---|
| 2026-10-14 | Editing a cell with text that would read back as a number, boolean, date or formula (entered with a leading apostrophe) shows the apostrophe in the editor and formula bar, so Enter unchanged keeps it text (#1362) |
| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 274 / 290 (94.5%), worksheet functions 513 / 523 (98.1%; 504 working, 96.4%) |
| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 276 / 292 (94.5%), worksheet functions 513 / 523 (98.1%; 504 working, 96.4%) |
| **Ready for real work** | **~50%** | estimated from written weights (51.6); [target-app-parity.md](docs/target-app-parity.md) |
| **Mainstream practitioner** | **~50%** | estimated: typical analyst/finance user, weekly features, × 0.90 interaction × 0.92 stability × 0.88 file exchange; [method](docs/target-app-parity.md#mainstream-practitioner) |
| **Essentials user** | **~63%** | estimated: core features only, × 0.92 launch/stability × 0.95 clarity × 0.90 opening sent files; [method](docs/target-app-parity.md#essentials-user) |
@@ -86,7 +86,7 @@ collaboration, mobile, AI).
| Get & Transform, external data, data model | ~15% | ~10% | 60–100 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) |
| Insert objects (pictures, shapes, ink, SmartArt, equations) | ~55% | ~45% | 25–40 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) |
| Review, comments, protection, collaboration | ~55% | ~40% | 35–55 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) |
| Automation (recorded scripts; no VBA) | ~30% | ~20% | 12–20 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) |
| Automation (recorded scripts; typed macros with a recorder, a Macros dialog and a step editor; no VBA) | ~30% | ~20% | 12–20 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) |
## Languages
@@ -136,6 +136,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | What landed |
|---|---|
| 2026-10-25 | AutoSum over a selection does what Excel does: a selected row gets one total to its right (in its last cell if that is empty), not a one-cell total under each cell; a block whose last column is empty gets each row's total there, one whose last row is empty each column's, and with both the corner gets the total of the block; otherwise totals go under each column as before |
| 2026-10-24 | Macro interface ([macros.md](docs/macros.md#in-the-window)): an Automate › Macros group (Record Macro, which turns into Stop Recording, Use Relative References, Run Macro with recent macros); "Recording · N steps" with Stop in the status bar; a Record Macro dialog (checked name, free Ctrl+Shift shortcut, description); the Macros dialog lists macros beside Automate scripts and a VBA project, with Run, Step Into, Edit, Create, Delete and Options; Ctrl+Shift+letter macro shortcuts; questions that offer Run, Overwrite, Shift Down or Shift Right; a step editor with a breakpoint gutter, drag to reorder, a command picker, reference boxes that switch between relative tokens and absolute references, Inputs and Output tabs, a debug bar (Check, Run, Step, Continue, Run to Here, Stop with keep or undo) and grid overlays for inputs, writes, conflicts and the current step |
| 2026-10-24 | Go To lists the named ranges of every sheet (`Sheet2!Name` for a name scoped to another sheet; hidden names, constants and formulas left out) and the references entered before, a double-click goes to an entry, and the Reference box takes several names or references separated by commas (`Price, Type`, `B14:C22,F19:G30`) |
| 2026-10-24 | Status bar: a right-click opens a menu that sets which selection statistics show (Average, Count and Sum by default; Numerical Count, Minimum and Maximum are new), kept with the preferences (`view.statusBar`) |
| 2026-10-24 | Ctrl+Shift+U (Control+Shift+U on a Mac) expands and collapses the formula bar, on the grid and while editing (`view.expandFormulaBar`); on Windows and Linux it used to underline |
@@ -179,6 +180,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| 2026-10-13 | XLSX: `[Content_Types].xml`, `_rels/.rels`, the workbook rels and `xl/workbook.xml` are the first zip entries (document properties last), so libmagic and PHP `finfo` detect the file type even with large properties (#1090) |
| 2026-10-13 | Conditional formatting: a rule gathers range statistics only when it reads them (top/bottom, above average, duplicates, scales, bars, icons) and borrows them instead of copying them per cell, so the per-frame lookup of an expression or cell-value rule no longer visits its whole range (#1098, part 1) |
| 2026-10-12 | Next/previous sheet navigation stops at the first/last visible worksheet instead of activating a hidden boundary sheet; the current selection is retained |
| 2026-10-21 | Macro engine ([macros.md](docs/macros.md)): typed, declarative macros saved in the workbook (a JSON field; hidden names in xlsx); declared inputs checked with messages that name the cells; tokens (`Input.col(2)`, `Row`, `Output`, `Name:Total`, `{{…}}` in formulas); one undo step with full rollback; a footprint dry run with ask, fail, overwrite, shift down or shift right; a policy that keeps macros away from files and the app, with approval for macros in opened files; Repeat, If and Stop steps; a debugger; a relative recorder; `macro.*` commands beside the unchanged Automate scripts |
| 2026-10-12 | Security: the `--control` server requires an auth token (random, kept in a 0600 file or chosen with `GRIDCRAFT_CONTROL_TOKEN`) on each connection's first line and closes bad connections (#777) |
| 2026-10-12 | Rows auto-fit oversized fonts: opening a file and whole-row/column formatting grow non-custom rows to their tallest font (#833) |
| 2026-10-12 | Number formats: parsing a format code is linear in its length (long runs of `[`, `,` or `/` no longer take quadratic time), and an unclosed `[` is a literal when splitting sections as well as when tokenizing (#810) |
@@ -243,6 +245,8 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | Change | Summary |
|---|---|---|
| 2026-10-25 | minor | Progress log: AutoSum over a selected row, and a selection's empty last row and column |
| 2026-10-24 | minor | Macro interface on the Automate tab; progress log row |
| 2026-10-24 | minor | Macro engine and `macro.*` commands; catalog 275 / 291 → 276 / 292 (View › Macros › Record Macro); progress log row |
| 2026-10-23 | minor | Progress log: accounting negatives in number text |
| 2026-10-23 | minor | Progress log: INDEX with one index on a 2-D reference is #REF! |
| 2026-10-22 | trivial | Progress log: formula hardening (#2163, #2162) |
+3 -2
View File
@@ -230,8 +230,9 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {
&& let gridcraft_engine::links::LinkKind::External(url) = gridcraft_engine::links::classify(url)
{
// With `noopener` the new browsing context can't hold a reference back to the
// editor window and navigate it while the user reads the linked page.
let _ = w.open_with_url_and_target(&url, "_blank,noopener");
// editor window and navigate it while the user reads the linked page. It must be a
// window *feature*: inside the target string it would only name the new window.
let _ = w.open_with_url_and_target_and_features(&url, "_blank", "noopener,noreferrer");
}
})),
inbox: Some(inbox),
+164 -15
View File
@@ -12,16 +12,47 @@
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::mpsc::{Receiver, Sender, channel};
use std::time::Duration;
use std::time::{Duration, Instant};
use gridcraft_mcp::control_auth;
use gridcraft_ui_egui::ControlRequest;
use serde_json::{Value as Json, json};
const MAX_LINE: usize = 64 * 1024 * 1024;
/// How long a new connection may take to send its token.
/// How long a new connection may take to send its token, in total.
const AUTH_TIMEOUT: Duration = Duration::from_secs(10);
/// How many connections may be waiting to authenticate at once. Anyone on the machine can
/// connect without the token, so past this a new connection is closed before anything is read:
/// each waiting one holds a thread and a socket.
const MAX_PENDING_AUTH: usize = 16;
/// How the server treats connections that haven't authenticated yet.
#[derive(Clone, Copy)]
struct AuthLimits {
timeout: Duration,
max_pending: usize,
}
const AUTH_LIMITS: AuthLimits = AuthLimits { timeout: AUTH_TIMEOUT, max_pending: MAX_PENDING_AUTH };
/// A connection that is still waiting to authenticate; dropping it frees its place.
struct Pending(Arc<AtomicUsize>);
impl Pending {
/// A place among the `max` waiting connections, or `None` when they are all taken.
fn claim(waiting: &Arc<AtomicUsize>, max: usize) -> Option<Pending> {
waiting.try_update(Ordering::AcqRel, Ordering::Acquire, |n| (n < max).then_some(n + 1)).ok()?;
Some(Pending(Arc::clone(waiting)))
}
}
impl Drop for Pending {
fn drop(&mut self) {
self.0.fetch_sub(1, Ordering::AcqRel);
}
}
/// The secret of this run: the one the user chose (`--control-token`, `GRIDCRAFT_CONTROL_TOKEN`),
/// else 256 random bits from the operating system, written to a file only the user can read.
@@ -51,35 +82,75 @@ pub fn start(port: u16, chosen_token: Option<String>, ctx: egui::Context) -> std
/// Accept connections on `listener`; requests arrive on the returned receiver.
fn serve_listener(listener: TcpListener, token: Arc<str>, ctx: egui::Context) -> std::io::Result<Receiver<ControlRequest>> {
serve_listener_with(listener, token, ctx, AUTH_LIMITS)
}
fn serve_listener_with(listener: TcpListener, token: Arc<str>, ctx: egui::Context, limits: AuthLimits) -> std::io::Result<Receiver<ControlRequest>> {
let (tx, rx) = channel();
let waiting = Arc::new(AtomicUsize::new(0));
std::thread::Builder::new().name("control".into()).spawn(move || {
for stream in listener.incoming().flatten() {
for stream in listener.incoming() {
let Ok(stream) = stream else {
// Out of sockets or handles: wait for some to free instead of spinning on the error.
std::thread::sleep(Duration::from_millis(50));
continue;
};
// Dropping the stream closes it before anything is read.
let Some(place) = Pending::claim(&waiting, limits.max_pending) else { continue };
let tx = tx.clone();
let ctx = ctx.clone();
let token = Arc::clone(&token);
let _ = std::thread::Builder::new().name("control-conn".into()).spawn(move || serve(stream, &token, tx, ctx));
let _ =
std::thread::Builder::new().name("control-conn".into()).spawn(move || serve(stream, &token, tx, ctx, limits.timeout, Some(place)));
}
})?;
Ok(rx)
}
/// Read the connection's first line (at most [`control_auth::MAX_AUTH_LINE`] bytes, within
/// [`AUTH_TIMEOUT`]) and check the token. `true` when the peer may go on.
fn authenticate(reader: &mut BufReader<TcpStream>, out: &mut TcpStream, token: &str) -> bool {
let _ = out.set_read_timeout(Some(AUTH_TIMEOUT));
let mut first = String::new();
let read = reader.by_ref().take(control_auth::MAX_AUTH_LINE as u64).read_line(&mut first);
let verdict = match read {
Ok(n) if n > 0 && first.ends_with('\n') => control_auth::check_first_line(&first, token),
/// `timeout` in total) and check the token. `true` when the peer may go on.
#[allow(clippy::disallowed_methods)] // the desktop app (native only): wasm never runs it
fn authenticate(reader: &mut BufReader<TcpStream>, out: &mut TcpStream, token: &str, timeout: Duration) -> bool {
let deadline = Instant::now().checked_add(timeout).unwrap_or_else(Instant::now);
let mut first = Vec::new();
let read = read_auth_line(reader, &mut first, deadline);
let verdict = match (read, std::str::from_utf8(&first)) {
(Ok(()), Ok(line)) if line.ends_with('\n') => control_auth::check_first_line(line, token),
_ => Err(json!({"ok": false, "error": "authentication required"})),
};
let allowed = verdict.is_ok();
let reply = verdict.unwrap_or_else(|e| e);
let _ = writeln!(out, "{reply}");
let _ = out.set_read_timeout(None);
let _ = reader.get_ref().set_read_timeout(None);
allowed
}
/// Read the first line, at most [`control_auth::MAX_AUTH_LINE`] bytes, by `deadline`.
///
/// The timeout goes on the handle the reader reads: on Windows a `try_clone` of the stream keeps
/// its own, so one set on the writing half never fired (a silent peer held its thread forever).
/// It is shortened before every read, so a peer sending one byte at a time can't stretch it.
#[allow(clippy::disallowed_methods)] // the desktop app (native only): wasm never runs it
fn read_auth_line(reader: &mut BufReader<TcpStream>, line: &mut Vec<u8>, deadline: Instant) -> std::io::Result<()> {
let max = control_auth::MAX_AUTH_LINE;
while line.len() < max && line.last() != Some(&b'\n') {
let left = deadline.saturating_duration_since(Instant::now());
if left.is_zero() {
return Err(std::io::ErrorKind::TimedOut.into());
}
reader.get_ref().set_read_timeout(Some(left))?;
let buf = reader.fill_buf()?;
if buf.is_empty() {
break;
}
let room = max.saturating_sub(line.len());
let take = buf.iter().take(room).position(|b| *b == b'\n').map_or(buf.len().min(room), |i| i + 1);
line.extend(buf.iter().take(take));
reader.consume(take);
}
Ok(())
}
/// Read one `\n`-terminated line of at most `max` bytes into `buf` (newline stripped).
/// `Ok(false)` at end of stream; `Err` when the line is too long or the read fails.
fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -> Result<bool, String> {
@@ -148,11 +219,14 @@ fn is_http_request_line(text: &str) -> bool {
|| text.rsplit_once(' ').is_some_and(|(_, version)| version.starts_with("HTTP/"))
}
fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context) {
fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context, auth_timeout: Duration, place: Option<Pending>) {
let Ok(read) = stream.try_clone() else { return };
let mut out = stream;
let mut reader = BufReader::new(read);
if !authenticate(&mut reader, &mut out, token) {
let allowed = authenticate(&mut reader, &mut out, token, auth_timeout);
// Authenticated or refused, the connection no longer counts as waiting.
drop(place);
if !allowed {
return;
}
let mut buf = Vec::new();
@@ -294,7 +368,7 @@ mod tests {
std::thread::spawn(move || {
for stream in listener.incoming().flatten() {
let tx = tx.clone();
std::thread::spawn(move || serve(stream, TOKEN, tx, egui::Context::default()));
std::thread::spawn(move || serve(stream, TOKEN, tx, egui::Context::default(), AUTH_TIMEOUT, None));
}
});
addr
@@ -346,6 +420,81 @@ mod tests {
assert_eq!(got[1]["ok"], json!(false));
}
/// Whether the server closed `c` (end of stream, or a refusal and then end of stream) within
/// `within`, and how long it took.
#[allow(clippy::disallowed_methods)] // a native-only test of the desktop app
fn closed_within(c: TcpStream, within: Duration) -> (bool, Duration) {
let start = Instant::now();
c.set_read_timeout(Some(within)).unwrap();
let mut r = BufReader::new(c);
let mut line = String::new();
loop {
line.clear();
match r.read_line(&mut line) {
Ok(0) => return (true, start.elapsed()),
Ok(_) => continue,
Err(e) if matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) => return (false, start.elapsed()),
Err(_) => return (true, start.elapsed()),
}
}
}
/// The token deadline is for the whole first line: neither a silent peer nor one sending a
/// byte at a time keeps the connection (and its thread) past it.
#[test]
fn a_silent_or_slow_peer_is_closed_at_the_auth_deadline() {
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let addr = listener.local_addr().unwrap();
let limits = AuthLimits { timeout: Duration::from_millis(300), max_pending: MAX_PENDING_AUTH };
let _rx = serve_listener_with(listener, TOKEN.into(), egui::Context::default(), limits).unwrap();
let silent = TcpStream::connect(addr).unwrap();
let mut slow = TcpStream::connect(addr).unwrap();
let mut drip = slow.try_clone().unwrap();
std::thread::spawn(move || {
for b in control_auth::auth_line(1, TOKEN).bytes() {
if drip.write_all(&[b]).is_err() {
break;
}
std::thread::sleep(Duration::from_millis(50));
}
});
let (closed, took) = closed_within(silent, Duration::from_secs(5));
assert!(closed && took < Duration::from_secs(3), "silent peer kept for {took:?}");
let (closed, took) = closed_within(slow.try_clone().unwrap(), Duration::from_secs(5));
assert!(closed && took < Duration::from_secs(3), "byte-at-a-time peer kept for {took:?}");
let _ = slow.flush();
}
/// Peers without the token can't pile up threads: past the cap a new connection is closed at
/// once, and a waiting one that authenticates frees its place.
#[test]
fn connections_waiting_to_authenticate_are_capped() {
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let addr = listener.local_addr().unwrap();
let limits = AuthLimits { timeout: Duration::from_secs(20), max_pending: 2 };
let _rx = serve_listener_with(listener, TOKEN.into(), egui::Context::default(), limits).unwrap();
let mut first = TcpStream::connect(addr).unwrap();
let _second = TcpStream::connect(addr).unwrap();
std::thread::sleep(Duration::from_millis(200));
let (closed, took) = closed_within(TcpStream::connect(addr).unwrap(), Duration::from_secs(5));
assert!(closed && took < Duration::from_secs(3), "a third waiting connection was kept");
first.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
first.write_all(format!("{}\n", control_auth::auth_line(1, TOKEN)).as_bytes()).unwrap();
let mut reply = String::new();
BufReader::new(first.try_clone().unwrap()).read_line(&mut reply).unwrap();
assert_eq!(serde_json::from_str::<Json>(&reply).unwrap()["ok"], json!(true), "{reply}");
std::thread::sleep(Duration::from_millis(200));
let mut next = TcpStream::connect(addr).unwrap();
next.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
next.write_all(format!("{}\n", control_auth::auth_line(1, TOKEN)).as_bytes()).unwrap();
let mut reply = String::new();
BufReader::new(next).read_line(&mut reply).unwrap();
assert_eq!(serde_json::from_str::<Json>(&reply).unwrap()["ok"], json!(true), "a freed place was not reused: {reply}");
}
#[test]
fn a_chosen_token_must_be_long_enough() {
assert!(make_token(1, Some("short".into())).is_err());
+6 -5
View File
@@ -256,7 +256,7 @@ fn main() -> eframe::Result<()> {
}));
let args: Vec<String> = std::env::args().skip(1).collect();
// The restart helper a self-update spawns: it finishes the swap and relaunches, then exits.
if let Some(done) = craft_update::self_update::finish(&craft_update::suite::GRIDCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
if let Some(done) = craft_update::self_update::finish(&gridcraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
if let Err(e) = done {
eprintln!("GridCraft update helper failed: {e}");
std::process::exit(1);
@@ -311,15 +311,16 @@ fn main() -> eframe::Result<()> {
{
log::warn!("sample {s}: {e}");
}
if session.documents().is_empty() {
session.new_workbook();
}
let mut app = SheetApp::new(session, services());
app.watch_desktop_theme();
app.start_update_check();
app.after_engine(); // Show warnings from the sample.
let control_port = control_port;
load_prefs(&mut app);
// Stamp the startup workbook's author from the persisted user name.
if app.session.documents().is_empty() {
app.session.new_workbook();
}
app.after_engine(); // Show warnings from the sample.
// Files named on the command line (a double-click on Windows and Linux) open as File › Open
// opens them: a password-protected one asks for its password, and errors are shown, not
// only logged. Each replaces the untouched blank Book1, as a file opened from Finder does.
+8 -7
View File
@@ -390,11 +390,12 @@ impl NativeMenu {
#[cfg(test)]
mod tests {
use super::*;
// No `use super::*`: tests/mac_menu_appkit.rs builds this file with cfg(test) but no test
// harness, so the #[test] functions are left out and a module-level import would be unused.
#[test]
fn file_menu_has_export_as_pdf_dispatching_the_command() {
let tree = tree();
let tree = super::tree();
let (_, file) = tree.iter().find(|(title, _)| *title == "File").expect("File menu");
let item = file.iter().find(|e| e.0 == "Export as PDF…").expect("Export as PDF… in the File menu");
assert_eq!(item.1, "file.exportPdf");
@@ -403,23 +404,23 @@ mod tests {
#[test]
fn help_menu_checks_for_updates_through_the_registered_command() {
let tree = tree();
let tree = super::tree();
let (_, help) = tree.iter().find(|(title, _)| *title == "Help").expect("Help menu");
let item = help.iter().find(|e| e.0 == "Check for Updates…").expect("Check for Updates… in the Help menu");
assert_eq!(item.1, "app.checkForUpdates");
let mut app = SheetApp::new(gridcraft_engine::Session::new(), Default::default());
let mut app = super::SheetApp::new(gridcraft_engine::Session::new(), Default::default());
assert!(app.run(item.1, serde_json::json!({})).is_ok());
}
#[test]
fn theme_choices_use_the_shared_preference_command() {
let mut app = SheetApp::new(gridcraft_engine::Session::new(), Default::default());
let mut app = super::SheetApp::new(gridcraft_engine::Session::new(), Default::default());
for mode in ["dark", "system", "light"] {
assert!(select_theme(&mut app, &format!("theme:{mode}")));
assert!(super::select_theme(&mut app, &format!("theme:{mode}")));
assert_eq!(app.ui.theme_mode(), mode);
assert!(app.message.is_none());
}
assert!(!select_theme(&mut app, "view.formulaBar"));
assert!(!super::select_theme(&mut app, "view.formulaBar"));
assert_eq!(app.ui.theme_mode(), "light");
}
}
+13 -2
View File
@@ -19,12 +19,23 @@ fn main() {
nsapp.finishLaunching();
let ctx = egui::Context::default();
let _menu = native_menu::NativeMenu::install(&ctx);
let mut menu = native_menu::NativeMenu::install(&ctx);
let bar = nsapp.mainMenu().expect("a main menu");
let tops: Vec<String> = (0..bar.numberOfItems()).filter_map(|i| bar.itemAtIndex(i)?.submenu()).map(|m| m.title().to_string()).collect();
assert_eq!(tops, ["GridCraft", "File", "Edit", "View", "Insert", "Format", "Tools", "Data", "Window", "Help"]);
assert_eq!(nsapp.windowsMenu().map(|m| m.title().to_string()).as_deref(), Some("Window"), "AppKit knows the Window menu");
let windows_menu = nsapp.windowsMenu().expect("AppKit knows the Window menu");
assert_eq!(windows_menu.title().to_string(), "Window");
// A poll lists the open workbook windows at the end of the Window menu (#716).
let mut session = gridcraft_engine::Session::new();
session.new_workbook();
let mut app = gridcraft_ui_egui::SheetApp::new(session, Default::default());
menu.poll(&mut app, &ctx, egui::ViewportId::ROOT);
let open = app.open_windows();
assert_eq!(open.len(), 1, "one workbook window");
let last = windows_menu.itemAtIndex(windows_menu.numberOfItems() - 1).expect("a Window menu item");
assert_eq!(last.title().to_string(), open[0].label(), "the Window menu lists the open workbook");
println!("mac_menu_appkit: ok");
}
+6
View File
@@ -103,3 +103,9 @@ real_name = "Helen Kwok"
[people.dotCooCoo]
real_name = "Robert Lee"
sync_github_name = true
[people.dylanmtaylor]
sync_github_name = true
[people.Plutonium-fream]
sync_github_name = true
+19
View File
@@ -614,6 +614,25 @@ fn lambda_calls_spend_one_work_budget_per_cell() {
assert_eq!(t.num("A2"), 4000.0 * 4001.0 / 2.0);
}
#[test]
fn lifted_lookups_stop_past_their_work_budget() {
// Security: each needle searched the whole lookup vector, so this one cell ran for hours
// (about 1.1e12 compares) and hung opening the file that held it. It is #NUM! at once now.
let mut t = T::new();
t.set("A1", "=ROWS(XMATCH(SEQUENCE(1048576),-SEQUENCE(1048576)))");
assert_eq!(t.get("A1"), Value::Error(CellError::Num));
// Ordinary lifted lookups are unaffected.
for (i, k) in [10, 20, 30, 40, 50].into_iter().enumerate() {
t.set(&format!("B{}", i + 1), &k.to_string());
t.set(&format!("C{}", i + 1), &(i + 1).to_string());
}
t.set("D1", "=SUM(XMATCH({30;50;10},B1:B5))");
t.set("D2", "=SUM(XLOOKUP({20;40},B1:B5,C1:C5))");
t.set("D3", "=SUM(VLOOKUP({30,10},B1:C5,2,FALSE))");
t.set("D4", "=SUM(MATCH({25,50},B1:B5))");
assert_eq!([t.num("D1"), t.num("D2"), t.num("D3"), t.num("D4")], [9.0, 6.0, 4.0, 7.0]);
}
#[test]
fn accounting_negative_text_coerces() {
let mut t = T::new();
+1
View File
@@ -269,6 +269,7 @@ pub const CATALOG: &[(&str, &str, &str, &str)] = &[
("view.unhideWindow", "View", "Window", "Unhide"),
("view.switchWindows", "View", "Window", "Switch Windows"),
("view.macros", "View", "Macros", "Macros"),
("macro.record", "View", "Macros", "Record Macro"),
// Automate
("automate.newScript", "Automate", "Scripting Tools", "New Script"),
("automate.allScripts", "Automate", "Scripting Tools", "All Scripts"),
+29 -2
View File
@@ -12,6 +12,7 @@ pub mod format;
pub mod formulas;
pub mod insert;
pub mod inspect;
pub mod macros;
pub mod pivot;
pub mod print;
pub mod review;
@@ -143,6 +144,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(view::specs());
v.extend(inspect::specs());
v.extend(extra::specs());
v.extend(macros::specs());
v.extend(draw::specs());
v.extend(spelling::specs());
v.extend(solver::specs());
@@ -337,6 +339,9 @@ pub struct Ctx<'a> {
/// The command applied sheet protection itself (row/column inserts and deletes the
/// protection allows), so cells it shifts aren't refused as edits of locked cells.
pub protection_checked: bool,
/// The structural edits the command made, in order (the sheet's name, the edit), as
/// references were rewritten for them.
pub edits: Vec<(String, gridcraft_formula::adjust::Edit)>,
}
pub(crate) fn edit<R>(s: &mut Session, f: impl FnOnce(&mut Ctx) -> Result<R>) -> Result<R> {
@@ -358,15 +363,33 @@ pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>)
let t0 = if cfg!(target_arch = "wasm32") { None } else { std::env::var_os("GRIDCRAFT_PROFILE").map(|_| wall_clock()) };
let since = |t0: std::time::Duration| wall_clock().saturating_sub(t0);
let mut sel = d.selection.clone();
let mut ctx = Ctx { wb: (*d.wb).clone(), changed: Vec::new(), structural: false, sel: &mut sel, fit_rows: Vec::new(), protection_checked: false };
let mut ctx = Ctx {
wb: (*d.wb).clone(),
changed: Vec::new(),
structural: false,
sel: &mut sel,
fit_rows: Vec::new(),
protection_checked: false,
edits: Vec::new(),
};
let r = f(&mut ctx)?;
let Ctx { mut wb, changed, structural, mut fit_rows, protection_checked, .. } = ctx;
let Ctx { mut wb, changed, structural, mut fit_rows, protection_checked, edits, .. } = ctx;
if !protection_checked {
check_protection(&d.wb, &wb, &d.unlocked_ranges)?;
}
if !structural {
check_arrays(&d.wb, &wb)?;
}
if let Some(log) = d.touched.as_mut() {
for (si, c) in changed.iter().take(TOUCH_LOG_LIMIT.saturating_sub(log.len())) {
if let Some(sh) = wb.sheet(*si) {
log.push((sh.name.clone(), *c));
}
}
}
if let Some(log) = d.edits.as_mut() {
log.extend(edits);
}
if changed.len() <= 200_000 {
fit_rows.extend(changed.iter().map(|(s, c)| (*s, c.row)));
}
@@ -394,6 +417,10 @@ pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>)
Ok(r)
}
/// The most cells a dry run's touch log keeps; a footprint whose log is full may be missing writes
/// that kept a cell's value, so a shift is refused then.
pub(crate) const TOUCH_LOG_LIMIT: usize = 1_000_000;
/// Excel's message for an edit of a locked cell on a protected sheet.
pub(crate) const PROTECTED: &str = "The cell or chart you're trying to change is on a protected sheet.";
+21 -6
View File
@@ -213,6 +213,13 @@ fn redo(s: &mut Session, p: &Json) -> Result<Json> {
// ---------------------------------------------------------------- clear
/// The rows of `range` an applied filter hides (rows hidden by hand are not among them).
fn filtered_rows(sh: &gridcraft_model::Sheet, range: RangeRef) -> std::collections::BTreeSet<u32> {
sh.rows.range(range.start.row..=range.end.row).filter(|(r, info)| info.hidden && sh.is_row_filtered(**r)).map(|(r, _)| *r).collect()
}
/// The Clear commands (Delete is Clear Contents). As in Excel, the rows an applied filter hides
/// are left as they are; rows hidden by hand are cleared.
fn clear(s: &mut Session, p: &Json, what: &str) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let ranges = target_ranges(s, p)?;
@@ -223,7 +230,10 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result<Json> {
d.wb.sheet(sheet).is_some_and(|sh| {
ranges.iter().any(|r| {
r.intersection(&sh.used_range().unwrap_or(RangeRef::cell(r.start))).is_some_and(|r| {
sh.cells.iter_range(r).any(|(c, x)| x.value == Value::Bool(true) && super::extra::is_checkbox_cell(&d.wb, sh, c))
let skip = filtered_rows(sh, r);
sh.cells
.iter_range(r)
.any(|(c, x)| !skip.contains(&c.row) && x.value == Value::Bool(true) && super::extra::is_checkbox_cell(&d.wb, sh, c))
})
})
})
@@ -237,8 +247,12 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result<Json> {
},
None => continue,
};
let cells: Vec<(CellRef, Cell)> =
cx.wb.sheet(sheet).map(|sh| sh.cells.iter_range(r).map(|(c, x)| (c, x.clone())).collect()).unwrap_or_default();
let skip = cx.wb.sheet(sheet).map(|sh| filtered_rows(sh, r)).unwrap_or_default();
let cells: Vec<(CellRef, Cell)> = cx
.wb
.sheet(sheet)
.map(|sh| sh.cells.iter_range(r).filter(|(c, _)| !skip.contains(&c.row)).map(|(c, x)| (c, x.clone())).collect())
.unwrap_or_default();
// Report every stored cell we touch, even beyond a million coordinates in a sparse
// selection. Removed formula anchors must also reach calc so their spills disappear.
cx.changed.extend(cells.iter().map(|(c, _)| (sheet, *c)));
@@ -273,16 +287,17 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result<Json> {
_ => {}
}
}
let cleared = |c: CellRef| r.contains(c) && !skip.contains(&c.row);
if matches!(what, "all" | "comments") {
sh.comments.retain(|c, _| !r.contains(*c));
sh.comments.retain(|c, _| !cleared(*c));
}
if matches!(what, "all" | "hyperlinks") {
sh.hyperlinks.retain(|c, _| !r.contains(*c));
sh.hyperlinks.retain(|c, _| !cleared(*c));
}
if matches!(what, "all" | "formats") {
let mut unmerged = false;
sh.merges.retain(|m| {
let keep = !r.contains_range(m);
let keep = !r.contains_range(m) || skip.range(m.start.row..=m.end.row).next().is_some();
unmerged |= !keep;
keep
});
+42 -7
View File
@@ -1,10 +1,15 @@
use super::{Json, Result, SCRIPTS_NAME, Session, Workbook, bad, bool_param, edit, json, stash_get, stash_map, stash_set, str_param};
use super::{EngineError, Json, Result, SCRIPTS_NAME, Session, Workbook, bad, bool_param, edit, json, stash_get, stash_map, stash_set, str_param};
pub(super) fn record(s: &mut Session, p: &Json) -> Result<Json> {
if bool_param(p, "stop").unwrap_or(false) || s.recording.is_some() {
let Some(start) = s.recording.take() else { return Ok(json!({"recording": false})) };
let cmds: Vec<Json> =
s.journal.iter().skip(start).filter(|(id, _)| !id.starts_with("automate.")).map(|(id, p)| json!({"command": id, "params": p})).collect();
let cmds: Vec<Json> = s
.journal
.iter()
.skip(start)
.filter(|(id, _)| !id.starts_with("automate.") && !crate::record::NOT_RECORDED.contains(&id.as_str()))
.map(|(id, p)| json!({"command": id, "params": p}))
.collect();
let name = str_param(p, "name").map(str::to_string).unwrap_or_else(|| format!("Script {}", start));
s.execute("automate.newScript", json!({"name": name, "commands": cmds}))?;
return Ok(json!({"recording": false, "saved": name, "steps": cmds.len()}));
@@ -104,12 +109,16 @@ pub(super) fn run_script(s: &mut Session, p: &Json) -> Result<Json> {
}
let _sandbox = crate::io::ScriptSandbox::enter();
let mut n = 0;
// What ran, for a recorder in progress: the recording keeps these steps with the run, so
// editing or deleting the script before the recording stops does not change what it saved.
let mut ran_steps: Vec<(String, Json)> = Vec::new();
for st in &steps {
let id = st["command"].as_str().unwrap_or("");
if id.starts_with("automate.") {
continue;
}
let before = s.ui_requests.len();
let wb_before = s.doc().ok().map(|d| (d.uid, d.wb.clone()));
let ran = s.execute(id, st["params"].clone());
// A step that asks the host to open a URL leaves the app: drop the request and stop.
if s.ui_requests.get(before..).is_some_and(|new| new.iter().any(|r| matches!(r, crate::UiRequest::OpenUrl(_)))) {
@@ -120,17 +129,43 @@ pub(super) fn run_script(s: &mut Session, p: &Json) -> Result<Json> {
});
return Err(bad("automate.runScript", format!("scripts can't open links (`{id}`)")));
}
ran?;
let result = ran?;
n += 1;
if s.recorder.is_some() {
// The recorder's rule for each step, as for a command run on its own: a menu-style
// step that only opened a dialog is left out (playing it back would stop at the
// dialog), and a toggle keeps the state it set.
let opened_dialog = s.ui_requests.get(before..).is_some_and(|new| new.iter().any(|r| matches!(r, crate::UiRequest::Dialog(..))));
let changed = wb_before.as_ref().is_some_and(|(uid, wb)| s.docs.iter().any(|d| d.uid == *uid && !std::sync::Arc::ptr_eq(&d.wb, wb)));
if !opened_dialog || changed {
ran_steps.push((id.to_string(), crate::record::with_toggle_state(st["params"].clone(), &result)));
}
}
}
if s.recorder.is_some() {
s.script_ran = Some(ran_steps);
}
Ok(json!({"ran": n}))
}
/// Automate › Stop Action: rolls back the undo groups open on the active workbook, so Undo and
/// Redo work there again.
/// Redo work there again. A macro being debugged on that workbook is stopped with them.
pub(super) fn stop_action(s: &mut Session, _: &Json) -> Result<Json> {
// Inside another command the open groups are that command's own (an action in progress):
// a step of it that stopped them would pull the workbook from under the rest of its run.
if s.depth() > 1 {
return Err(EngineError::Disabled("automate.stopAction".into(), "Stop Action runs on its own, not as a step of a script or macro.".into()));
}
let uid = s.doc()?.uid;
Ok(json!({"stopped": s.abort_groups(uid)}))
let mut out = json!({"stopped": s.abort_groups(uid)});
// A macro being debugged here had its group among them: its run is over.
if let Some((name, wrote_files)) = crate::macros::debug::end_stopped(s, uid) {
out["macro"] = json!(name);
out["message"] = json!(format!(
"Macro '{name}', which was being debugged, was stopped and rolled back. {}",
crate::macros::run::rolled_back(&wrote_files)
));
}
Ok(out)
}
pub(super) fn delete_script(s: &mut Session, p: &Json) -> Result<Json> {
+6 -2
View File
@@ -13,7 +13,7 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path} (.xlsx, .csv, .tsv, .json, .html, or .pdf: a PDF of the active sheet)", has_doc, save_as),
cmd!(query "file.saveBytes", "Encode Workbook", [], None, "{format?: xlsx|csv|tsv|json|html} → {base64}", has_doc, save_bytes),
cmd!(noundo "file.close", "Close", ["File"], Some("Cmd+W"), "{force?: bool, document?: bool} (closes the active window, or with `document` the workbook and all its windows; the workbook closes with its last window)", has_doc, close),
cmd!(noundo "file.exportCsv", "Export as CSV", ["File", "Export"], None, "{path, sheet?}", has_doc, |s, p| export(s, p, "csv")),
cmd!(noundo "file.exportCsv", "Export as CSV", ["File", "Export"], None, "{path, sheet?, values?: bool} → {path, bytes}: writes the cells as displayed, like Excel's Save As CSV; `values: true` writes raw values instead (full-precision numbers, ISO 8601 dates and times, TRUE/FALSE)", has_doc, |s, p| export(s, p, "csv")),
cmd!(noundo "file.exportHtml", "Save as Web Page", ["File", "Export"], None, "{path}", has_doc, |s, p| export(s, p, "html")),
cmd!("file.properties", "Properties", ["File"], None, "{title?, subject?, author?, company?, keywords?, description?}", has_doc, properties),
cmd!(
@@ -191,7 +191,11 @@ fn export(s: &mut Session, p: &Json, ext: &str) -> Result<Json> {
wb.set_active_sheet(target_sheet(s, p)?);
}
let target = if path.ends_with(&format!(".{ext}")) { path.clone() } else { format!("{path}.{ext}") };
let bytes = crate::io::save_bytes(&wb, &target)?;
let bytes = if ext == "csv" && bool_param(p, "values").unwrap_or(false) {
crate::io::csv_values_bytes(&wb)?
} else {
crate::io::save_bytes(&wb, &target)?
};
crate::io::write_file(&target, &bytes)?;
Ok(json!({"path": target, "bytes": bytes.len()}))
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -60,7 +60,7 @@ pub(super) fn insert_table(s: &mut Session, p: &Json) -> Result<Json> {
let e = Edit::InsertCells { axis: Axis::Rows, range: RangeRef::new(r.start, CellRef::new(r.start.row, r.end.col)) };
shift_cell_features(sh, &e);
let name = sh.name.clone();
rewrite(&mut cx.wb, &name, &e);
rewrite(cx, &name, &e);
let sh = cx.sheet_mut(sheet)?;
for (i, c) in (r.start.col..=r.end.col).enumerate() {
sh.set_value(CellRef::new(r.start.row, c), gridcraft_core::Value::text(format!("Column{}", i + 1)));
+372
View File
@@ -0,0 +1,372 @@
//! Macros: typed command lists that run as one undo step. See [`crate::macros`] for the model,
//! tokens, runner, debugger, recorder and policy. Automate scripts (`automate.*`) are a separate,
//! older feature with their own storage; these commands neither read nor change it. A recording
//! keeps the steps a script run ran while it recorded, as the run reported them.
use serde_json::{Value as Json, json};
use super::{CommandSpec, EngineError, Result, Session, bad, bool_param, cmd, edit, has_doc, str_param};
use crate::macros::recorder::RecordingStart;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(query "macro.list", "List Macros", [], None, "{} → [{name, description, shortcut?, steps, inputs: [names], onConflict, permissions}]", has_doc, list),
cmd!(query "macro.get", "Get Macro", [], None, "{name} → the macro: {v, name, description, shortcut?, inputs, steps, output, permissions}", has_doc, get),
cmd!(
"macro.save",
"Save Macro",
[],
None,
"{macro: {name, description?, shortcut?, inputs?: [{name, kind: cell|range, rows: [min, max], cols: [min, max], header, colTypes: [number|text|date|bool|any], nonEmpty, defaultRef?}], steps: [{type: command, id, params, enabled?, breakpoint?, note?} | {type: repeat, over, of: rows|columns, max, body} | {type: if, cell, op: eq|ne|gt|ge|lt|le|empty|notEmpty|contains, value, then, else} | {type: stop, message, rollback?}], output?: {anchor?, onConflict: ask|fail|overwrite|shiftDown|shiftRight}, permissions?: [command ids]} (or those fields at the top level), replace?: bool (default false: saving over a macro with the name is refused)} → {name, steps}",
has_doc,
save
),
cmd!("macro.delete", "Delete Macro", [], None, "{name}", has_doc, delete),
cmd!("macro.rename", "Rename Macro", [], None, "{name, to}", has_doc, rename),
cmd!(
"macro.run",
"Run Macro",
[],
None,
"{name, inputs?: {Input: \"B2:D9\"} | \"B2:D9\", onConflict?: ask|fail|overwrite|shiftDown|shiftRight, overwrite?: bool, approve?: bool, timeLimit?: seconds} runs as one undo step; any error rolls it all back → {macro, ran, inputs, stopped?, shifted?}",
has_doc,
run
),
cmd!(
query "macro.check",
"Check Macro",
[],
None,
"{name, inputs?, onConflict?, overwrite?, timeLimit?: seconds} validates the inputs and dry-runs the macro on the workbook (then puts everything back) → {ok (macro.run with these parameters would go ahead without a question), errors?, needsApproval?, onConflict, inputs, output?, conflicts, footprint}",
has_doc,
check
),
cmd!(
noundo "macro.record",
"Record Macro",
["View", "Macros"],
None,
"{stop?: bool, cancel?: bool (ends a recording without saving it), relative?: bool (default true; when starting), name?, shortcut?: \"Ctrl+Shift+M\" | \"M\", description?} toggles recording; when starting, the name (new and valid) and the shortcut (free) are checked and kept for the save, and without a name the macro gets the next MacroN; when stopping, a name or description given then wins (a taken name gets a suffix); with relative references the selection when it starts is the example input → {recording, name?, relative?, input?} or {recording: false, saved, steps, dropped, skipped, inputs, shortcutDropped? (the shortcut another macro took while recording)}",
has_doc,
record
),
cmd!(
"macro.debug",
"Step Into",
[],
None,
"{action: start|step|continue|runTo|stop|state, name, inputs?, onConflict?, overwrite?, approve?, timeLimit?: seconds per action (start, as macro.run), step: path (runTo), keep?: bool (stop), breakpoints?: [\"3\", \"2.1\"] (steps of the macro), breakpointsOnly?: bool (the list replaces the steps' own breakpoints)} debugs a macro one step at a time; stop keeps the changes as one undo step or rolls them back. Runs on its own, never as a step of a script or macro. Until it finishes or stops, its workbook runs only macro.debug, queries, selection and sheet changes, Go To, Find, Copy, zoom, Switch Window, Close and Stop Action (other workbooks stay editable) → {state: paused|finished|stopped|failed, current: {step, kind, command?, params?, iteration?}, lastParams, executed, changed (at most 500), changedCells, atBreakpoint?, shifted?, wroteFiles?, message?, error?}",
has_doc,
|s, p| crate::macros::debug::debug(s, p)
),
]
}
fn name_param<'a>(cmd: &str, p: &'a Json) -> Result<&'a str> {
str_param(p, "name").filter(|n| !n.trim().is_empty()).ok_or_else(|| bad(cmd, "missing `name`"))
}
/// The index of macro `name` in the active workbook, or an error that says there is none.
fn index_of(s: &Session, name: &str) -> Result<usize> {
crate::macros::find(&s.doc()?.wb, name).ok_or_else(|| EngineError::Other(format!("There is no macro called '{name}'.")))
}
fn list(s: &mut Session, _: &Json) -> Result<Json> {
let wb = &s.doc()?.wb;
Ok(Json::Array(
wb.macros
.iter()
.map(|m| {
let mut o = json!({
"name": m.name,
"description": m.description,
"steps": m.step_count(),
"inputs": m.inputs.iter().map(|i| i.name.clone()).collect::<Vec<_>>(),
"onConflict": m.output.on_conflict.name(),
"permissions": m.permissions,
});
if let Some(k) = &m.shortcut {
o["shortcut"] = json!(k);
}
o
})
.collect(),
))
}
fn get(s: &mut Session, p: &Json) -> Result<Json> {
let i = index_of(s, name_param("macro.get", p)?)?;
let wb = &s.doc()?.wb;
Ok(wb.macros.get(i).map(crate::macros::macro_json).unwrap_or(Json::Null))
}
fn save(s: &mut Session, p: &Json) -> Result<Json> {
let v = p.get("macro").filter(|m| m.is_object()).unwrap_or(p);
let m = crate::macros::parse_macro(v).map_err(|e| bad("macro.save", e))?;
// Replacing is asked for, never assumed: a save with a name in use is refused.
let replace = match p.get("replace") {
None | Some(Json::Null) => false,
Some(Json::Bool(b)) => *b,
Some(_) => return Err(bad("macro.save", "`replace` must be true or false")),
};
let (name, steps) = (m.name.clone(), m.step_count());
edit(s, |cx| {
if let Some(kept) = crate::macros::kept_name(&cx.wb, &m.name) {
return Err(EngineError::Other(taken_by_kept(&kept)));
}
match crate::macros::find(&cx.wb, &m.name) {
Some(i) if !replace => {
let existing = cx.wb.macros.get(i).map_or(m.name.as_str(), |o| o.name.as_str());
return Err(EngineError::Other(format!("A macro called '{existing}' already exists. Pass `replace: true` to replace it.")));
}
Some(i) => {
if let Some(old) = cx.wb.macros.get_mut(i) {
*old = m;
}
}
None => {
crate::macros::check_room(&cx.wb).map_err(EngineError::Other)?;
cx.wb.macros.push(m);
}
}
Ok(())
})?;
Ok(json!({"name": name, "steps": steps}))
}
/// The refusal of a name that a macro kept in a newer format has.
fn taken_by_kept(name: &str) -> String {
format!(
"A macro called '{name}' made by a newer version of GridCraft is kept in this workbook; this version can't change it. Choose another name."
)
}
fn delete(s: &mut Session, p: &Json) -> Result<Json> {
let i = index_of(s, name_param("macro.delete", p)?)?;
edit(s, |cx| {
if i < cx.wb.macros.len() {
cx.wb.macros.remove(i);
}
Ok(Json::Null)
})
}
fn rename(s: &mut Session, p: &Json) -> Result<Json> {
let i = index_of(s, name_param("macro.rename", p)?)?;
let to = str_param(p, "to").map(str::trim).filter(|t| !t.is_empty()).ok_or_else(|| bad("macro.rename", "missing `to`"))?.to_string();
if to.chars().any(char::is_control) {
return Err(bad("macro.rename", "a macro name can't hold line breaks or other control characters"));
}
let wb = &s.doc()?.wb;
if crate::macros::find(wb, &to).is_some_and(|j| j != i) {
return Err(EngineError::Other(format!("A macro called '{to}' already exists.")));
}
if let Some(kept) = crate::macros::kept_name(wb, &to) {
return Err(EngineError::Other(taken_by_kept(&kept)));
}
edit(s, |cx| {
let Some(m) = cx.wb.macros.get_mut(i) else { return Ok(()) };
let mut renamed = m.clone();
renamed.name = to.clone();
// The whole check, size included: a longer name can take a macro past what a file holds.
crate::macros::check(&renamed).map_err(EngineError::Other)?;
*m = renamed;
Ok(())
})?;
Ok(json!({"name": to}))
}
fn run(s: &mut Session, p: &Json) -> Result<Json> {
let name = name_param("macro.run", p)?;
// While recording: the steps a recording keeps for this run, taken from the macro as it runs,
// so saving or deleting the macro before the recording stops doesn't change what it saved.
let inline = if s.is_recording() {
let wb = &s.doc()?.wb;
crate::macros::find(wb, name).and_then(|i| wb.macros.get(i)).and_then(crate::macros::recorder::inline_steps)
} else {
None
};
let out = crate::macros::run::run(s, name, p)?;
if s.is_recording() {
// `None` when the macro's steps don't stand on their own: the recording leaves the run out.
s.script_ran = inline;
}
Ok(out)
}
fn check(s: &mut Session, p: &Json) -> Result<Json> {
let name = name_param("macro.check", p)?;
crate::macros::run::check(s, name, p)
}
fn record(s: &mut Session, p: &Json) -> Result<Json> {
check_record_params(s.is_recording(), p)?;
// `cancel` ends a recording without saving it: the way out of a recording whose macro
// cannot be saved (a name the check refuses, a full workbook).
if bool_param(p, "cancel").unwrap_or(false) {
let was = s.stop_recording().is_some();
s.macro_recording = None;
return Ok(json!({"recording": false, "cancelled": was}));
}
if bool_param(p, "stop").unwrap_or(false) || s.is_recording() {
return stop_recording(s, p);
}
start_recording(s, p, bool_param(p, "relative").unwrap_or(true))
}
/// Refuses parameters `macro.record` would ignore (`null` counts as not given): `relative` and
/// `shortcut` when stopping (they are chosen when recording starts), `stop: false` while
/// recording (the call stops it), anything beside `cancel: true`, and parameters of the wrong
/// kind. `name` and `description` count when starting and when stopping (stopping wins).
fn check_record_params(recording: bool, p: &Json) -> Result<()> {
let given = |k: &str| p.get(k).filter(|v| !v.is_null());
for k in ["stop", "cancel", "relative"] {
if given(k).is_some_and(|v| !v.is_boolean()) {
return Err(bad("macro.record", format!("`{k}` must be true or false")));
}
}
for k in ["name", "description", "shortcut"] {
if given(k).is_some_and(|v| !v.is_string()) {
return Err(bad("macro.record", format!("`{k}` must be text")));
}
}
if given("cancel").and_then(Json::as_bool) == Some(true) {
if let Some(k) = ["stop", "relative", "name", "description", "shortcut"].into_iter().find(|k| given(k).is_some()) {
return Err(bad("macro.record", format!("`cancel: true` ends the recording without saving it, so `{k}` would be ignored")));
}
return Ok(());
}
let stop = given("stop").and_then(Json::as_bool);
if recording && stop == Some(false) {
return Err(bad(
"macro.record",
"a recording is in progress, and this call stops it: pass `stop: true`, or `cancel: true` to end it unsaved",
));
}
if (recording || stop == Some(true))
&& let Some(k) = ["relative", "shortcut"].into_iter().find(|k| given(k).is_some())
{
return Err(bad("macro.record", format!("`{k}` is chosen when recording starts")));
}
Ok(())
}
/// Starts recording. `name`, `shortcut` and `description` (from the Record Macro dialog) are
/// checked now, so a bad name or a taken shortcut is refused before anything is recorded.
fn start_recording(s: &mut Session, p: &Json, relative: bool) -> Result<Json> {
let d = s.doc()?;
let name = match str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()) {
Some(n) => Some(crate::macros::check_new_name(&d.wb, n).map_err(|e| bad("macro.record", e))?),
None => None,
};
let shortcut = match str_param(p, "shortcut").map(str::trim).filter(|k| !k.is_empty()) {
Some(k) => {
let letter = crate::macros::parse_shortcut(k)
.ok_or_else(|| bad("macro.record", "`shortcut` must be one letter from A to Z (used as Ctrl+Shift+letter)"))?;
if let Some(why) = crate::macros::shortcut_conflict(&d.wb, name.as_deref().unwrap_or(""), letter) {
return Err(bad("macro.record", why));
}
Some(crate::macros::shortcut_text(letter))
}
None => None,
};
let description = match str_param(p, "description") {
Some(t) if t.chars().count() > gridcraft_model::MAX_MACRO_TEXT => {
return Err(bad("macro.record", format!("`description` can be at most {} characters long", gridcraft_model::MAX_MACRO_TEXT)));
}
Some(t) => Some(t.to_string()),
None => None,
};
let mut start = RecordingStart::new(d, relative);
(start.name, start.shortcut, start.description) = (name.clone(), shortcut, description);
let input = start.selection.current().a1();
s.start_recording();
s.macro_recording = Some(start);
let mut out = if relative { json!({"recording": true, "relative": true, "input": input}) } else { json!({"recording": true, "relative": false}) };
if let Some(n) = name {
out["name"] = json!(n);
}
Ok(out)
}
/// Stops recording and saves what was recorded as a macro, in the workbook where recording
/// started. When saving fails (a bad name, a full workbook, a macro being debugged there) the
/// recording goes on, so stopping again keeps what was recorded; when that workbook was closed,
/// the recording ends unsaved.
fn stop_recording(s: &mut Session, p: &Json) -> Result<Json> {
let Some(rec) = s.stop_recording() else { return Ok(json!({"recording": false})) };
let start = s.macro_recording.take();
let at = match &start {
Some(st) => s
.docs
.iter()
.position(|d| d.uid == st.doc)
.ok_or_else(|| EngineError::Other("The workbook where recording started was closed, so the recording was not saved.".into()))?,
None => s.active_index(),
};
let saved = save_recording(s, p, &rec, start.clone(), at);
if saved.is_err() {
s.recorder = Some(rec);
s.macro_recording = start;
}
saved
}
/// Saves the recording into the workbook at index `at`.
fn save_recording(s: &mut Session, p: &Json, rec: &crate::record::Recorder, start: Option<RecordingStart>, at: usize) -> Result<Json> {
let d = s.docs.get(at).ok_or(EngineError::NoDocument)?;
// A macro being debugged there could still roll the saved macro back with its run.
if s.macro_debug.as_ref().is_some_and(|m| m.locks(d.uid)) {
return Err(EngineError::Disabled("macro.record".into(), crate::macros::debug::BUSY.into()));
}
// A recording started without `macro.record` (`Session::start_recording`) takes its example
// input from the workbook as it is now.
let start = start.unwrap_or_else(|| RecordingStart::new(d, true));
let (doc, wb) = (d.uid, d.wb.clone());
// A name given when stopping wins over the one given when starting; either one, if another
// macro has it, gets a suffix, so the save never replaces a macro.
let name = match str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).or(start.name.as_deref()) {
Some(n) => crate::macros::unique_name(&wb, n),
None => crate::macros::next_name(&wb),
};
let mut converted = crate::macros::recorder::convert(&name, &start, &rec.steps, &rec.ran);
// Steps past the macro limit or its size, and ones recorded in another workbook, are left
// out and counted with the ones the recorder dropped.
let mut cut = converted.cut.saturating_add(converted.mac.steps.len().saturating_sub(gridcraft_model::MAX_MACRO_STEPS));
converted.mac.steps.truncate(gridcraft_model::MAX_MACRO_STEPS);
if let Some(desc) = str_param(p, "description").or(start.description.as_deref()) {
converted.mac.description = desc.chars().take(gridcraft_model::MAX_MACRO_TEXT).collect();
}
cut = cut.saturating_add(crate::macros::fit_size(&mut converted.mac));
// The shortcut, unless another macro took it while recording; then it is left off and the
// result says so.
let mut shortcut_dropped = None;
if let Some(k) = &start.shortcut {
if crate::macros::parse_shortcut(k).is_some_and(|c| crate::macros::shortcut_conflict(&wb, &name, c).is_none()) {
converted.mac.shortcut = Some(k.clone());
} else {
shortcut_dropped = Some(k.clone());
}
}
let steps = converted.mac.steps.len();
let inputs: Vec<String> = converted.mac.inputs.iter().map(|i| i.name.clone()).collect();
// Saved into that workbook even when another one is active now.
let active = s.active_index();
s.set_active(at);
let saved = s.execute("macro.save", json!({"macro": crate::macros::macro_json(&converted.mac), "replace": false}));
s.set_active(active);
saved?;
// The person recorded it, so it runs without the approval a macro that came with a file
// needs, until it changes.
if let Some(d) = s.docs.iter().find(|d| d.uid == doc)
&& let Some(m) = crate::macros::find(&d.wb, &name).and_then(|i| d.wb.macros.get(i))
{
let key = (doc, crate::macros::fingerprint(m));
s.macro_approvals.insert(key);
}
let mut out = json!({"recording": false, "saved": name, "steps": steps, "dropped": rec.dropped.saturating_add(cut), "skipped": converted.skipped, "inputs": inputs});
if let Some(k) = shortcut_dropped {
out["shortcutDropped"] = json!(k);
}
Ok(out)
}
+36 -2
View File
@@ -2,8 +2,8 @@ use super::layout::{DENSE_BLOCK, SheetLayout};
use super::objects::draw_cell_picture;
use super::text::{TextPlacement, draw_text};
use super::{
BorderLine, BorderStyle, Borders, CellRef, Color, Font, Page, PatternType, Point, RangeRef, Rgb, Sheet, Style, StyleId, Underline, Workbook,
split_sheet,
BorderLine, BorderStyle, Borders, CellRef, Color, Font, Page, PatternType, Point, RangeRef, Rgb, Sheet, Style, StyleId, Underline, Value,
Workbook, split_sheet,
};
// ---------------------------------------------------------------- drawing
@@ -280,6 +280,7 @@ pub(super) fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Opt
|| merges.iter().any(|m| m.start == *c)
|| !sh.value(*c).is_empty()
|| l.style.borders != Borders::default()
|| l.style.num_fmt.as_str() == "checkbox"
})
.collect();
let rect_of = |c: CellRef| -> (f32, f32, f32, f32, bool) {
@@ -343,6 +344,8 @@ pub(super) fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Opt
}
if let Some(picture) = sh.cell_pictures.get(c) {
draw_cell_picture(pg, &picture.data, (x, y, w, h), b.k);
} else if let Some(on) = checkbox_state(l, &sh.value(*c)) {
draw_checkbox(pg, (x, y, w, h), b.k, on);
} else {
draw_text(pg, b, TextPlacement { cell: *c, look: l, rect: (x, y, w, h), merged, merges: &merges, limits: (bx, bx + bw) });
}
@@ -350,6 +353,37 @@ pub(super) fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Opt
pg.restore();
}
/// A checkbox cell prints TRUE/FALSE (and empty) as a box, as on screen (#2972); other values
/// print as text.
fn checkbox_state(l: &Look, v: &Value) -> Option<bool> {
if l.style.num_fmt.as_str() != "checkbox" || l.cf.as_ref().is_some_and(|cf| cf.hide_value) {
return None;
}
match v {
Value::Bool(on) => Some(*on),
Value::Empty => Some(false),
_ => None,
}
}
/// A checkbox centred in the cell: white with a grey outline, or green with a white tick.
fn draw_checkbox(pg: &mut Page, (x, y, w, h): (f32, f32, f32, f32), k: f32, on: bool) {
let s = (13.2 * k).min(w - 2.0 * k).min(h - 2.0 * k);
if s <= 0.0 {
return;
}
let (bx, by) = (x + (w - s) / 2.0, y + (h - s) / 2.0);
let corners = [[bx, by], [bx + s, by], [bx + s, by + s], [bx, by + s]];
if on {
let green = [0x10, 0x7C, 0x41];
pg.polygon(&corners, Some(green), Some((green, 0.75 * k)));
let tick = [[bx + 0.22 * s, by + 0.52 * s], [bx + 0.42 * s, by + 0.72 * s], [bx + 0.78 * s, by + 0.30 * s]];
pg.polyline(&tick, 0.14 * s, [0xFF, 0xFF, 0xFF], &[]);
} else {
pg.polygon(&corners, Some([0xFF, 0xFF, 0xFF]), Some(([110, 110, 110], 0.75 * k)));
}
}
pub(super) struct PendingPdfLink {
pub(super) page: usize,
pub(super) sheet: usize,
+16
View File
@@ -565,3 +565,19 @@ fn rotated_wrapped_text_prints_rotated() {
assert_eq!(up + down, blocks.len(), "a header line printed flat: {blocks:?}");
assert!((1..=3).contains(&up) && (1..=3).contains(&down), "up {up}, down {down}: {blocks:?}");
}
#[test]
fn checkbox_cells_print_as_boxes_not_true_false() {
// #2972: a checkbox cell printed its value as TRUE or FALSE.
let mut s = s();
s.execute("insert.checkbox", json!({"range": "A1:A2"})).unwrap();
s.execute("cell.toggleCheckbox", json!({"cell": "A2"})).unwrap();
s.execute("cell.set", json!({"cell": "C1", "input": "TRUE"})).unwrap();
let (pdf, _) = pdf_of(&mut s, json!({}));
assert!(!contains(&pdf, "(FALSE) Tj"), "the unchecked box printed as FALSE");
// Only the plain TRUE in C1 prints as text; the checked box is a filled box.
let text = String::from_utf8_lossy(&pdf);
assert_eq!(text.matches("(TRUE) Tj").count(), 1, "the checked box printed as TRUE");
assert!(contains(&pdf, "0.063 0.486 0.255 rg"), "the checked box is not filled");
assert!(contains(&pdf, "0.431 0.431 0.431 RG"), "the unchecked box is not outlined");
}
+12 -10
View File
@@ -232,7 +232,9 @@ fn shift_sheet_features(sh: &mut Sheet, axis: Axis, at: u32, count: u32, insert:
}
/// Rewrites every formula reference (and chart series) for a structural edit on `target`.
pub(super) fn rewrite(wb: &mut gridcraft_model::Workbook, target: &str, e: &Edit) {
pub(super) fn rewrite(cx: &mut Ctx, target: &str, e: &Edit) {
cx.edits.push((target.to_string(), e.clone()));
let wb = &mut cx.wb;
super::edit::rewrite_all_formulas(wb, target, e);
for si in 0..wb.sheets.len() {
let host = wb.sheets.get(si).map(|s| s.name.clone()).unwrap_or_default();
@@ -296,7 +298,7 @@ fn check_push_off_sheet(s: &Session, sheet: usize, axis: Axis, at: u32, count: u
if let Some(u) = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.used_range()) {
let last = if axis == Axis::Rows { u.end.row } else { u.end.col };
if last >= at && last as u64 + count as u64 >= limit as u64 {
return Err(EngineError::Other("To prevent possible loss of data, Excel cannot shift nonblank cells off of the worksheet.".into()));
return Err(EngineError::Other("To prevent possible loss of data, GridCraft cannot shift nonblank cells off of the worksheet.".into()));
}
}
Ok(())
@@ -325,7 +327,7 @@ fn insert_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
// New lines take the format of the line before (Excel's "Format Same As Above").
inherit_line_format(sh, axis, at, count);
}
rewrite(&mut cx.wb, &name, &Edit::Insert { axis, at, count });
rewrite(cx, &name, &Edit::Insert { axis, at, count });
cx.structural = true;
Ok(Json::Null)
})?;
@@ -390,7 +392,7 @@ fn delete_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
}
shift_sheet_features(sh, axis, at, count, false);
}
rewrite(&mut cx.wb, &name, &Edit::Delete { axis, at, count });
rewrite(cx, &name, &Edit::Delete { axis, at, count });
}
cx.structural = true;
Ok(Json::Null)
@@ -442,7 +444,7 @@ fn move_columns(s: &mut Session, p: &Json) -> Result<Json> {
sh.cells.shift_cols(before, count as i64);
shift_sheet_features(sh, Axis::Cols, before, count, true);
}
rewrite(&mut cx.wb, &name, &Edit::Insert { axis: Axis::Cols, at: before, count });
rewrite(cx, &name, &Edit::Insert { axis: Axis::Cols, at: before, count });
let mut touched = Vec::new();
{
@@ -480,14 +482,14 @@ fn move_columns(s: &mut Session, p: &Json) -> Result<Json> {
for cell in touched {
cx.touch(sheet, cell);
}
rewrite(&mut cx.wb, &name, &Edit::Move { from: source, to_row: 0, to_col: destination });
rewrite(cx, &name, &Edit::Move { from: source, to_row: 0, to_col: destination });
{
let sh = cx.sheet_mut(sheet)?;
sh.cells.shift_cols(delete_at + count, -(count as i64));
shift_sheet_features(sh, Axis::Cols, delete_at, count, false);
}
rewrite(&mut cx.wb, &name, &Edit::Delete { axis: Axis::Cols, at: delete_at, count });
rewrite(cx, &name, &Edit::Delete { axis: Axis::Cols, at: delete_at, count });
*cx.sel = crate::Selection {
active: CellRef::new(active_row, final_start),
anchor: CellRef::new(active_row, final_start),
@@ -519,7 +521,7 @@ fn insert_cells(s: &mut Session, p: &Json) -> Result<Json> {
RangeRef::new(CellRef::new(r.start.row.max(MAX_ROWS.saturating_sub(r.height())), r.start.col), CellRef::new(MAX_ROWS - 1, r.end.col))
};
if sh.cells.iter_range(doomed).any(|(_, x)| x.formula.is_some() || !x.value.is_empty()) {
return Err(EngineError::Other("To prevent possible loss of data, Excel cannot shift nonblank cells off of the worksheet.".into()));
return Err(EngineError::Other("To prevent possible loss of data, GridCraft cannot shift nonblank cells off of the worksheet.".into()));
}
}
let right = shift == "right";
@@ -532,7 +534,7 @@ fn insert_cells(s: &mut Session, p: &Json) -> Result<Json> {
}
shift_cell_features(sh, &e);
let name = cx.wb.sheet(sheet).map(|s| s.name.clone()).unwrap_or_default();
rewrite(&mut cx.wb, &name, &e);
rewrite(cx, &name, &e);
cx.structural = true;
Ok(Json::Null)
})?;
@@ -567,7 +569,7 @@ fn delete_cells(s: &mut Session, p: &Json) -> Result<Json> {
shift_cell_features(sh, &e);
}
// References into the deleted cells become #REF!; the rest move.
rewrite(&mut cx.wb, &name, &e);
rewrite(cx, &name, &e);
cx.structural = true;
Ok(Json::Null)
})?;
+1 -1
View File
@@ -199,7 +199,7 @@ fn phase_one(tableau: &mut Tableau, normalized: &[(Vec<f64>, Sense, f64)], budge
return Err(Outcome::Infeasible);
}
for i in 0..tableau.rows.len() {
if !tableau.basis.get(i).is_some_and(|basic| *basic >= tableau.decision_cols + tableau.slack_cols) {
if tableau.basis.get(i).is_none_or(|basic| *basic < tableau.decision_cols + tableau.slack_cols) {
continue;
}
let pivot_col = tableau
+105 -37
View File
@@ -15,7 +15,7 @@
//! noncharacter, which XML carries but real text does not use, so the escape costs nothing on
//! ordinary text. Legacy `_sc_` items are read as they are.
use std::collections::BTreeMap;
use std::collections::{BTreeMap, HashSet};
use std::fmt::Write as _;
use gridcraft_model::{DefinedName, Workbook};
@@ -102,15 +102,29 @@ pub fn get(wb: &Workbook, key: &str) -> Option<String> {
assemble(&parts)
}
/// What a listing found: the first [`MAX_ITEMS`] items or keys, and how many more there were.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Listing<T> {
pub found: T,
/// Items past the cap, not listed. A caller says so rather than letting them vanish.
pub more: usize,
}
/// The items whose key starts with `prefix` (`"macro_"`), by lower-cased key, read in one pass
/// over the names: the first [`MAX_ITEMS`] of [`keys`], without those over the size limit.
pub fn items(wb: &Workbook, prefix: &str) -> BTreeMap<String, String> {
group(wb, prefix)
.into_iter()
.filter(|(_, parts)| parts.is_item())
.take(MAX_ITEMS)
.filter_map(|(k, parts)| assemble(&parts).map(|text| (k, text)))
.collect()
/// over the names: the first [`MAX_ITEMS`] items that can be read, and the count of the readable
/// ones past them. An item over the size limit is neither listed nor counted, so it takes no
/// place another item could have. ([`keys`] lists keys without reading the items.)
pub fn items(wb: &Workbook, prefix: &str) -> Listing<BTreeMap<String, String>> {
let mut listing: Listing<BTreeMap<String, String>> = Listing::default();
for (k, parts) in group(wb, prefix) {
let Some(text) = parts.is_item().then(|| assemble(&parts)).flatten() else { continue };
if listing.found.len() < MAX_ITEMS {
listing.found.insert(k, text);
} else {
listing.more = listing.more.saturating_add(1);
}
}
listing
}
/// The text of the item held by `parts`, as [`get`] reads it.
@@ -169,28 +183,44 @@ fn unescape(s: &str) -> String {
/// Stores `text` under `key`, replacing what was there (in either format). Empty text removes it.
pub fn set(wb: &mut Workbook, key: &str, text: &str) -> Result<()> {
check_key(key)?;
if text.chars().count() > MAX_ITEM_CHARS {
return Err(EngineError::Other(format!("The data is too large to keep in the workbook ({} MB at most).", MAX_ITEM_CHARS / (1024 * 1024))));
set_many(wb, &[(key.to_string(), text.to_string())])
}
/// Stores every `(key, text)` of `items` as [`set`] would, with the checks done before anything
/// changes and one pass over the names for all of them, so writing a thousand items into a
/// workbook with many thousands of names stays near-linear.
pub fn set_many(wb: &mut Workbook, items: &[(String, String)]) -> Result<()> {
let mut planned = Vec::new();
let mut keys: HashSet<String> = HashSet::new();
for (key, text) in items {
check_key(key)?;
if text.chars().count() > MAX_ITEM_CHARS {
return Err(EngineError::Other(format!(
"The data is too large to keep in the workbook ({} MB at most).",
MAX_ITEM_CHARS / (1024 * 1024)
)));
}
// Two items under one key (names compare without case) would leave two hidden names of
// one name, of which an xlsx file keeps one: a caller's mistake, refused.
if !keys.insert(key.to_ascii_lowercase()) {
return Err(EngineError::Other(format!("`{key}` is given twice.")));
}
for (i, formula) in chunk_formulas(text).into_iter().enumerate() {
planned.push(DefinedName { name: format!("{PREFIX}{key}_{i}"), scope: None, formula, comment: "GridCraft data".into(), hidden: true });
}
}
let formulas = chunk_formulas(text);
// A visible name of the item's shape is someone's own (see `item_of`); a hidden name beside
// it with the same name would be the one an xlsx file drops. Refuse before changing anything,
// in one pass over the names (a file can carry many thousands).
let chunks = formulas.len() as u64;
let taken = |n: &&DefinedName| {
n.scope.is_none() && !n.hidden && parse_name(&n.name).is_some_and(|(k, i)| k.eq_ignore_ascii_case(key) && i.is_some_and(|i| i < chunks))
};
if let Some(n) = wb.names.iter().find(taken) {
// A visible name of an item's shape is someone's own (see `item_of`); a hidden name beside
// it with the same name would be the one an xlsx file drops. Refuse before changing anything.
let names: HashSet<String> = planned.iter().map(|n| n.name.to_ascii_lowercase()).collect();
if let Some(n) = wb.names.iter().find(|n| n.scope.is_none() && !n.hidden && names.contains(&n.name.to_ascii_lowercase())) {
return Err(EngineError::Other(format!(
"The workbook has a defined name '{}', which GridCraft needs for its own data. Rename it first.",
n.name
)));
}
remove(wb, key);
for (i, formula) in formulas.into_iter().enumerate() {
wb.names.push(DefinedName { name: format!("{PREFIX}{key}_{i}"), scope: None, formula, comment: "GridCraft data".into(), hidden: true });
}
// What was there under these keys goes, in either format, then the new chunks come.
wb.names.retain(|n| !item_of(n).is_some_and(|(k, _)| keys.contains(&k.to_ascii_lowercase())));
wb.names.extend(planned);
Ok(())
}
@@ -211,8 +241,11 @@ pub fn set_json(wb: &mut Workbook, key: &str, v: &Json) -> Result<()> {
/// The keys of the items stored in the workbook that start with `prefix` (`"macro_"`), sorted
/// and lower-cased: the first [`MAX_ITEMS`] of them. To read them all, [`items`] takes one pass
/// where a [`get`] per key would take one each.
pub fn keys(wb: &Workbook, prefix: &str) -> Vec<String> {
group(wb, prefix).into_iter().filter(|(_, parts)| parts.is_item()).map(|(k, _)| k).take(MAX_ITEMS).collect()
pub fn keys(wb: &Workbook, prefix: &str) -> Listing<Vec<String>> {
let mut found: Vec<String> = group(wb, prefix).into_iter().filter(|(_, parts)| parts.is_item()).map(|(k, _)| k).collect();
let more = found.len().saturating_sub(MAX_ITEMS);
found.truncate(MAX_ITEMS);
Listing { found, more }
}
/// Whether the workbook holds an item under `name` (compared without case): a hidden workbook-scope
@@ -417,10 +450,10 @@ mod tests {
// Only a later chunk: no item, not listed, and the legacy name is still read.
wb.names.push(name("_gc_orphan_2", "\"c\""));
assert_eq!(get(&wb, "orphan"), None);
assert_eq!(keys(&wb, "orphan"), Vec::<String>::new());
assert_eq!(keys(&wb, "orphan").found, Vec::<String>::new());
wb.names.push(name("_sc_orphan", "\"old\""));
assert_eq!(get(&wb, "orphan").as_deref(), Some("old"));
assert_eq!(keys(&wb, "orphan"), ["orphan"]);
assert_eq!(keys(&wb, "orphan").found, ["orphan"]);
// Chunks out of order, a duplicate, junk around the literals, an unterminated literal.
wb.names.push(name("_gc_mix_1", "\"b\"&A1&"));
wb.names.push(name("_gc_mix_0", "SUM(\"a\")"));
@@ -468,7 +501,7 @@ mod tests {
let own = DefinedName { hidden: false, ..name("_gc_watch_0", "\"mine\"") };
wb.names.push(own.clone());
assert_eq!(get(&wb, "watch"), None);
assert!(keys(&wb, "").is_empty());
assert!(keys(&wb, "").found.is_empty());
// Writing the item would put a hidden name of the same name beside it, and an xlsx file
// keeps only one of them: refused, with the names as they were.
let err = set(&mut wb, "watch", "theirs").unwrap_err().to_string();
@@ -482,6 +515,18 @@ mod tests {
assert_eq!(wb.names, vec![own]);
}
#[test]
fn set_many_refuses_one_key_twice() {
// Two items under one key, in any case, would leave two hidden names of one name.
let mut wb = Workbook::new();
wb.names.push(name("_gc_other_0", "\"stays\""));
let err = set_many(&mut wb, &[("k".into(), "a".into()), ("K".into(), "b".into())]).unwrap_err().to_string();
assert!(err.contains("given twice"), "{err}");
assert_eq!(wb.names.len(), 1, "nothing changed");
set_many(&mut wb, &[("k".into(), "a".into()), ("k2".into(), "b".into())]).unwrap();
assert_eq!((get(&wb, "k").as_deref(), get(&wb, "K2").as_deref(), get(&wb, "other").as_deref()), (Some("a"), Some("b"), Some("stays")));
}
#[test]
fn a_chunk_number_with_leading_zeros_is_another_name() {
// `_gc_watch_00` is not chunk 0 under another spelling: a hidden one is not read, and a
@@ -489,7 +534,7 @@ mod tests {
let mut wb = Workbook::new();
wb.names.push(name("_gc_watch_00", "\"theirs\""));
assert_eq!(get(&wb, "watch"), None);
assert!(keys(&wb, "").is_empty());
assert!(keys(&wb, "").found.is_empty());
let own = DefinedName { hidden: false, ..name("_gc_watch_00", "\"mine\"") };
wb.names.push(own.clone());
set(&mut wb, "watch", "ours").unwrap();
@@ -505,7 +550,7 @@ mod tests {
set(&mut wb, "macro_2", "b").unwrap();
set(&mut wb, "macro_1", &"a".repeat(9_000)).unwrap();
set(&mut wb, "scripts", "{}").unwrap();
assert_eq!(keys(&wb, "macro_"), ["macro_1", "macro_2"]);
assert_eq!(keys(&wb, "macro_").found, ["macro_1", "macro_2"]);
assert_eq!(item_key("_gc_macro_1_3").as_deref(), Some("macro_1"));
assert_eq!(item_key("_SC_Scripts").as_deref(), Some("scripts"));
assert_eq!(item_key("Total"), None);
@@ -525,7 +570,8 @@ mod tests {
wb.names.push(name("_gc_macro_orphan_1", "\"no chunk 0\""));
wb.names.push(name("_gc_other_0", "\"not a macro\""));
NAME_VISITS.with(|v| v.set(0));
let all = items(&wb, "MACRO_");
let listing = items(&wb, "MACRO_");
let all = &listing.found;
assert_eq!(NAME_VISITS.with(|v| v.get()), wb.names.len(), "one pass over the names");
assert_eq!(all.len(), MAX_ITEMS);
let first: Vec<&String> = all.keys().take(2).collect();
@@ -535,19 +581,41 @@ mod tests {
NAME_VISITS.with(|v| v.set(0));
let listed = keys(&wb, "macro_");
assert_eq!(NAME_VISITS.with(|v| v.get()), wb.names.len());
assert_eq!(listed, all.keys().cloned().collect::<Vec<_>>(), "keys and items agree");
assert_eq!(listed.found, all.keys().cloned().collect::<Vec<_>>(), "keys and items agree");
assert_eq!((listing.more, listed.more), (10_002, 10_002), "the rest are counted, not lost");
// Under the cap every item is there, read as `get` reads it.
wb.names.retain(|n| !n.name.starts_with("_gc_macro_0") && !n.name.starts_with("_gc_macro_1"));
let few = items(&wb, "macro_");
let few = items(&wb, "macro_").found;
let want: BTreeMap<String, String> =
[("macro_big", "b".repeat(9_000)), ("macro_legacy", "old".into())].into_iter().map(|(k, t)| (k.to_string(), t)).collect();
assert_eq!(few, want);
for (k, text) in &few {
assert_eq!(get(&wb, k).as_ref(), Some(text));
}
assert_eq!(keys(&wb, "macro_"), ["macro_big", "macro_legacy"]);
assert_eq!(items(&wb, "").len(), 3, "the other item too");
assert_eq!(keys(&wb, "macro_").found, ["macro_big", "macro_legacy"]);
assert_eq!((items(&wb, "").found.len(), items(&wb, "").more), (3, 0), "the other item too, none past the cap");
}
#[test]
fn an_item_over_the_size_limit_takes_no_place_in_a_listing() {
// One item too large to read sorts first and one sorts last, around MAX_ITEMS readable
// ones: every readable item is listed, and neither oversized one is counted as more.
let mut wb = Workbook::new();
let oversized = |wb: &mut Workbook, key: &str| {
for (i, formula) in chunk_formulas(&"x".repeat(MAX_ITEM_CHARS + 1)).into_iter().enumerate() {
wb.names.push(name(&format!("_gc_{key}_{i}"), &formula));
}
};
oversized(&mut wb, "macro_0");
for i in 0..MAX_ITEMS {
wb.names.push(name(&format!("_gc_macro_{i:05}_0"), &format!("\"m{i}\"")));
}
oversized(&mut wb, "macro_z");
assert_eq!(get(&wb, "macro_0"), None, "too large to read");
let listing = items(&wb, "macro_");
assert_eq!((listing.found.len(), listing.more), (MAX_ITEMS, 0));
assert!(listing.found.contains_key("macro_09999"));
}
#[test]
+16 -1
View File
@@ -593,7 +593,7 @@ pub fn series(cx: &mut Ctx, sheet: usize, options: SeriesOptions<'_>) -> Result<
};
// The stop value bounds the series in the direction it runs (a growth by 0.5 falls).
if let Some(s) = stop
&& ((v >= prev && v > s) || (v < prev && v < s))
&& ((v >= prev && round15(v) > s) || (v < prev && round15(v) < s))
{
break;
}
@@ -806,6 +806,21 @@ mod tests {
assert_eq!(sh.value(CellRef::new(1, 1)), Value::from("03/12/2023"));
}
#[test]
fn series_keeps_value_equal_to_stop_after_float_drift() {
let mut s = crate::Session::new();
s.new_workbook();
s.execute("cell.set", serde_json::json!({"cell": "A1", "input": "1"})).unwrap();
s.execute("edit.fillSeries", serde_json::json!({"range": "A1:A6", "type": "linear", "step": 0.1, "stop": 1.3})).unwrap();
s.execute("cell.set", serde_json::json!({"cell": "B1", "input": "0.1"})).unwrap();
s.execute("edit.fillSeries", serde_json::json!({"range": "B1:B4", "type": "growth", "step": 3, "stop": 0.3})).unwrap();
let sh = s.doc().unwrap().wb.active().unwrap();
let col = |c: u32, n: u32| (0..n).map(|r| sh.value(CellRef::new(r, c))).collect::<Vec<_>>();
let n = Value::number;
assert_eq!(col(0, 6), vec![n(1.0), n(1.1), n(1.2), n(1.3), Value::Empty, Value::Empty]);
assert_eq!(col(1, 4), vec![n(0.1), n(0.3), Value::Empty, Value::Empty]);
}
#[test]
fn flash_programs() {
let p = learn(&[("John Smith".into(), "Smith, John".into())]).unwrap();
+49 -4
View File
@@ -118,8 +118,14 @@ fn decrypt_message(name: &str, e: gridcraft_xlsx::DecryptError) -> String {
}
}
/// Parses bytes that aren't encrypted.
/// Parses bytes that aren't encrypted, then takes the workbook's macros out of their hidden names.
fn open_plain(name: &str, bytes: &[u8], csv_as_text: bool) -> Result<(Workbook, Vec<String>)> {
let (mut wb, mut warnings) = read_plain(name, bytes, csv_as_text)?;
warnings.extend(crate::macros::load(&mut wb));
Ok((wb, warnings))
}
fn read_plain(name: &str, bytes: &[u8], csv_as_text: bool) -> Result<(Workbook, Vec<String>)> {
let kind = FileKind::from_path(name);
let sniffed = gridcraft_xlsx::sniff(bytes);
if sniffed == gridcraft_xlsx::Format::Xls {
@@ -160,7 +166,10 @@ fn open_plain(name: &str, bytes: &[u8], csv_as_text: bool) -> Result<(Workbook,
let mut wb: Workbook = serde_json::from_slice(bytes).map_err(|e| EngineError::Other(format!("not a GridCraft JSON workbook: {e}")))?;
wb.styles.rebuild_index();
if wb.sheets.is_empty() {
// The file's macros are read after this (`macros::load`), so they come along.
let macros = std::mem::take(&mut wb.macros_read);
wb = Workbook::new();
wb.macros_read = macros;
}
// The file's band placement and cell count are derived state: rebuild them from the
// cells themselves, dropping what is outside the sheet grid (#1008).
@@ -238,9 +247,12 @@ pub fn save_bytes_as(wb: &Workbook, path: &str, kind: FileKind) -> Result<Vec<u8
match kind {
// The extension picks the package kind; only the macro-enabled kinds (.xlsm, .xltm) carry
// the VBA project, so any other name saves without it, as Excel does.
// Macros travel in hidden names.
FileKind::Xlsx => {
let named = crate::macros::with_names(wb)?;
let book = std::path::Path::new(path).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or_default();
gridcraft_xlsx::write_xlsx_named(wb, gridcraft_xlsx::XlsxKind::from_path(path), book).map_err(|e| EngineError::Other(e.to_string()))
gridcraft_xlsx::write_xlsx_named(named.as_ref().unwrap_or(wb), gridcraft_xlsx::XlsxKind::from_path(path), book)
.map_err(|e| EngineError::Other(e.to_string()))
}
FileKind::Xls => {
Err(EngineError::Other("Excel 97-2003 (.xls) is supported for import only. Save as .xlsx or another supported export format.".into()))
@@ -250,7 +262,7 @@ pub fn save_bytes_as(wb: &Workbook, path: &str, kind: FileKind) -> Result<Vec<u8
// A workbook read from CSV keeps the separator it was read with (a `;` file stays `;`).
FileKind::Csv => wb.sheet(sheet).map(|sh| csv(wb, sh, wb.csv_delimiter.unwrap_or(b','))).transpose().map(Option::unwrap_or_default),
FileKind::Tsv => wb.sheet(sheet).map(|sh| csv(wb, sh, b'\t')).transpose().map(Option::unwrap_or_default),
FileKind::Json => serde_json::to_vec_pretty(wb).map_err(|e| EngineError::Other(e.to_string())),
FileKind::Json => crate::macros::json_bytes(wb),
FileKind::Html => to_html(wb, sheet).map(String::into_bytes),
}
}
@@ -294,6 +306,39 @@ fn csv(wb: &Workbook, sh: &Sheet, delimiter: u8) -> Result<Vec<u8>> {
.map_err(|e| EngineError::Other(e.to_string()))
}
/// The active sheet as CSV of raw cell values, for automation: numbers at full precision, dates and
/// times as ISO 8601, booleans `TRUE`/`FALSE`, errors as their text, text as stored. Save As CSV
/// writes displayed text instead ([`save_bytes`]) and stays the default.
pub fn csv_values_bytes(wb: &Workbook) -> Result<Vec<u8>> {
let Some(sh) = wb.sheet(wb.active_sheet()) else { return Ok(Vec::new()) };
gridcraft_xlsx::write_csv(sh, wb.csv_delimiter.unwrap_or(b','), |c| match sh.value(c) {
gridcraft_core::Value::Number(n) if n.is_finite() => {
let shape = crate::display::number_format(wb.styles.get(sh.style_id(c)).num_fmt.as_str()).date_shape(n);
match shape.zip(gridcraft_core::date::datetime_from_serial(wb.date_system, n)) {
Some(((date, time), d)) => iso(&d, date, time),
None => n.to_string(),
}
}
gridcraft_core::Value::Text(t) => t.to_string(),
v => crate::display::value_text_as(wb, &v, "General"),
})
.map_err(|e| EngineError::Other(e.to_string()))
}
/// `date` and `time` say which parts the cell's format shows; a format with neither is not a date.
fn iso(d: &gridcraft_core::date::DateTime, date: bool, time: bool) -> String {
let day = format!("{:04}-{:02}-{:02}", d.year, d.month, d.day);
let mut clock = format!("{:02}:{:02}:{:02}", d.hour, d.minute, d.second);
if d.milli != 0 {
clock.push_str(&format!(".{:03}", d.milli));
}
match (date, time) {
(true, true) => format!("{day}T{clock}"),
(false, true) => clock,
_ => day,
}
}
pub(crate) fn esc(s: &str) -> String {
s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
}
@@ -599,7 +644,7 @@ impl Drop for ScriptSandbox {
/// thread is being torn down).
fn script_sandbox_check(path: &str) -> Result<()> {
if SCRIPT_SANDBOX.try_with(|c| c.get() > 0).unwrap_or(true) {
return Err(EngineError::Other(format!("{path}: scripts can't read or write files")));
return Err(EngineError::Other(format!("{path}: scripts, and macro steps without the permission, can't read or write files")));
}
Ok(())
}
+1
View File
@@ -19,6 +19,7 @@ fn copied(wb: &Workbook, range: &str) -> String {
}
#[cfg(not(target_arch = "wasm32"))]
#[cfg(unix)]
#[test]
fn save_fails_upfront_when_the_temp_name_is_taken() {
// Another local user who can write the directory must not be able to point the
+122 -4
View File
@@ -15,6 +15,7 @@ pub mod display;
pub mod fill;
pub mod io;
pub mod links;
pub mod macros;
pub mod outline;
pub mod paper;
pub mod pivot;
@@ -54,6 +55,12 @@ pub enum EngineError {
NoDocument,
#[error("{0}")]
Other(String),
/// The command would replace something; run it again with `overwrite: true` to go ahead.
#[error("confirm: {0} (run again with `overwrite: true`)")]
Confirm(String),
/// Active content (a macro) asks to run; run it again with `approve: true` to allow it.
#[error("approve: {0} (run again with `approve: true`)")]
Approve(String),
#[error("internal error in `{0}` (the workbook was kept as it was): {1}")]
Internal(String, String),
}
@@ -97,6 +104,9 @@ struct OpenGroup {
selection: Selection,
/// The redo stack when the group began (a rollback puts it back).
redo: Vec<HistoryEntry>,
/// Where the recording was when the group began (`None` when nothing was recording): a
/// rollback takes back the steps recorded since, as it takes back their edits.
recorded: Option<(u64, u64)>,
}
/// Where the last [`Session::group_end`] or [`Session::group_rollback`] left its document: the
@@ -203,6 +213,12 @@ pub struct DocState {
/// workbook is opened from a file, carried by content pasted from such a workbook, and never
/// changed by a trust decision.
pub trust: Trust,
/// While a macro's footprint is measured: every cell a command wrote, by sheet name, even
/// where it wrote what was already there.
pub touched: Option<Vec<(String, CellRef)>>,
/// While a macro step runs: the structural edits its command made (the sheet's name, the
/// edit), so the macro's inputs, output and Repeat ranges follow their cells as formulas do.
pub edits: Option<Vec<(String, gridcraft_formula::adjust::Edit)>>,
/// The references `edit.goTo` with `record: true` went to in this document, newest first,
/// without names, each area written with its sheet (`Sheet1!B14:C22,Sheet1!F19:G30`).
pub go_to_recent: Vec<String>,
@@ -231,6 +247,10 @@ fn history_retained_bytes(older: &Workbook, newer: &Workbook) -> usize {
let next = newer.sheets.get(index).map_or(&empty, |next| &next.cells);
bytes = bytes.saturating_add(sheet.cells.retained_bytes_against(next));
}
// Snapshots share the macro list until a change to it; a snapshot with its own list keeps it.
if !older.macros.shares(&newer.macros) {
bytes = bytes.saturating_add(older.macros.estimated_bytes());
}
bytes
}
@@ -303,6 +323,8 @@ impl DocState {
content_kind: None,
scripts_trusted,
trust,
touched: None,
edits: None,
go_to_recent: vec![],
}
}
@@ -446,6 +468,9 @@ pub struct Session {
/// Serialized size of each journal entry, measured once when it is pushed, and their sum.
journal_sizes: Vec<usize>,
journal_bytes: usize,
/// While a dry run runs (the footprint, `macro.check`), its commands are not journaled: the
/// dry run is put back, and journaling it would evict the person's own oldest entries.
pub(crate) journal_paused: u32,
pub ui_requests: Vec<UiRequest>,
/// Format painter source style (sticky when double-clicked).
pub format_painter: Option<(Arc<Workbook>, usize, RangeRef, bool)>,
@@ -460,6 +485,17 @@ pub struct Session {
pub recorder: Option<record::Recorder>,
/// How many `execute` calls are running (0 between top-level commands).
depth: u32,
/// The steps the last successful `automate.runScript` or `macro.run` ran while a recorder was
/// on, for the recorder to keep with that step (see [`record::Recorder::ran`]); taken by the
/// recording hook.
pub(crate) script_ran: Option<Vec<(String, Json)>>,
/// Macros the person allowed to run in this session: (document uid, the macro's content as
/// canonical JSON, from [`macros::fingerprint`]).
pub macro_approvals: std::collections::HashSet<(u64, String)>,
/// The macro being debugged, between debugger actions.
pub macro_debug: Option<macros::debug::Debugger>,
/// Where the macro recorder started (beside `recorder`, its buffer).
pub macro_recording: Option<macros::recorder::RecordingStart>,
/// Draw tab tool: "" (select), "pen" or "eraser"; pen colour and width.
pub draw_tool: String,
pub draw_color: String,
@@ -535,6 +571,10 @@ impl Session {
if i < self.docs.len() {
let d = self.docs.remove(i);
self.groups.retain(|g| g.doc != d.uid);
// A macro being debugged in this workbook ends with it; its group just went.
if self.macro_debug.as_ref().is_some_and(|m| m.doc == d.uid) {
self.macro_debug = None;
}
self.windows.retain(|w| w.doc_uid != d.uid);
if self.active_window.is_some_and(|w| self.window(w).is_none()) {
self.activate_recent();
@@ -547,6 +587,7 @@ impl Session {
pub fn new_workbook(&mut self) -> usize {
let n = (1..).find(|n| !self.docs.iter().any(|d| d.title == format!("Book{n}") && d.path.is_none())).unwrap_or(1);
let mut wb = Workbook::new();
wb.props.author = self.prefs.user_name.clone();
for _ in 1..self.prefs.sheets_in_new_workbook.clamp(1, 255) {
let name = wb.next_sheet_name();
wb.sheets.push(Arc::new(gridcraft_model::Sheet::new(name)));
@@ -586,6 +627,16 @@ impl Session {
}
}
/// Cuts the journal back to its first `keep` entries, with its size bookkeeping.
pub(crate) fn truncate_journal(&mut self, keep: usize) {
if self.journal_sizes.len() == self.journal.len() {
self.journal_sizes.truncate(keep);
self.journal_bytes = self.journal_sizes.iter().sum();
}
self.journal.truncate(keep);
self.recording = self.recording.map(|r| r.min(self.journal.len()));
}
pub fn commands(&self) -> Vec<CommandInfo> {
command_specs().iter().map(|c| c.info(self)).collect()
}
@@ -598,7 +649,25 @@ impl Session {
if let Err(why) = (spec.enabled)(self) {
return Err(EngineError::Disabled(id.to_string(), why));
}
// A debugger whose undo group something else ended locks nothing any more (`macro.debug`
// itself says that the run had ended).
if id != "macro.debug" {
macros::debug::drop_if_ended(self);
}
let query = !spec.journal && !spec.undoable;
// While a macro is being debugged its undo group is open, and its workbook is locked:
// between steps only the debugger, queries and commands that look around run there
// (`macros::debug::LOOKS_AROUND`). Anything else waits, undoable or not (saving, view
// modes, stopping Record Actions, which saves a script), also when another command runs
// it. Only the run's own steps go ahead. Other workbooks stay editable.
let locked = self.active().is_some_and(|d| self.macro_debug.as_ref().is_some_and(|m| m.locks(d.uid) && !m.stepping()));
if locked && !macros::debug::runs_while_locked(id, query) {
return Err(EngineError::Disabled(id.to_string(), macros::debug::BUSY.into()));
}
let before = self.active().map(|d| (d.uid, d.wb.clone(), d.selection.clone()));
// A dry run inside this command that a panic cuts short leaves journaling paused and its
// touch log open; the panic branch puts both back.
let paused_before = self.journal_paused;
let params = if params.is_null() { Json::Object(Default::default()) } else { params };
let (first_group, group_epoch) = (self.next_group, self.group_epoch);
// Whether a group was open on the document before the command, and how long its history was.
@@ -608,7 +677,6 @@ impl Session {
// because a nested `edit.undo` removes entries. A query runs nothing that changes the
// history (`every_query_leaves_the_history_alone` runs each one), so it needs no
// copy; front ends run queries all the time.
let query = !spec.journal && !spec.undoable;
let history_before = before.as_ref().filter(|_| !query).and_then(|(uid, ..)| {
#[cfg(test)]
tests::HISTORY_COPIES.with(|n| n.set(n.get().saturating_add(1)));
@@ -669,7 +737,16 @@ impl Session {
}
d.calc.rebuild(&d.wb);
}
if self.journal_paused != paused_before {
self.journal_paused = paused_before;
for d in self.docs.iter_mut() {
d.touched = None;
}
}
if self.depth == 0 {
// Only the `macro.debug` call itself can lose the run's place: a command nested
// in a step that panics is an error of that step, which the run handles.
macros::debug::recover(self);
self.trim_history();
self.group_step = None;
}
@@ -687,13 +764,33 @@ impl Session {
// finds no recorder. A menu-style call that only opened a dialog (`file.open` with no
// file, `home.formatCells` with no style) is not recorded either: the dialog's own
// call is, so playing the recording back runs the command once, with its parameters.
// A call that opened a dialog and changed the workbook too (a script with an edit before
// a menu-style step) is recorded: the edit happened, and the dialog's confirming call
// is a step of its own.
let opened_dialog = self.ui_requests.get(requests_before..).is_some_and(|new| new.iter().any(|q| matches!(q, UiRequest::Dialog(..))));
if let Some((doc, sheet, sheet_name, selection)) = place.filter(|_| !opened_dialog)
let changed = before.as_ref().is_some_and(|(uid, wb, _)| self.docs.iter().any(|d| d.uid == *uid && !Arc::ptr_eq(&d.wb, wb)));
if let Some((doc, sheet, sheet_name, selection)) = place.filter(|_| (!opened_dialog || changed) && !record::NOT_RECORDED.contains(&id))
&& let Some(rec) = self.recorder.as_mut()
{
rec.push(record::RecordedStep { doc, sheet, sheet_name, selection, id: id.to_string(), params: cmd::journal_params(&params) });
// A toggle (Bold) is recorded with the state it set, as Excel records
// `Bold = True`: playing it back sets that state rather than flipping it again.
let params = match &result {
Ok(r) if params.is_object() => record::with_toggle_state(cmd::journal_params(&params), r),
_ => cmd::journal_params(&params),
};
let kept = rec.push(record::RecordedStep { doc, sheet, sheet_name, selection, id: id.to_string(), params });
// A script or macro run is kept with the steps it ran, so editing or deleting the
// script or macro before the recording stops does not change what was recorded.
if let Some(ran) = self.script_ran.take()
&& kept
&& matches!(id, "automate.runScript" | "macro.run")
{
rec.attach_ran(ran);
}
}
if self.journal_paused == 0 {
self.push_journal(id, cmd::journal_params(&params));
}
self.push_journal(id, cmd::journal_params(&params));
}
// Inside an undo group the group owns the step. A command that ended a group open before
// it leaves the group its step and makes one of its own for what it changed after the end.
@@ -808,6 +905,7 @@ impl Session {
wb: d.wb.clone(),
selection: d.selection.clone(),
redo: d.redo.clone(),
recorded: self.recorder.as_ref().map(record::Recorder::mark),
};
let token = GroupToken { id: g.id, doc: g.doc };
self.next_group = self.next_group.saturating_add(1);
@@ -904,6 +1002,11 @@ impl Session {
d.undo.truncate(g.start);
d.redo = g.redo;
d.revision += 1;
// The steps recorded while the group was open go with their edits (all of them when the
// recording started inside the group).
if let Some(rec) = self.recorder.as_mut() {
rec.cut_back(g.recorded.unwrap_or((0, 0)), g.doc);
}
// The record of the last group step stays when the document is back exactly where that
// step left it (this group began there); otherwise the rollback is where the group ended.
let kept = self.group_step.as_ref().is_some_and(|st| st.doc == g.doc && st.len == g.start && Arc::ptr_eq(&st.after, &d.wb));
@@ -935,6 +1038,21 @@ impl Session {
self.depth
}
/// Runs a command as a command in progress would (one level deeper), for tests of what a
/// nested call may do.
#[cfg(test)]
pub(crate) fn execute_nested(&mut self, id: &str, params: Json) -> Result<Json> {
self.depth = self.depth.saturating_add(1);
let r = self.execute(id, params);
self.depth = self.depth.saturating_sub(1);
r
}
/// Whether the token's undo group is still open (nothing has ended or rolled it back).
pub fn group_is_open(&self, token: &GroupToken) -> bool {
self.groups.iter().any(|g| g.id == token.id)
}
/// Whether an undo group is open on the document with this uid.
pub fn in_group(&self, doc: u64) -> bool {
self.groups.iter().any(|g| g.doc == doc)
+694
View File
@@ -0,0 +1,694 @@
//! The macro debugger: a run that stops between steps.
//!
//! `macro.debug` starts a run that pauses before its first step, then steps, continues to the next
//! breakpoint, runs to a chosen step, or stops. The run lives on the session between calls (one
//! action per call) inside an undo group that spans them; Stop keeps the changes as one undo step
//! or rolls them back. While a macro is being debugged its workbook is locked: only the
//! debugger, queries and the commands in [`LOOKS_AROUND`] run there, and everything else, saving
//! included, is refused ("Finish or stop the macro first"). Other workbooks stay editable. The
//! debugger runs on its own, never as a step of a script or macro, and is never recorded.
use std::collections::BTreeSet;
use std::sync::Arc;
use gridcraft_model::Workbook;
use serde_json::{Value as Json, json};
use super::run::{Machine, StepError, rolled_back};
use crate::{EngineError, GroupToken, Result, Selection, Session};
/// The most breakpoints one call sets.
pub const MAX_BREAKPOINTS: usize = 10_000;
/// The most entries `changed` lists (ranges and sheet notes), as the footprint lists ranges.
const MAX_LISTED: usize = 500;
/// Commands that run on the debugged workbook between steps, besides `macro.debug` and queries.
/// They only look around (the selection, the active sheet, Go To, Find, zoom, copying), switch to
/// another window, close the workbook (which ends the run) or stop the run (Stop Action). The
/// debugger puts the run's active sheet and selection back before its next step runs.
pub const LOOKS_AROUND: &[&str] = &[
"selection.set",
"selection.move",
"selection.next",
"selection.row",
"selection.column",
"selection.currentRegion",
"edit.selectAll",
"sheet.activate",
"sheet.next",
"sheet.previous",
"edit.goTo",
"edit.goToSpecial",
"edit.find",
"edit.copy",
"view.zoom",
"view.zoom100",
"view.zoomToSelection",
"window.activate",
"file.close",
"automate.stopAction",
];
/// Whether command `id` (a query when `query`) runs on a workbook the debugger locks.
pub fn runs_while_locked(id: &str, query: bool) -> bool {
// `macro.check` is a query, but its dry run runs the macro's steps, which the lock refuses: it
// waits too, rather than report the lock as the failure of a step.
(query && id != "macro.check") || id == "macro.debug" || LOOKS_AROUND.contains(&id)
}
/// A macro being debugged.
pub struct Debugger {
pub name: String,
pub doc: u64,
/// Out of the session only while a step runs.
machine: Option<Machine>,
token: Option<GroupToken>,
/// Breakpoints set for this run (step paths), on top of the steps' own.
pub breakpoints: BTreeSet<String>,
/// Only `breakpoints` stop the run; the steps' own flags are ignored (an editor that shows
/// its own breakpoints sends them all, so clearing one takes effect at once).
pub only_listed: bool,
/// The workbook when the run started, for "changed so far".
start: Arc<Workbook>,
/// Every step path in the macro, for checking `runTo` targets and breakpoints.
paths: BTreeSet<String>,
/// The workbooks the run has touched (by uid). Steps run on the macro's own workbook and the
/// policy refuses window and file commands, so this is the macro's workbook unless a command
/// reaches another one some other way; edits of these wait until the run ends.
touched: BTreeSet<u64>,
/// Steps the footprint's dry runs executed before the run (they count towards the run's
/// budget, not towards `executed`).
base: u64,
/// The conflict rule that shifted the data at the start (`shiftDown`, `shiftRight`).
shifted: Option<&'static str>,
/// Where the run left its workbook after the last action.
view: Option<View>,
}
/// The active sheet and the selections of the debugged workbook where the run left them.
#[derive(Clone)]
struct View {
sheet: usize,
selection: Selection,
sheet_selections: Vec<Selection>,
}
impl Debugger {
/// Whether edits of the workbook with this uid wait for the run to end.
pub fn locks(&self, doc: u64) -> bool {
self.touched.contains(&doc)
}
/// Whether one of the run's steps is running (its machine is out of the session).
pub fn stepping(&self) -> bool {
self.machine.is_none()
}
/// The run's own steps so far.
fn executed(&self) -> u64 {
self.machine.as_ref().map_or(0, |m| m.executed.saturating_sub(self.base))
}
}
/// Each open workbook's uid and its current version, to see which ones a step changes.
fn versions(s: &Session) -> Vec<(u64, Arc<Workbook>)> {
s.docs.iter().map(|d| (d.uid, d.wb.clone())).collect()
}
/// Adds to the run's touched log the workbooks that changed since `before`.
fn note_touched(s: &mut Session, before: &[(u64, Arc<Workbook>)]) {
let changed: Vec<u64> =
s.docs.iter().filter(|d| before.iter().any(|(uid, wb)| *uid == d.uid && !Arc::ptr_eq(wb, &d.wb))).map(|d| d.uid).collect();
if let Some(dbg) = s.macro_debug.as_mut() {
dbg.touched.extend(changed);
}
}
/// Remembers where the run left its workbook: the active sheet and the selections.
fn remember_view(s: &mut Session) {
let Some(uid) = s.macro_debug.as_ref().map(|d| d.doc) else { return };
let view = s.docs.iter().find(|d| d.uid == uid).map(|d| View {
sheet: d.wb.active_sheet(),
selection: d.selection.clone(),
sheet_selections: d.sheet_selections.clone(),
});
if let Some(dbg) = s.macro_debug.as_mut() {
dbg.view = view;
}
}
/// Puts back the active sheet and the selections where the run left them, so the next step runs
/// where `macro.run` would run it, however the person looked around meanwhile.
fn put_back_view(s: &mut Session) {
let Some((uid, Some(view))) = s.macro_debug.as_ref().map(|d| (d.doc, d.view.clone())) else { return };
let Some(d) = s.docs.iter_mut().find(|d| d.uid == uid) else { return };
if view.sheet < d.wb.sheets.len() {
// The active sheet is view state: setting it doesn't make the workbook dirty.
d.wb.set_active_sheet(view.sheet);
}
d.selection = view.selection;
d.sheet_selections = view.sheet_selections;
}
/// The message for edits refused while a macro is being debugged.
pub const BUSY: &str = "Finish or stop the macro first";
/// Ends a debugger whose undo group is no longer open (something other than the debugger ended
/// or rolled it back): its workbook is not locked any more. Returns the macro's name when it did.
pub(crate) fn drop_if_ended(s: &mut Session) -> Option<String> {
let d = s.macro_debug.as_ref()?;
if d.stepping() || d.token.as_ref().is_some_and(|t| s.group_is_open(t)) {
return None;
}
s.macro_debug.take().map(|d| d.name)
}
/// After Stop Action rolled back the undo groups of the workbook with uid `doc`: ends the
/// debugger if its run was there. Returns the macro's name and the files the run wrote, which stay.
pub(crate) fn end_stopped(s: &mut Session, doc: u64) -> Option<(String, Vec<String>)> {
if !s.macro_debug.as_ref().is_some_and(|d| d.doc == doc && !d.stepping()) {
return None;
}
let d = s.macro_debug.take()?;
Some((d.name, d.machine.map(|m| m.wrote_files).unwrap_or_default()))
}
/// Why an action finds the run already ended: its undo group was closed outside the debugger.
fn already_ended(name: &str) -> String {
format!("Macro '{name}' was no longer being debugged: its undo group had been closed outside the debugger, so nothing was kept or rolled back.")
}
enum Mode {
Step,
Continue,
RunTo(String),
}
const ACTIONS: [&str; 6] = ["start", "step", "continue", "runTo", "stop", "state"];
/// `macro.debug {name?, inputs?, action: start|step|continue|runTo|stop|state, step?, keep?,
/// breakpoints?, breakpointsOnly?}`.
pub fn debug(s: &mut Session, p: &Json) -> Result<Json> {
// A step of a script or macro that started the debugger would leave it behind when the
// script fails and rolls back its groups, the debugger's among them.
if s.depth() > 1 {
return Err(EngineError::Disabled("macro.debug".into(), "The macro debugger runs on its own, not as a step of a script or macro.".into()));
}
let ended = drop_if_ended(s);
let action = match p.get("action") {
None | Some(Json::Null) if s.macro_debug.is_some() || ended.is_some() => "state".to_string(),
None | Some(Json::Null) => "start".to_string(),
Some(Json::String(a)) => a.clone(),
// `{"action": {"runTo": {"step": "3"}}}`
Some(Json::Object(o)) if o.len() == 1 => o.keys().next().cloned().unwrap_or_default(),
Some(_) => return Err(bad("`action` must be start, step, continue, runTo, stop or state")),
};
if !ACTIONS.contains(&action.as_str()) {
return Err(bad(format!("action must be start, step, continue, runTo, stop or state, not '{action}'")));
}
if let Some(name) = ended
&& action != "start"
{
return Err(EngineError::Other(already_ended(&name)));
}
let only = bool_param(p, "breakpointsOnly")?;
if action == "start" {
return start(s, p, only.unwrap_or(false));
}
let keep = bool_param(p, "keep")?;
let d = s.macro_debug.as_ref().ok_or_else(not_debugging)?;
let target = if action == "runTo" {
let target = p
.get("step")
.or_else(|| p.pointer("/action/runTo/step"))
.and_then(path_of)
.ok_or_else(|| bad("`runTo` needs `step`: the path of the step to stop at"))?;
if !d.paths.contains(&target) {
return Err(bad(format!("macro '{}' has no step {target}", d.name)));
}
Some(target)
} else {
None
};
let breakpoints = p.get("breakpoints").filter(|v| !v.is_null()).map(|v| paths_param(v, &d.paths, &d.name)).transpose()?;
if let Some(d) = s.macro_debug.as_mut() {
if let Some(bps) = breakpoints {
d.breakpoints = bps;
}
if let Some(only) = only {
d.only_listed = only;
}
}
match (action.as_str(), target) {
("step", _) => advance(s, Mode::Step),
("continue", _) => advance(s, Mode::Continue),
("runTo", Some(target)) => advance(s, Mode::RunTo(target)),
("stop", _) => stop(s, keep.unwrap_or(false)),
_ => state(s),
}
}
fn bad(msg: impl Into<String>) -> EngineError {
crate::cmd::bad("macro.debug", msg)
}
fn not_debugging() -> EngineError {
EngineError::Other("No macro is being debugged. Start one with action: start.".into())
}
/// `true`, `false`, or not given (`null` counts as not given); anything else is refused.
fn bool_param(p: &Json, key: &str) -> Result<Option<bool>> {
match p.get(key) {
None | Some(Json::Null) => Ok(None),
Some(Json::Bool(b)) => Ok(Some(*b)),
Some(_) => Err(bad(format!("`{key}` must be true or false"))),
}
}
/// A step path from JSON: `"2.1"` or `3`.
fn path_of(v: &Json) -> Option<String> {
match v {
Json::String(t) if !t.trim().is_empty() => Some(t.trim().to_string()),
Json::Number(n) => n.as_u64().map(|n| n.to_string()),
_ => None,
}
}
/// The `breakpoints` list: step paths of macro `name` (one of `paths`), at most
/// [`MAX_BREAKPOINTS`].
fn paths_param(v: &Json, paths: &BTreeSet<String>, name: &str) -> Result<BTreeSet<String>> {
let list = v.as_array().ok_or_else(|| bad("`breakpoints` must be a list of step paths"))?;
if list.len() > MAX_BREAKPOINTS {
return Err(bad(format!("`breakpoints` lists {} steps; at most {MAX_BREAKPOINTS} can be set", list.len())));
}
let mut out = BTreeSet::new();
for x in list {
let path = path_of(x).ok_or_else(|| bad("a breakpoint is a step path such as \"3\" or \"2.1\""))?;
if !paths.contains(&path) {
return Err(bad(format!("macro '{name}' has no step {path} for a breakpoint")));
}
out.insert(path);
}
Ok(out)
}
fn start(s: &mut Session, p: &Json, only_listed: bool) -> Result<Json> {
if let Some(d) = &s.macro_debug {
return Err(EngineError::Other(format!("Macro '{}' is being debugged. Stop it first.", d.name)));
}
let name = p.get("name").and_then(Json::as_str).filter(|n| !n.trim().is_empty()).ok_or_else(|| bad("missing `name`"))?;
let b = super::run::begin(s, name, p)?;
let paths = all_paths(&b.mac.steps, "");
let breakpoints = match p.get("breakpoints").filter(|v| !v.is_null()) {
Some(v) => paths_param(v, &paths, &b.mac.name)?,
None => BTreeSet::new(),
};
let start_wb = s.doc()?.wb.clone();
let token = s.group_begin(&format!("Run Macro: {}", b.mac.name))?;
// The run's machine: unless the rule is overwrite, it checks each step for writes over data.
let (mut machine, shifted) = match super::run::make_way(s, &b) {
Ok(made) => made,
Err(e) => {
let _ = s.group_rollback(token);
return Err(e);
}
};
// Each action has the time limit to itself; the dry runs just had theirs.
machine.restart_clock();
let base = machine.executed;
s.macro_debug = Some(Debugger {
name: b.mac.name.clone(),
doc: b.prepared.doc,
machine: Some(machine),
token: Some(token),
breakpoints,
only_listed,
start: start_wb,
paths,
touched: BTreeSet::from([b.prepared.doc]),
base,
shifted: shifted.then(|| b.rule.name()),
view: None,
});
if s.macro_debug.as_ref().and_then(|d| d.machine.as_ref()).is_some_and(Machine::is_done) {
return finish(s, None);
}
remember_view(s);
state(s)
}
fn all_paths(steps: &[gridcraft_model::Step], prefix: &str) -> BTreeSet<String> {
let mut out = BTreeSet::new();
for (i, st) in steps.iter().enumerate() {
let path = if prefix.is_empty() { (i + 1).to_string() } else { format!("{prefix}.{}", i + 1) };
match &st.kind {
gridcraft_model::StepKind::Repeat { body, .. } => out.extend(all_paths(body, &path)),
gridcraft_model::StepKind::If { then, otherwise, .. } => {
out.extend(all_paths(then, &format!("{path}.then")));
out.extend(all_paths(otherwise, &format!("{path}.else")));
}
_ => {}
}
out.insert(path);
}
out
}
/// Runs one step (`Step`), or steps until a breakpoint, the target step or the end.
fn advance(s: &mut Session, mode: Mode) -> Result<Json> {
put_back_view(s);
let d = s.macro_debug.as_mut().ok_or_else(not_debugging)?;
let (name, base) = (d.name.clone(), d.base);
let breakpoints = d.breakpoints.clone();
let only_listed = d.only_listed;
let mut machine = d.machine.take().ok_or_else(not_debugging)?;
machine.restart_clock();
let before = versions(s);
let mut first = true;
let mut hit = false;
let mut outcome = Ok(());
while !machine.is_done() {
if !first {
let Some((path, st)) = machine.current() else { break };
if matches!(mode, Mode::Step) {
break;
}
if (st.breakpoint && !only_listed) || breakpoints.contains(&path) {
hit = true;
break;
}
if let Mode::RunTo(target) = &mode
&& *target == path
{
break;
}
}
first = false;
outcome = machine.step(s);
if outcome.is_err() {
break;
}
}
let done = machine.is_done();
match s.macro_debug.as_mut() {
Some(d) => d.machine = Some(machine),
// The macro.debug call panicked and `recover` has already rolled the run back.
None => {
return match outcome {
Err(e) => Ok(failed(&name, machine.executed.saturating_sub(base), &e)),
Ok(()) => Err(not_debugging()),
};
}
}
note_touched(s, &before);
if let Err(e) = outcome {
return finish(s, Some(e));
}
if done {
return finish(s, None);
}
remember_view(s);
let mut out = state(s)?;
out["atBreakpoint"] = json!(hit);
Ok(out)
}
/// What every answer about the run's end or its state adds: the data shift at the start and the
/// files the run wrote (they stay, whatever happens to the workbook).
fn add_notes(out: &mut Json, d: &Debugger, wrote_files: &[String]) {
if let Some(rule) = d.shifted {
out["shifted"] = json!(rule);
}
if !wrote_files.is_empty() {
out["wroteFiles"] = json!(wrote_files);
}
}
/// Ends the run after its last step (kept as one undo step, unless a Stop step asked for a
/// rollback) or a failed step (rolled back).
fn finish(s: &mut Session, error: Option<StepError>) -> Result<Json> {
let Some(mut d) = s.macro_debug.take() else { return Err(not_debugging()) };
let (changed, cells) = changed(s, &d);
let executed = d.executed();
let machine = d.machine.take();
let stopped = machine.as_ref().and_then(|m| m.stopped.clone());
let wrote = machine.map(|m| m.wrote_files).unwrap_or_default();
let rollback = error.is_some() || matches!(stopped, Some((_, true)));
let ended = match d.token.take() {
Some(token) if rollback => s.group_rollback(token).is_ok(),
Some(token) => s.group_end(token).is_ok(),
None => false,
};
if !ended {
let step = error.map(|e| format!("{} ", e.describe())).unwrap_or_default();
return Err(EngineError::Other(format!("{step}{}", already_ended(&d.name))));
}
if let Some(e) = &error {
return Ok(failed(&d.name, executed, e));
}
let mut out = json!({"macro": d.name, "executed": executed});
match &stopped {
Some((msg, rolled)) => {
out["state"] = json!("stopped");
out["stopped"] = json!(msg);
out["kept"] = json!(!rolled);
if *rolled {
out["message"] = json!(rolled_back(&wrote));
}
}
None => out["state"] = json!("finished"),
}
if !rollback {
out["changed"] = json!(changed);
out["changedCells"] = json!(cells);
}
add_notes(&mut out, &d, &wrote);
Ok(out)
}
/// The result of a run that a failed step rolled back.
fn failed(name: &str, executed: u64, e: &StepError) -> Json {
let mut out = json!({
"macro": name,
"executed": executed,
"state": "failed",
"error": serde_json::to_value(e).unwrap_or(Json::Null),
"message": format!("{} {}", e.describe(), rolled_back(&e.wrote_files)),
});
if !e.wrote_files.is_empty() {
out["wroteFiles"] = json!(e.wrote_files);
}
out
}
/// Stops the run: keeps what it did as one undo step, or rolls it back.
fn stop(s: &mut Session, keep: bool) -> Result<Json> {
let Some(mut d) = s.macro_debug.take() else { return Err(not_debugging()) };
let (changed, cells) = changed(s, &d);
let ended = match d.token.take() {
Some(token) if keep => s.group_end(token).is_ok(),
Some(token) => s.group_rollback(token).is_ok(),
None => false,
};
if !ended {
return Err(EngineError::Other(already_ended(&d.name)));
}
let wrote = d.machine.as_ref().map(|m| m.wrote_files.clone()).unwrap_or_default();
let mut out = json!({"state": "stopped", "macro": d.name, "kept": keep, "executed": d.executed()});
if keep {
out["changed"] = json!(changed);
out["changedCells"] = json!(cells);
} else {
out["message"] = json!(rolled_back(&wrote));
}
add_notes(&mut out, &d, &wrote);
Ok(out)
}
/// Where the run is: the next step with its parameters as they would run, the step that just
/// ran, and what the run has changed in its workbook so far.
fn state(s: &mut Session) -> Result<Json> {
let d = s.macro_debug.as_ref().ok_or_else(not_debugging)?;
let machine = d.machine.as_ref().ok_or_else(not_debugging)?;
let wb = s.docs.iter().find(|x| x.uid == d.doc).map(|x| x.wb.clone()).ok_or(EngineError::NoDocument)?;
let (changed, cells) = changed(s, d);
let mut out = json!({
"state": "paused",
"macro": d.name,
"current": machine.current_json(&wb),
"lastParams": machine.last_params,
"executed": d.executed(),
"breakpoints": d.breakpoints,
"changed": changed,
"changedCells": cells,
});
add_notes(&mut out, d, &machine.wrote_files);
Ok(out)
}
/// What the run changed in its workbook since it started: cells as `Sheet!A1:B2` ranges and the
/// sheets it added, deleted or renamed (at most [`MAX_LISTED`] entries), and how many cells
/// changed on the sheets the workbook had at the start.
fn changed(s: &Session, d: &Debugger) -> (Vec<String>, usize) {
let Some(now) = s.docs.iter().find(|x| x.uid == d.doc).map(|x| x.wb.clone()) else { return (vec![], 0) };
let (pairs, added) = super::run::pair_sheets(&d.start, &now);
let mut out = Vec::new();
let mut count = 0usize;
for (bi, ai) in pairs {
let Some(before) = d.start.sheets.get(bi) else { continue };
let Some(after) = ai.and_then(|a| now.sheets.get(a)) else {
out.push(format!("{} (deleted sheet)", before.name));
continue;
};
if before.name != after.name {
out.push(format!("{} (renamed from {})", after.name, before.name));
}
if Arc::ptr_eq(before, after) {
continue;
}
let mut cells = before.cells.diff(&after.cells);
count = count.saturating_add(cells.len());
if out.len() < MAX_LISTED {
let q = super::resolve::quote_sheet(&after.name);
out.extend(super::run::ranges(&mut cells).into_iter().map(|r| format!("{q}!{r}")));
}
}
out.extend(added.into_iter().filter_map(|a| now.sheets.get(a)).map(|sh| format!("{} (new sheet)", sh.name)));
out.truncate(MAX_LISTED);
(out, count)
}
/// After the `macro.debug` call itself panicked: if the run lost its place, roll it back so its
/// undo group doesn't stay open.
pub(crate) fn recover(s: &mut Session) {
if s.macro_debug.as_ref().is_some_and(Debugger::stepping)
&& let Some(mut d) = s.macro_debug.take()
&& let Some(token) = d.token.take()
{
let _ = s.group_rollback(token);
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use crate::Session;
#[test]
fn a_run_that_lost_its_place_is_rolled_back() {
let mut s = Session::new();
s.new_workbook();
let m = crate::macros::parse_macro(&json!({"name": "M", "output": {"onConflict": "overwrite"}, "steps": [
{"command": "cell.set", "params": {"cell": "A1", "input": "x"}},
{"command": "cell.set", "params": {"cell": "A2", "input": "y"}}
]}))
.unwrap();
let d = s.doc_mut().unwrap();
let mut wb = (*d.wb).clone();
wb.macros.push(m);
d.wb = std::sync::Arc::new(wb);
let start = s.doc().unwrap().wb.clone();
s.execute("macro.debug", json!({"name": "M"})).unwrap();
s.execute("macro.debug", json!({"action": "step"})).unwrap();
// As if a step had panicked with the machine out of the session.
s.macro_debug.as_mut().unwrap().machine = None;
super::recover(&mut s);
assert!(s.macro_debug.is_none());
assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().wb, &start));
assert!(!s.in_group(s.doc().unwrap().uid));
s.execute("cell.set", json!({"cell": "B1", "input": "free"})).unwrap();
}
#[test]
fn a_step_that_panics_reports_the_step_failure() {
crate::register_test_command("test.debugStepPanics", |_, _| panic!("boom"));
let mut s = Session::new();
s.new_workbook();
let m = crate::macros::parse_macro(&json!({"name": "M", "output": {"onConflict": "overwrite"}, "steps": [
{"command": "cell.set", "params": {"cell": "A1", "input": "x"}},
{"command": "test.debugStepPanics", "params": {}}
]}))
.unwrap();
let d = s.doc_mut().unwrap();
let mut wb = (*d.wb).clone();
wb.macros.push(m);
d.wb = std::sync::Arc::new(wb);
let start = s.doc().unwrap().wb.clone();
s.execute("macro.debug", json!({"name": "M"})).unwrap();
s.execute("macro.debug", json!({"action": "step"})).unwrap();
let r = s.execute("macro.debug", json!({"action": "step"})).unwrap();
assert_eq!(r["state"], "failed", "{r}");
assert_eq!(r["macro"], "M");
assert_eq!(r["error"]["step"], "2");
assert!(r["message"].as_str().unwrap().contains("boom"), "{r}");
assert!(s.macro_debug.is_none());
assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().wb, &start));
assert!(!s.in_group(s.doc().unwrap().uid));
}
#[test]
fn a_workbook_in_the_touched_log_waits_until_the_run_ends() {
let mut s = Session::new();
s.new_workbook();
let m = crate::macros::parse_macro(&json!({"name": "M", "output": {"onConflict": "overwrite"}, "steps": [
{"command": "cell.set", "params": {"cell": "A1", "input": "x"}},
{"command": "cell.set", "params": {"cell": "A2", "input": "y"}}
]}))
.unwrap();
let d = s.doc_mut().unwrap();
let mut wb = (*d.wb).clone();
wb.macros.push(m);
d.wb = std::sync::Arc::new(wb);
s.new_workbook();
let other = s.doc().unwrap().uid;
s.set_active(0);
s.execute("macro.debug", json!({"name": "M"})).unwrap();
assert!(!s.macro_debug.as_ref().unwrap().locks(other));
// As if a step had changed the other workbook too.
let before = super::versions(&s);
let d = s.docs.iter_mut().find(|d| d.uid == other).unwrap();
let mut wb = (*d.wb).clone();
wb.props.title = "changed by the macro".into();
d.wb = std::sync::Arc::new(wb);
super::note_touched(&mut s, &before);
assert!(s.macro_debug.as_ref().unwrap().locks(other));
s.set_active(1);
assert!(s.execute("cell.set", json!({"cell": "B1", "input": "no"})).is_err());
s.execute("macro.debug", json!({"action": "stop", "keep": true})).unwrap();
s.execute("cell.set", json!({"cell": "B1", "input": "free"})).unwrap();
}
#[test]
fn edits_run_by_another_command_wait_too() {
let mut s = Session::new();
s.new_workbook();
let m = crate::macros::parse_macro(&json!({"name": "M", "output": {"onConflict": "overwrite"}, "steps": [
{"command": "cell.set", "params": {"cell": "A1", "input": "x"}},
{"command": "cell.set", "params": {"cell": "A2", "input": "y"}}
]}))
.unwrap();
let d = s.doc_mut().unwrap();
let mut wb = (*d.wb).clone();
wb.macros.push(m);
d.wb = std::sync::Arc::new(wb);
s.execute("automate.recordActions", json!({})).unwrap();
s.execute("cell.set", json!({"cell": "C1", "input": "1"})).unwrap();
s.execute("macro.debug", json!({"name": "M"})).unwrap();
s.execute("macro.debug", json!({"action": "step"})).unwrap();
// Record Actions changes nothing itself; stopping it saves the script with a nested
// command, which would land in the run's undo group.
let e = s.execute("automate.recordActions", json!({"stop": true, "name": "S"})).unwrap_err().to_string();
assert!(e.contains(super::BUSY), "{e}");
assert!(s.execute("automate.allScripts", json!({})).unwrap().as_array().unwrap().is_empty());
// The run's own steps still go ahead.
let r = s.execute("macro.debug", json!({"action": "step"})).unwrap();
assert_eq!(r["executed"], 2, "{r}");
assert!(s.macro_debug.is_none());
// The recording was kept: stopping now saves it.
let saved = s.execute("automate.recordActions", json!({"stop": true, "name": "S"})).unwrap();
assert_eq!(saved["saved"], "S", "{saved}");
let scripts = s.execute("automate.allScripts", json!({})).unwrap();
assert_eq!(scripts[0]["name"], "S", "{scripts}");
}
}
+515
View File
@@ -0,0 +1,515 @@
//! GridCraft macros: typed, declarative command lists, not VBA. The model is plain data in
//! `gridcraft_model::Macro`; this module reads and writes it and keeps it within its limits.
//!
//! Storage: the JSON format keeps `Workbook::macros` as a field. In xlsx each macro is one item of
//! GridCraft's hidden workbook data ([`crate::cmd::stash`]), `_gc_macro_<n>_<k>`, written when the
//! file is saved and taken back out of the names when it is opened, so the names never linger in
//! the open document. Scripts made with Automate › New Script or Record Actions are a separate,
//! older feature with their own storage; macros neither read nor change it. A recording keeps the
//! steps a script run ran while it recorded, as the run reported them.
use std::collections::{BTreeMap, HashMap, HashSet};
use gridcraft_model::{DefinedName, Macro, Step, StepKind, Workbook};
use serde_json::{Value as Json, json};
use crate::cmd::stash;
pub mod debug;
pub mod policy;
pub mod recorder;
pub mod resolve;
pub mod run;
/// The hidden-data key prefix of a stored macro (`macro_<n>`).
const MACRO_KEY: &str = "macro_";
/// Reads a macro from JSON, as `macro.save` and files give it. A command step may also use the
/// short form `{"command": "cell.set", "params": {…}}`.
pub fn parse_macro(v: &Json) -> Result<Macro, String> {
// A newer format is refused before its steps are read, so the message says why.
if let Some(found) = v.get("v").and_then(Json::as_u64) {
gridcraft_model::check_version(found)?;
}
let mut v = v.clone();
if let Some(steps) = v.get_mut("steps") {
normalize_steps(steps, 1)?;
}
if v.get("v").is_none()
&& let Some(o) = v.as_object_mut()
{
o.insert("v".into(), json!(gridcraft_model::MACRO_VERSION));
}
let mut m: Macro = serde_json::from_value(v).map_err(|e| format!("This isn't a valid macro: {e}"))?;
m.name = m.name.trim().to_string();
check(&m)?;
Ok(m)
}
/// Every check a macro must pass before it is kept in a workbook: the model's limits
/// ([`Macro::check_limits`]) and the size an xlsx file can hold. Every path that adds a macro,
/// from JSON, from a file or built in code, goes through this one check.
pub fn check(m: &Macro) -> Result<(), String> {
m.check_limits()?;
check_size(m)
}
/// Refuses a macro too large to keep in an xlsx file ([`stash::MAX_ITEM_CHARS`] characters of
/// JSON). Every command that adds or changes a macro calls [`check`], so the size of a macro kept
/// in the workbook doesn't make a save fail. (A clashing visible name doesn't either:
/// [`with_names`] stores the macro under another index.)
fn check_size(m: &Macro) -> Result<(), String> {
if macro_json(m).to_string().chars().count() > stash::MAX_ITEM_CHARS {
return Err(format!("This macro is too large to keep in the workbook ({} characters at most).", stash::MAX_ITEM_CHARS));
}
Ok(())
}
/// Leaves out the last top-level steps of `m` until it fits the size [`check_size`] allows, and
/// returns how many steps it left out (a recording cut to fit, as at the step limit). Each step's
/// JSON is measured once.
pub fn fit_size(m: &mut Macro) -> usize {
let chars = |v: &Json| v.to_string().chars().count();
let steps = std::mem::take(&mut m.steps);
// The macro without steps holds `"steps":[]`; each step adds its JSON, and a comma after the
// first.
let mut size = chars(&macro_json(m));
let mut kept = 0usize;
for (i, st) in steps.iter().enumerate() {
let add = chars(&serde_json::to_value(st).unwrap_or(Json::Null)).saturating_add(usize::from(i > 0));
if size.saturating_add(add) > stash::MAX_ITEM_CHARS {
break;
}
size = size.saturating_add(add);
kept = i.saturating_add(1);
}
let cut = steps.len().saturating_sub(kept);
m.steps = steps;
m.steps.truncate(kept);
cut
}
/// How many macros the workbook holds: the readable ones and the ones kept in a newer format
/// (`Workbook::macros_unread`), which a save writes back and the next open counts too.
pub fn count(wb: &Workbook) -> usize {
wb.macros.len().saturating_add(wb.macros_unread.len())
}
/// Refuses one more macro in a workbook that holds [`gridcraft_model::MAX_MACROS`] already,
/// counting the kept ones, so a save never holds more than the next open keeps.
pub fn check_room(wb: &Workbook) -> Result<(), String> {
let max = gridcraft_model::MAX_MACROS;
if count(wb) < max {
return Ok(());
}
Err(match wb.macros_unread.len() {
0 => format!("A workbook can hold at most {max} macros."),
kept => format!(
"A workbook can hold at most {max} macros, and {kept} of the {} in this one {} made by a newer version of GridCraft and kept unchanged.",
count(wb),
if kept == 1 { "was" } else { "were" }
),
})
}
/// The name of the macro kept in a newer format called `name` (compared as [`find`] compares),
/// as the file held it. Its name is taken: a new or renamed macro with it would clash when the
/// newer version reads the file.
pub fn kept_name(wb: &Workbook, name: &str) -> Option<String> {
let name = name.trim();
wb.macros_unread
.iter()
.filter_map(|v| v.get("name").and_then(Json::as_str))
.map(str::trim)
.find(|k| k.eq_ignore_ascii_case(name))
.map(str::to_string)
}
/// Rewrites the short step form into the tagged one, in place, refusing nesting past the limit
/// before the steps are deserialized.
fn normalize_steps(steps: &mut Json, level: usize) -> Result<(), String> {
let Some(list) = steps.as_array_mut() else { return Err("`steps` must be a list.".into()) };
for st in list {
let Some(o) = st.as_object_mut() else { return Err("Every step must be an object.".into()) };
if !o.contains_key("type")
&& let Some(id) = o.remove("command")
{
o.insert("type".into(), json!("command"));
o.insert("id".into(), id);
}
// A step with a body is a Repeat or an If: one at the top is nested 1 deep.
let nested = ["body", "then", "else"].iter().any(|key| o.contains_key(*key));
if nested && level > gridcraft_model::MAX_MACRO_DEPTH {
return Err(format!("Repeat and If steps can nest at most {} deep.", gridcraft_model::MAX_MACRO_DEPTH));
}
for key in ["body", "then", "else"] {
if let Some(body) = o.get_mut(key) {
normalize_steps(body, level + 1)?;
}
}
}
Ok(())
}
/// A macro as JSON (the shape `macro.get` returns and `macro.save` takes).
pub fn macro_json(m: &Macro) -> Json {
serde_json::to_value(m).unwrap_or(Json::Null)
}
/// The index of the macro called `name` (case-insensitive).
pub fn find(wb: &Workbook, name: &str) -> Option<usize> {
let name = name.trim();
wb.macros.iter().position(|m| m.name.eq_ignore_ascii_case(name))
}
/// A macro's content as canonical JSON text (object keys sorted): approval to run a macro holds
/// until the macro changes. It is the text itself rather than a hash, so no two different macros
/// share it.
pub fn fingerprint(m: &Macro) -> String {
macro_json(m).to_string()
}
/// A copy of `wb` for xlsx with each macro in hidden names (`_gc_macro_<n>_<k>`) instead of the
/// field, or `None` when there is nothing to change. The macros keep their order; the index `n`
/// is this build's choice, so it steps around any a person's own visible name already uses.
pub fn with_names(wb: &Workbook) -> crate::Result<Option<Workbook>> {
// Only the numbered items are this build's: a hidden `_gc_macro_<x>_<k>` name with some
// other key stays, as `load` leaves it.
let stored = |n: &DefinedName| macro_item(n).is_some_and(|(_, i)| i.is_some());
if wb.macros.is_empty() && wb.macros_unread.is_empty() && !wb.names.iter().any(stored) {
return Ok(None);
}
// A macro that skipped `check` (built in code) fails the save, as the JSON save does, rather
// than being written and dropped the next time the file opens.
for m in wb.macros.iter() {
check(m).map_err(crate::EngineError::Other)?;
}
let mut out = wb.clone();
// One pass drops the items of the last save, and one writes the macros (a file can hold a
// thousand macros and many thousands of names).
out.names.retain(|n| !stored(n));
// A visible workbook-scope name of the storage shape is someone's own, and `set_many` won't
// write a hidden one of the same name beside it. Such a name (`_GC_MACRO_1_0`, or one a
// person defined after the file opened) blocks its item's key, in any chunk, so the macros
// take the free indices from 0 up. The keys compare as the stash reads them, so this skips
// exactly what `set_many` would refuse, and no arithmetic depends on a number from the file.
let blocked: HashSet<String> = out.names.iter().filter(|n| !n.hidden && n.scope.is_none()).filter_map(|n| stash::item_key(&n.name)).collect();
let free = (0..=u64::MAX).map(|i| format!("{MACRO_KEY}{i}")).filter(|key| !blocked.contains(key));
// Macros in a newer format follow the readable ones, written back as the file held them.
let texts = wb.macros.iter().map(|m| macro_json(m).to_string()).chain(wb.macros_unread.iter().map(Json::to_string));
let items: Vec<(String, String)> = texts.zip(free).map(|(text, key)| (key, text)).collect();
stash::set_many(&mut out, &items)?;
// An empty list of its own, not a copy of the shared one cleared.
out.macros = gridcraft_model::Macros::default();
out.macros_unread = std::sync::Arc::default();
Ok(Some(out))
}
/// The JSON format's bytes for `wb`: the workbook, with the macros in a newer format put back into
/// the `macros` list as the file held them.
pub fn json_bytes(wb: &Workbook) -> crate::Result<Vec<u8>> {
// Every path that adds a macro checks it, so this refuses only one that skipped the check, as
// the xlsx save does, instead of writing a macro the next open would drop.
for m in &wb.macros {
check(m).map_err(crate::EngineError::Other)?;
}
let bytes = if wb.macros_unread.is_empty() {
serde_json::to_vec_pretty(wb)
} else {
serde_json::to_value(wb).and_then(|mut v| {
if let Some(list) = v.as_object_mut().map(|o| o.entry("macros").or_insert_with(|| Json::Array(vec![]))).and_then(Json::as_array_mut) {
list.extend(wb.macros_unread.iter().cloned());
}
serde_json::to_vec_pretty(&v)
})
};
bytes.map_err(|e| crate::EngineError::Other(e.to_string()))
}
/// Whether `v` is a macro in a newer format than this build reads.
fn is_newer(v: &Json) -> bool {
v.get("v").and_then(Json::as_u64).is_some_and(|found| found > u64::from(gridcraft_model::MACRO_VERSION))
}
/// Keeps a macro in a newer format unread, if it is small enough to save back
/// ([`stash::MAX_ITEM_CHARS`] characters of JSON, the size every xlsx save can write).
fn keep_unread(unread: &mut Vec<Json>, v: Json, bad: &mut usize) {
if v.to_string().chars().count() <= stash::MAX_ITEM_CHARS {
unread.push(v);
} else {
*bad = bad.saturating_add(1);
}
}
/// After a file is opened: moves macros out of hidden names into `Workbook::macros`, keeps
/// macros in a newer format unread (`Workbook::macros_unread`), and drops macros that are damaged
/// or over the limits. Returns a warning for what was kept unread and one for what was dropped.
pub fn load(wb: &mut Workbook) -> Vec<String> {
let mut warnings = Vec::new();
let mut bad = 0usize;
let mut unread = Vec::new();
// Entries of the JSON format's field, each read on its own so a damaged one drops alone.
let read = std::mem::take(&mut wb.macros_read);
bad = bad.saturating_add(read.len().saturating_sub(gridcraft_model::MAX_MACROS));
for v in read.into_iter().take(gridcraft_model::MAX_MACROS) {
if is_newer(&v) {
keep_unread(&mut unread, v, &mut bad);
continue;
}
match parse_macro(&v) {
Ok(m) => wb.macros.push(m),
Err(_) => bad += 1,
}
}
// One pass takes the hidden macro names out of the workbook, grouped by macro in saved order.
let mut stored: BTreeMap<(u64, String), Vec<DefinedName>> = BTreeMap::new();
let mut kept = Vec::new();
for n in std::mem::take(&mut wb.names) {
match macro_item(&n) {
None => kept.push(n),
Some((key, Some(i))) => stored.entry((i, key)).or_default().push(n),
// A `macro_` item that isn't numbered isn't one this build wrote: it stays as it is,
// unread, and a save keeps it.
Some((_, None)) => kept.push(n),
}
}
wb.names = kept;
// Past the macro limit, the rest are counted without being read.
bad = bad.saturating_add(stored.len().saturating_sub(gridcraft_model::MAX_MACROS));
let mut found = Vec::new();
let mut item = Workbook::new();
for ((_, key), names) in stored.into_iter().take(gridcraft_model::MAX_MACROS) {
item.names = names;
let v = stash::get_json(&item, &key);
if let Some(v) = v.as_ref().filter(|v| is_newer(v)) {
keep_unread(&mut unread, v.clone(), &mut bad);
continue;
}
match v.ok_or_else(|| "unreadable".to_string()).and_then(|v| parse_macro(&v)) {
Ok(m) => found.push(m),
Err(_) => bad += 1,
}
}
// All of them go through the same limits, and a clashing name gets a suffix.
let mut all = std::mem::take(&mut wb.macros);
all.extend(found);
let mut taken = Taken::default();
for m in all {
if wb.macros.len() >= gridcraft_model::MAX_MACROS {
bad += 1;
continue;
}
let mut m = m;
let checked = m.check_limits().and_then(|()| {
m.name = taken.unique(m.name.trim());
// The macro as it is kept, renamed, must still pass, or the next save would lose it.
check(&m)
});
match checked {
Ok(()) => {
taken.insert(&m.name);
wb.macros.push(m);
}
Err(_) => bad += 1,
}
}
// Readable and unread macros together stay within the limit, the readable ones first.
let room = gridcraft_model::MAX_MACROS.saturating_sub(wb.macros.len());
bad = bad.saturating_add(unread.len().saturating_sub(room));
unread.truncate(room);
if !unread.is_empty() {
let one = unread.len() == 1;
warnings.push(format!(
"{} macro{} in the workbook {} made by a newer version of GridCraft: this version can't show or run {}, and saving keeps {} unchanged.",
unread.len(),
if one { "" } else { "s" },
if one { "was" } else { "were" },
if one { "it" } else { "them" },
if one { "it" } else { "them" },
));
}
wb.macros_unread = std::sync::Arc::new(unread);
if bad > 0 {
warnings.push(format!(
"{bad} macro{} in the workbook couldn't be opened: {} damaged or over the limits ({} macros, {} steps each, Repeat and If nested {} deep).",
if bad == 1 { "" } else { "s" },
if bad == 1 { "it is" } else { "they are" },
gridcraft_model::MAX_MACROS,
gridcraft_model::MAX_MACRO_STEPS,
gridcraft_model::MAX_MACRO_DEPTH
));
}
warnings
}
/// The item key (`macro_<n>`, lower-cased) of a hidden macro name, and `n` when it is a number.
fn macro_item(n: &DefinedName) -> Option<(String, Option<u64>)> {
// Only a hidden workbook-scope name is macro storage: a person's own visible name that
// happens to look like one stays a name.
let key = stash::item_key(&n.name).filter(|k| n.hidden && n.scope.is_none() && k.starts_with(MACRO_KEY))?;
let i = key.strip_prefix(MACRO_KEY).and_then(|i| i.parse().ok());
Some((key, i))
}
/// `name`, or `name (2)`, `name (3)`… if a macro already has it, a kept one too. A free name comes back as it is;
/// a renamed one is shortened to make room for the suffix, so it stays within
/// [`gridcraft_model::MAX_MACRO_NAME`] characters. The result still goes through [`check`].
pub fn unique_name(wb: &Workbook, name: &str) -> String {
let mut taken = Taken::default();
for m in &wb.macros {
taken.insert(&m.name);
}
for kept in wb.macros_unread.iter().filter_map(|v| v.get("name").and_then(Json::as_str)) {
taken.insert(kept);
}
taken.unique(name)
}
/// Macro names in use, compared as [`find`] compares them, and the next suffix to try for each
/// shortened name and suffix width, so naming many clashing macros takes near-linear work.
#[derive(Default)]
struct Taken {
names: HashSet<String>,
next: HashMap<(String, usize), u64>,
/// Name lookups made, for tests.
probes: usize,
}
impl Taken {
fn key(name: &str) -> String {
name.trim().to_ascii_lowercase()
}
fn insert(&mut self, name: &str) {
self.names.insert(Taken::key(name));
}
fn contains(&mut self, name: &str) -> bool {
self.probes = self.probes.saturating_add(1);
self.names.contains(&Taken::key(name))
}
/// The first of `name`, `name (2)`, `name (3)`… not in use.
fn unique(&mut self, name: &str) -> String {
let name = if name.is_empty() { "Macro" } else { name };
if !self.contains(name) {
return name.to_string();
}
let mut n: u64 = 2;
loop {
let suffix = format!(" ({n})");
let keep = gridcraft_model::MAX_MACRO_NAME.saturating_sub(suffix.chars().count());
let base: String = name.chars().take(keep).collect();
let base = base.trim_end();
// Every candidate for this base and suffix width below `next` is in use already
// (names are only added), so the search goes on from there.
let key = (Taken::key(base), suffix.len());
let next = self.next.get(&key).copied().unwrap_or(n);
if next > n {
n = next;
continue;
}
let Some(after) = n.checked_add(1) else { return name.to_string() };
self.next.insert(key, after);
let candidate = format!("{base}{suffix}");
if !self.contains(&candidate) {
return candidate;
}
n = after;
}
}
}
/// Ctrl+Shift (Cmd+Shift on a Mac) letters that GridCraft already uses, on any platform, which a
/// macro cannot take.
pub const RESERVED_SHORTCUTS: &[(char, &str)] = &[
('F', "Format Cells (Font) or Filter"),
('G', "Workbook Statistics"),
('H', "Replace"),
('J', "Ungroup"),
('K', "Group"),
('L', "Filter"),
('R', "Sort"),
('S', "Save As"),
('T', "AutoSum"),
('Z', "Redo"),
];
/// The letter of a macro shortcut: `"Ctrl+Shift+M"`, `"Cmd+Shift+M"`, `"⌘⇧M"` and `"m"` all give
/// `'M'`. `None` when it is not one letter from A to Z, alone or after Ctrl+Shift (Cmd+Shift):
/// other modifiers (`"Ctrl+Alt+M"`) are refused rather than read as Ctrl+Shift.
pub fn parse_shortcut(s: &str) -> Option<char> {
let letter = |t: &str| {
let mut chars = t.trim().chars();
match (chars.next(), chars.next()) {
(Some(c), None) if c.is_ascii_alphabetic() => Some(c.to_ascii_uppercase()),
_ => None,
}
};
let t = s.trim().to_ascii_lowercase();
["ctrl+shift+", "cmd+shift+", "⌘⇧"].iter().find_map(|p| t.strip_prefix(p)).map_or_else(|| letter(&t), letter)
}
/// How a shortcut is stored and shown: `Ctrl+Shift+M`.
pub fn shortcut_text(letter: char) -> String {
format!("Ctrl+Shift+{}", letter.to_ascii_uppercase())
}
/// Why macro `name` cannot use Ctrl+Shift+`letter`: GridCraft uses it, or another macro has it.
pub fn shortcut_conflict(wb: &Workbook, name: &str, letter: char) -> Option<String> {
let letter = letter.to_ascii_uppercase();
if let Some((_, what)) = RESERVED_SHORTCUTS.iter().find(|(c, _)| *c == letter) {
return Some(format!("{} is {what}. Choose another letter.", shortcut_text(letter)));
}
wb.macros
.iter()
.find(|m| !m.name.eq_ignore_ascii_case(name.trim()) && m.shortcut.as_deref().and_then(parse_shortcut) == Some(letter))
.map(|m| format!("{} already runs macro “{}”. Choose another letter.", shortcut_text(letter), m.name))
}
/// `name` trimmed, if it can name a new macro in `wb`; otherwise why not: it is empty, too long,
/// holds line breaks or another macro has it.
pub fn check_new_name(wb: &Workbook, name: &str) -> Result<String, String> {
let name = name.trim();
let probe = Macro { name: name.to_string(), ..Macro::default() };
probe.check_limits()?;
if name.chars().any(char::is_control) {
return Err("A macro name cannot hold line breaks or other control characters.".into());
}
if find(wb, name).is_some() {
return Err(format!("A macro called “{name}” already exists. Choose another name."));
}
if let Some(kept) = kept_name(wb, name) {
return Err(format!("A macro called “{kept}” is kept from a newer version of GridCraft. Choose another name."));
}
Ok(name.to_string())
}
/// The first free `Macro1`, `Macro2`… name, also free among the kept macros ([`kept_name`]).
pub fn next_name(wb: &Workbook) -> String {
(1..).map(|n| format!("Macro{n}")).find(|n| find(wb, n).is_none() && kept_name(wb, n).is_none()).unwrap_or_else(|| "Macro".into())
}
/// Every command step of `steps` (inside Repeat and If too), with its path.
pub fn command_steps(steps: &[Step]) -> Vec<(String, &str, &Json)> {
fn walk<'a>(steps: &'a [Step], prefix: &str, out: &mut Vec<(String, &'a str, &'a Json)>) {
for (i, st) in steps.iter().enumerate() {
let path = if prefix.is_empty() { (i + 1).to_string() } else { format!("{prefix}.{}", i + 1) };
match &st.kind {
StepKind::Command { id, params } => out.push((path, id.as_str(), params)),
StepKind::Repeat { body, .. } => walk(body, &path, out),
StepKind::If { then, otherwise, .. } => {
walk(then, &format!("{path}.then"), out);
walk(otherwise, &format!("{path}.else"), out);
}
StepKind::Stop { .. } => {}
}
}
}
let mut out = Vec::new();
walk(steps, "", &mut out);
out
}
#[cfg(test)]
mod tests;
+148
View File
@@ -0,0 +1,148 @@
//! What a macro step may run. Macros run commands, so this keeps a macro from saving, opening or
//! closing files, driving the app, running other macros, or reading and writing files without the
//! person knowing. It goes by command id and the `path` parameter; the runner also runs every
//! step except an approved file step inside the scripts' file sandbox, so a command that touches a
//! file under another parameter can't either.
use serde_json::Value as Json;
use crate::Trust;
/// Whether a command may run as a macro step.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Policy {
Allow,
/// Allowed once the person approves the macro; the text says why it asks ("came with a
/// workbook you opened").
Ask(String),
/// Reads or writes files: the macro must list the command in its permissions, and the person
/// approves the macro before it runs. The text says what it does ("reads a file").
NeedsPermission(String),
/// Never runs in a macro; the text says why.
Deny(String),
}
/// Prefixes of commands that never run in a macro, with the reason.
const DENIED_PREFIXES: &[(&str, &str)] = &[
("file.", "macros don't open, save, close or create workbooks"),
("app.", "macros don't change the app"),
("window.", "macros don't switch windows"),
("automate.", "macros don't record or run scripts or other macros"),
("macro.", "macros don't record or run other macros"),
];
/// Single commands that never run in a macro.
const DENIED: &[(&str, &str)] = &[
("edit.undo", "a macro is undone as one step after it runs"),
("edit.redo", "a macro is undone as one step after it runs"),
("view.newWindow", "macros don't open, hide or show windows"),
("view.hideWindow", "macros don't open, hide or show windows"),
("view.unhideWindow", "macros don't open, hide or show windows"),
];
/// Commands under a denied prefix that only change the workbook.
const ALLOWED: &[&str] = &["file.properties"];
/// Commands that read a file when given a `path`.
const READS_FILES: &[&str] = &["data.getData", "data.fromTextCsv", "insert.picture", "pageLayout.background"];
/// File commands that only produce bytes unless given a `path` (then they write a file).
const EXPORTS: &[&str] = &["file.exportPdf", "file.exportCsv", "file.exportHtml", "file.print", "file.printPreview", "file.saveBytes"];
/// Whether command `id` with `params` writes a file (an export or print given a `path`).
pub fn writes_file(id: &str, params: &Json) -> bool {
EXPORTS.contains(&id) && params.get("path").is_some_and(|p| !p.is_null())
}
/// The policy for running command `id` with `params` as a macro step in a document with `trust`.
///
/// Any `path` other than `null` counts as touching a file: a token such as `{"$ref": "Input"}`
/// becomes a path when the step runs, and exports write to `""` and `" "` as file names. The
/// runner checks the parameters both as written and as resolved.
pub fn macro_policy(id: &str, params: &Json, trust: Trust) -> Policy {
let has_path = params.get("path").is_some_and(|p| !p.is_null());
let base = if EXPORTS.contains(&id) {
if has_path { Policy::NeedsPermission("writes a file".into()) } else { Policy::Allow }
} else if ALLOWED.contains(&id) {
Policy::Allow
} else if let Some((_, why)) = DENIED.iter().find(|(d, _)| *d == id) {
Policy::Deny((*why).into())
} else if let Some((_, why)) = DENIED_PREFIXES.iter().find(|(p, _)| id.starts_with(p)) {
Policy::Deny((*why).into())
} else if READS_FILES.contains(&id) && has_path {
Policy::NeedsPermission("reads a file".into())
} else {
Policy::Allow
};
match base {
Policy::Allow if !trust.allows_code() => Policy::Ask("came with a workbook you opened".into()),
other => other,
}
}
/// Whether the footprint dry run can preview a command. Solver can take long, so a dry run stops
/// before it and reports the footprint as partial.
pub fn previewable(id: &str) -> bool {
id != "data.solver"
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn the_deny_and_permission_lists() {
let none = json!({});
let path = json!({"path": "/tmp/x"});
for id in [
"file.saveAs",
"file.save",
"file.open",
"file.close",
"file.new",
"app.commands",
"window.activate",
"automate.runScript",
"automate.recordActions",
"macro.run",
"macro.record",
"edit.undo",
"edit.redo",
"view.newWindow",
"view.hideWindow",
"view.unhideWindow",
] {
assert!(matches!(macro_policy(id, &none, Trust::Local), Policy::Deny(_)), "{id}");
assert!(matches!(macro_policy(id, &path, Trust::FromFile), Policy::Deny(_)), "{id}");
}
for id in ["data.getData", "data.fromTextCsv", "insert.picture", "pageLayout.background", "file.exportPdf", "file.print", "file.exportCsv"] {
assert!(matches!(macro_policy(id, &path, Trust::Local), Policy::NeedsPermission(_)), "{id}");
assert!(matches!(macro_policy(id, &path, Trust::FromFile), Policy::NeedsPermission(_)), "{id}");
}
// Without a path they touch no file.
for id in ["data.getData", "insert.picture", "file.exportPdf", "file.saveBytes", "file.printPreview"] {
assert_eq!(macro_policy(id, &none, Trust::Local), Policy::Allow, "{id}");
assert_eq!(macro_policy(id, &json!({"path": null}), Trust::Local), Policy::Allow, "{id}");
}
// A path that is blank or a token still touches a file once the step runs.
for path in [json!(""), json!(" "), json!({"$ref": "Input"}), json!("{{Input}}.csv"), json!(1)] {
for id in ["file.exportCsv", "data.fromTextCsv"] {
let p = json!({ "path": path });
assert!(matches!(macro_policy(id, &p, Trust::Local), Policy::NeedsPermission(_)), "{id} {p}");
}
}
for id in ["cell.set", "home.bold", "view.macros", "edit.copy", "file.properties"] {
assert_eq!(macro_policy(id, &none, Trust::Local), Policy::Allow, "{id}");
assert!(matches!(macro_policy(id, &none, Trust::FromFile), Policy::Ask(_)), "{id}");
}
assert!(!previewable("data.solver") && previewable("cell.set"));
}
#[test]
fn every_listed_command_exists() {
for id in ALLOWED.iter().chain(READS_FILES).chain(EXPORTS).chain(DENIED.iter().map(|(id, _)| id)).chain(&["data.solver"]) {
assert!(crate::find_command(id).is_some(), "{id}");
}
}
}
+528
View File
@@ -0,0 +1,528 @@
//! Turning a recording into a macro.
//!
//! The recording buffer (`crate::record`) holds the top-level commands run while recording, each
//! with the sheet and selection it ran in. When recording stops:
//!
//! - commands a macro may not run (saving, opening files…) and other macros' commands are left
//! out; a macro run while recording is replaced by its steps when it has no inputs, no output
//! and only plain commands (otherwise it is left out), and an Automate script run while
//! recording by the steps it ran, leaving out the steps a macro may not run; both are the steps
//! as they ran, kept with the recording ([`crate::record::Recorder::ran`]), and a run without
//! them is left out;
//! - a `sheet.activate` or `selection.set` step is added where the sheet or selection changed
//! between commands without one, and runs of `selection.set` collapse to the last;
//! - with relative references (the default), the selection when recording started is the example
//! input: references on its sheet in, next to or near it become tokens (`Input.col(2)`,
//! `Input.below`, `Input.cell(1,4)`), also inside formulas (`{{Input.col(2)}}`), and the input's
//! shape, header and column types are inferred from it as it was when recording started;
//! - recorded text that playback would read as a token stays text: a reference parameter that
//! reads as one (a defined name called `Output`) becomes `{"$text": …}`, and `{{` in a string
//! becomes `{{{{`.
use gridcraft_core::{CellRef, RangeRef, Value};
use gridcraft_model::{ColType, InputKind, MAX_MACRO_STEPS, Macro, NamedInput, Step, StepKind, Workbook};
use serde_json::{Map, Value as Json, json};
use std::collections::BTreeMap;
use super::policy::{Policy, macro_policy};
use crate::Selection;
use crate::record::RecordedStep;
/// How far outside the example input a reference may be and still be made relative to it.
const NEAR_ROWS: u32 = 50;
const NEAR_COLS: u32 = 26;
/// Where recording started, kept beside the buffer, with the name, shortcut and description the
/// macro is to be saved with (from the Record Macro dialog), applied when recording stops.
#[derive(Clone, Debug)]
pub struct RecordingStart {
pub doc: u64,
pub sheet: usize,
pub sheet_name: String,
pub selection: Selection,
pub relative: bool,
/// With relative references, the example input (the selection) as it was when recording
/// started: the recorded steps may change its values (text made into numbers), and the
/// macro is for data like the data it was recorded on.
pub input: Option<NamedInput>,
/// The name, shortcut and description given when recording started (Record Macro).
pub name: Option<String>,
pub shortcut: Option<String>,
pub description: Option<String>,
}
impl RecordingStart {
/// Recording starting now in `d`.
pub fn new(d: &crate::DocState, relative: bool) -> RecordingStart {
let sheet = d.wb.active_sheet();
RecordingStart {
doc: d.uid,
sheet,
sheet_name: d.wb.active().map(|sh| sh.name.clone()).unwrap_or_default(),
selection: d.selection.clone(),
relative,
input: relative.then(|| infer_input(&d.wb, sheet, d.selection.current())),
name: None,
shortcut: None,
description: None,
}
}
}
/// The steps a recording keeps for a run of `m`: its enabled command steps as written, when they
/// stand on their own (no inputs, no output and only plain commands; a Repeat, If or Stop means
/// nothing outside its macro). `None` when they don't, and the recording leaves the run out.
pub fn inline_steps(m: &Macro) -> Option<Vec<(String, Json)>> {
if !m.inputs.is_empty() || m.output.anchor.is_some() || !m.steps.iter().all(|x| matches!(x.kind, StepKind::Command { .. })) {
return None;
}
Some(
m.steps
.iter()
.filter(|x| x.enabled)
.filter_map(|x| match &x.kind {
StepKind::Command { id, params } => Some((id.clone(), params.clone())),
_ => None,
})
.collect(),
)
}
/// Where a gathered command came from, which says how its parameters are written.
#[derive(Clone, Copy, PartialEq, Eq)]
enum Origin {
/// Run while recording: plain parameters, which may become relative.
Recorded,
/// A step of a recorded script run: plain parameters, kept as they are.
Script,
/// A step of a recorded macro run: macro parameters (tokens and all), kept as written.
Macro,
}
/// A recording turned into a macro, with what was left out.
pub struct Converted {
pub mac: Macro,
/// Commands left out (ids), in order.
pub skipped: Vec<String>,
/// Steps not kept: past the macro's step limit (a recorded run of a long macro adds all of
/// its steps) or recorded in another workbook.
pub cut: usize,
}
/// Builds a macro called `name` from the recorded steps.
/// `ran` is what a macro or script run ran, by step index ([`crate::record::Recorder::ran`]); a
/// run without an entry is left out and listed in `skipped`.
pub fn convert(name: &str, start: &RecordingStart, steps: &[RecordedStep], ran: &BTreeMap<usize, Vec<(String, Json)>>) -> Converted {
let mut skipped = Vec::new();
// Commands as they'll be played back: (id, params, sheet before, selection before, where
// they came from). At most the macro's step limit are gathered: a recording of a thousand
// runs of a long macro would otherwise expand to millions of steps before the limit cut them
// back.
let mut cmds: Vec<(String, Json, usize, String, Selection, Origin)> = Vec::new();
let mut cut = steps.iter().filter(|st| st.doc != start.doc).count();
let refused = |id: &str, params: &Json| matches!(macro_policy(id, params, crate::Trust::Local), Policy::Deny(_) | Policy::NeedsPermission(_));
let mut push = |cmds: &mut Vec<(String, Json, usize, String, Selection, Origin)>, cmd: (String, Json, usize, String, Selection, Origin)| {
if cmds.len() < MAX_MACRO_STEPS {
cmds.push(cmd);
} else {
cut = cut.saturating_add(1);
}
};
for (index, st) in steps.iter().enumerate().filter(|(_, st)| st.doc == start.doc) {
let id = st.id.as_str();
// Another macro's steps (when they stand on their own, [`inline_steps`]) or an Automate
// script's, as they ran: what the run kept with the recording. Without them the run is
// left out; the macro or script as it stands now may not be what ran.
let origin = match id {
"macro.run" => Some(Origin::Macro),
"automate.runScript" => Some(Origin::Script),
_ => None,
};
if let Some(origin) = origin {
match ran.get(&index) {
Some(steps) => {
for (cid, params) in steps {
if refused(cid, params) {
skipped.push(cid.clone());
continue;
}
push(&mut cmds, (cid.clone(), params.clone(), st.sheet, st.sheet_name.clone(), st.selection.clone(), origin));
}
}
None => skipped.push(id.to_string()),
}
continue;
}
if refused(id, &st.params) {
skipped.push(id.to_string());
continue;
}
push(&mut cmds, (id.to_string(), st.params.clone(), st.sheet, st.sheet_name.clone(), st.selection.clone(), Origin::Recorded));
}
// Sheet and selection changes made without a command: where a command ran on another sheet,
// or with another selection, than the one before it, say so (a command that moved the
// selection itself does it again when played back, so the extra step is harmless).
// Each step keeps the name of the sheet it runs on.
let mut out: Vec<(String, Json, String, Origin)> = Vec::new();
let (mut sheet, mut sel) = (start.sheet, start.selection.clone());
for (id, params, at_sheet, sheet_name, at_sel, origin) in cmds {
if at_sheet != sheet {
out.push(("sheet.activate".into(), json!({"sheet": sheet_name}), sheet_name.clone(), Origin::Recorded));
}
// A recorded selection.set just before already put the selection there.
let selected = matches!(out.last(), Some((last, ..)) if last == "selection.set");
if (at_sel != sel || at_sheet != sheet) && !selected {
out.push(("selection.set".into(), selection_params(&at_sel), sheet_name.clone(), Origin::Recorded));
}
(sheet, sel) = (at_sheet, at_sel);
out.push((id, params, sheet_name, origin));
}
// Runs of selection.set (or sheet.activate): the last one wins.
let mut collapsed: Vec<(String, Json, String, Origin)> = Vec::new();
for (id, params, on, origin) in out {
if matches!(id.as_str(), "selection.set" | "sheet.activate")
&& let Some((last, ..)) = collapsed.last()
&& *last == id
{
collapsed.pop();
}
collapsed.push((id, params, on, origin));
}
// Relative references. Only references on the input's sheet become tokens: on another sheet
// `C4` is that sheet's C4.
let example = start.selection.current();
let mut used_input = false;
let steps: Vec<Step> = collapsed
.into_iter()
.map(|(id, params, on, origin)| {
// A step that names another sheet with its `sheet` parameter runs there, whatever
// sheet was active: its unqualified references are that sheet's cells.
let names_other_sheet = params.get("sheet").is_some_and(|v| match v {
Json::String(n) => !n.eq_ignore_ascii_case(&start.sheet_name),
Json::Number(i) => i.as_u64() != Some(start.sheet as u64),
_ => false,
});
let here = on.eq_ignore_ascii_case(&start.sheet_name) && !names_other_sheet;
// Plain text first, so the tokens added next are the only ones.
let params = if origin == Origin::Macro { params } else { literal(&params, None) };
let params = if start.relative && origin == Origin::Recorded {
relative(&params, example, &start.sheet_name, here, &mut used_input, None)
} else {
params
};
Step::command(id, params)
})
.collect();
let mut mac = Macro { name: name.to_string(), steps, ..Macro::default() };
if start.relative && used_input {
mac.inputs = vec![start.input.clone().unwrap_or_default()];
}
Converted { mac, skipped, cut }
}
fn selection_params(sel: &Selection) -> Json {
let ranges: Vec<String> = sel.ranges.iter().map(RangeRef::a1).collect();
json!({"range": ranges.join(","), "active": sel.active.a1()})
}
/// Plain recorded `params` written so playback reads them as they are: a string in a reference
/// parameter that playback would read as a token, or that holds `{{`, becomes `{"$text": …}`,
/// and `{{` in any other string becomes `{{{{`.
fn literal(v: &Json, key: Option<&str>) -> Json {
match v {
Json::Object(o) => Json::Object(o.iter().map(|(k, x)| (k.clone(), literal(x, Some(k)))).collect()),
Json::Array(a) => Json::Array(a.iter().map(|x| literal(x, key)).collect()),
Json::String(t) if key.is_some_and(super::resolve::is_ref_key) && (t.contains("{{") || super::resolve::reads_as_token(t, &["Input"])) => {
json!({"$text": t})
}
Json::String(t) if t.contains("{{") => json!(t.replace("{{", "{{{{")),
other => other.clone(),
}
}
/// `params` with references near `example` (on `sheet`) made into tokens; `here` says whether the
/// step runs on that sheet, so its unqualified references are on it.
fn relative(v: &Json, example: RangeRef, sheet: &str, here: bool, used: &mut bool, key: Option<&str>) -> Json {
match v {
// Text kept as text ([`literal`]).
Json::Object(o) if o.len() == 1 && o.contains_key("$text") => v.clone(),
Json::Object(o) => {
// A selection of several areas stays as it is, its active cell with it: a token for
// the active cell alone would put it outside the areas on playback.
let several_areas = o.get("range").and_then(Json::as_str).is_some_and(|r| r.contains(','));
let mut out = Map::new();
for (k, x) in o {
let x = if several_areas && k == "active" { x.clone() } else { relative(x, example, sheet, here, used, Some(k)) };
out.insert(k.clone(), x);
}
Json::Object(out)
}
Json::Array(a) => Json::Array(a.iter().map(|x| relative(x, example, sheet, here, used, key)).collect()),
Json::String(t) if key.is_some_and(super::resolve::is_ref_key) => {
// One area becomes a token; a list of areas ("A1:B2,D4") stays as it is.
if t.contains(',') {
return v.clone();
}
match ref_token(t, example, sheet, here) {
Some(token) => {
*used = true;
json!(token)
}
None => v.clone(),
}
}
Json::String(t) if here && t.starts_with('=') && example.count() > 1 => {
let (f, changed) = formula_tokens(t, example);
*used |= changed;
json!(f)
}
other => other.clone(),
}
}
/// The token for an A1 reference on the example's sheet near the example input. `here` says whether
/// an unqualified reference is on that sheet.
fn ref_token(text: &str, example: RangeRef, sheet: &str, here: bool) -> Option<String> {
let (on, body) = crate::cmd::split_sheet(text.trim());
match on {
Some(on) if !on.eq_ignore_ascii_case(sheet) => return None,
None if !here => return None,
_ => {}
}
let t = RangeRef::parse(body)?;
if t.is_full_cols() || t.is_full_rows() {
return None;
}
token(t, example)
}
/// `t` as a token relative to `r`.
fn token(t: RangeRef, r: RangeRef) -> Option<String> {
if t == r {
return Some("Input".into());
}
let rel_row = |row: u32| i64::from(row) - i64::from(r.start.row) + 1;
let rel_col = |col: u32| i64::from(col) - i64::from(r.start.col) + 1;
let cols = |a: u32, b: u32| if a == b { format!("col({})", rel_col(a)) } else { format!("cols({}..{})", rel_col(a), rel_col(b)) };
if r.contains_range(&t) {
let full_rows = t.start.row == r.start.row && t.end.row == r.end.row;
let full_cols = t.start.col == r.start.col && t.end.col == r.end.col;
return Some(if full_rows {
format!("Input.{}", cols(t.start.col, t.end.col))
} else if full_cols && t.start.row == t.end.row {
format!("Input.row({})", rel_row(t.start.row))
} else if full_cols && t.end.row == r.end.row {
format!("Input.rows({}..)", rel_row(t.start.row))
} else if full_cols {
format!("Input.rows({}..{})", rel_row(t.start.row), rel_row(t.end.row))
} else if t.is_single() {
format!("Input.cell({},{})", rel_row(t.start.row), rel_col(t.start.col))
} else {
format!("Input.cell({},{}).resize({},{})", rel_row(t.start.row), rel_col(t.start.col), t.height(), t.width())
});
}
let near = t.start.row.saturating_add(NEAR_ROWS) >= r.start.row
&& t.end.row <= r.end.row.saturating_add(NEAR_ROWS)
&& t.start.col.saturating_add(NEAR_COLS) >= r.start.col
&& t.end.col <= r.end.col.saturating_add(NEAR_COLS);
if !near {
return None;
}
// Just below the input, in its columns: follows the input's length.
if t.start.row == r.end.row + 1 && t.start.col >= r.start.col && t.end.col <= r.end.col {
let base = if t.start.col == r.start.col && t.end.col == r.end.col {
"Input".to_string()
} else {
format!("Input.{}", cols(t.start.col, t.end.col))
};
return Some(if t.height() == 1 { format!("{base}.below") } else { format!("{base}.below({})", t.height()) });
}
// Just right of the input, in its rows: follows its width.
if t.start.col == r.end.col + 1 && t.start.row >= r.start.row && t.end.row <= r.end.row {
let base = if t.start.row == r.start.row && t.end.row == r.end.row {
"Input".to_string()
} else if t.start.row == t.end.row {
format!("Input.row({})", rel_row(t.start.row))
} else {
format!("Input.rows({}..{})", rel_row(t.start.row), rel_row(t.end.row))
};
return Some(if t.width() == 1 { format!("{base}.right") } else { format!("{base}.right({})", t.width()) });
}
let cell = format!("Input.cell({},{})", rel_row(t.start.row), rel_col(t.start.col));
Some(if t.is_single() { cell } else { format!("{cell}.resize({},{})", t.height(), t.width()) })
}
/// Replaces references in a formula that are exactly the example input, one of its columns or
/// rows, or a column without its first row, with `{{token}}`.
fn formula_tokens(f: &str, r: RangeRef) -> (String, bool) {
// Each reference in the formula text is read and matched on its own, so the work is the
// formula's length whatever the example's size (an example of a whole sheet has a million
// rows, which no list of candidates may grow with).
let mut out = String::with_capacity(f.len());
let mut changed = false;
let (mut i, mut in_text) = (0, false);
let bytes = f.as_bytes();
while i < f.len() {
let Some(rest) = f.get(i..) else { break };
let ch = rest.chars().next().unwrap_or(' ');
if ch == '"' {
in_text = !in_text;
}
// A reference right after `{` stays as it is: `{{` before the token would read as the
// escape of a literal `{{`.
let prev_ok = i == 0 || !bytes.get(i - 1).is_some_and(|b| b.is_ascii_alphanumeric() || matches!(b, b'$' | b'!' | b'_' | b'.' | b':' | b'{'));
if !in_text && prev_ok && ch.is_ascii_alphabetic() {
// The longest run of reference characters here: letters, digits and one `:`.
let n = rest.bytes().take_while(|b| b.is_ascii_alphanumeric() || *b == b':').count();
let text = rest.get(..n).unwrap_or("");
let followed = rest.get(n..).and_then(|x| x.chars().next()).is_some_and(|c| matches!(c, '!' | '(' | '_' | '$'));
if !followed
&& !text.contains('$')
&& let Some(t) = RangeRef::parse(text)
&& let Some(tok) = formula_token(t, r)
{
out.push_str("{{");
out.push_str(&tok);
out.push_str("}}");
i += n;
changed = true;
continue;
}
}
out.push(ch);
i += ch.len_utf8();
}
(out, changed)
}
/// The token for a reference inside a formula: exactly the example input, one of its columns or
/// rows, or a column without its first row. Other references stay as they are.
fn formula_token(t: RangeRef, r: RangeRef) -> Option<String> {
if t == r {
return Some("Input".into());
}
if !r.contains_range(&t) {
return None;
}
let col = usize::try_from(t.start.col.checked_sub(r.start.col)?).ok()?.checked_add(1)?;
let row = usize::try_from(t.start.row.checked_sub(r.start.row)?).ok()?.checked_add(1)?;
if t.start.col == t.end.col && t.start.row == r.start.row && t.end.row == r.end.row {
return Some(format!("Input.col({col})"));
}
if t.start.col == t.end.col && r.height() > 2 && t.start.row == r.start.row.checked_add(1)? && t.end.row == r.end.row {
return Some(format!("Input.rows(2..).col({col})"));
}
if r.width() > 1 && t.start.row == t.end.row && t.start.col == r.start.col && t.end.col == r.end.col {
return Some(format!("Input.row({row})"));
}
None
}
/// The example input's shape: a header when the first row is all text over other kinds of
/// values, and each column's type when all its values agree.
pub fn infer_input(wb: &Workbook, sheet: usize, r: RangeRef) -> NamedInput {
let mut input = NamedInput::default();
if r.is_single() {
input.kind = InputKind::Cell;
input.rows = [1, 1];
input.cols = [1, 1];
return input;
}
input.cols = [r.width(), r.width()];
let Some(sh) = wb.sheet(sheet) else { return input };
if r.count() > 100_000 {
return input;
}
let kind = |c: CellRef| -> Option<ColType> {
let v = sh.value(c);
Some(match v {
Value::Empty => return None,
Value::Text(t) if t.is_empty() => return None,
Value::Number(_) if crate::display::number_format(wb.styles.get(sh.style_id(c)).num_fmt.as_str()).is_date() => ColType::Date,
Value::Number(_) => ColType::Number,
Value::Text(_) => ColType::Text,
Value::Bool(_) => ColType::Bool,
_ => ColType::Any,
})
};
let column = |col: u32, from: u32| -> ColType {
let mut seen: Option<ColType> = None;
for row in from..=r.end.row {
match (seen, kind(CellRef::new(row, col))) {
(_, None) => {}
(None, k) => seen = k,
(Some(a), Some(b)) if a != b => return ColType::Any,
_ => {}
}
}
seen.unwrap_or(ColType::Any)
};
let first_text = (r.start.col..=r.end.col).all(|c| kind(CellRef::new(r.start.row, c)) == Some(ColType::Text));
let below = (r.start.col..=r.end.col).map(|c| column(c, r.start.row + 1)).collect::<Vec<_>>();
input.header = r.height() > 1 && first_text && below.iter().any(|k| !matches!(k, ColType::Text | ColType::Any));
input.col_types = if input.header { below } else { (r.start.col..=r.end.col).map(|c| column(c, r.start.row)).collect() };
if input.col_types.iter().all(|k| *k == ColType::Any) {
input.col_types.clear();
}
input.rows = [1, 0];
input
}
#[cfg(test)]
mod tests {
use super::*;
fn r(a1: &str) -> RangeRef {
RangeRef::parse(a1).unwrap()
}
#[test]
fn references_near_the_example_become_tokens() {
let ex = r("B2:D11");
for (t, want) in [
("B2:D11", Some("Input")),
("C2:C11", Some("Input.col(2)")),
("C2:D11", Some("Input.cols(2..3)")),
("B2:D2", Some("Input.row(1)")),
("B3:D11", Some("Input.rows(2..)")),
("B3:D5", Some("Input.rows(2..4)")),
("C4", Some("Input.cell(3,2)")),
("C4:D5", Some("Input.cell(3,2).resize(2,2)")),
("B12:D12", Some("Input.below")),
("C12", Some("Input.col(2).below")),
("C12:C13", Some("Input.col(2).below(2)")),
("E2:E11", Some("Input.right")),
("E5", Some("Input.row(4).right")),
("E3:F4", Some("Input.rows(2..3).right(2)")),
("G2", Some("Input.cell(1,6)")),
("A1", Some("Input.cell(0,0)")),
("Z500", None),
("A:A", None),
] {
assert_eq!(ref_token(t, ex, "Sheet1", true).as_deref(), want, "{t}");
}
assert_eq!(ref_token("Sheet1!C4", ex, "Sheet1", true).as_deref(), Some("Input.cell(3,2)"));
assert_eq!(ref_token("Other!C4", ex, "Sheet1", true), None);
// On another sheet only a reference qualified with the input's sheet is on it.
assert_eq!(ref_token("C4", ex, "Sheet1", false), None);
assert_eq!(ref_token("Sheet1!C4", ex, "Sheet1", false).as_deref(), Some("Input.cell(3,2)"));
}
#[test]
fn formulas_get_tokens_for_exact_references() {
let ex = r("B2:C5");
for (f, want) in [
("=SUM(C3:C5)", "=SUM({{Input.rows(2..).col(2)}})"),
("=SUM(C2:C5)+COUNT(B2:C5)", "=SUM({{Input.col(2)}})+COUNT({{Input}})"),
("=SUM($C$3:$C$5)", "=SUM($C$3:$C$5)"),
("=SUM(Other!C3:C5)", "=SUM(Other!C3:C5)"),
("=\"C3:C5\"&C3:C5", "=\"C3:C5\"&{{Input.rows(2..).col(2)}}"),
("=SUM(AC3:C5)", "=SUM(AC3:C5)"),
("=B2", "=B2"),
] {
assert_eq!(formula_tokens(f, ex).0, want, "{f}");
}
}
}
+967
View File
@@ -0,0 +1,967 @@
//! Macro tokens: references in step parameters that are worked out when the step runs, so a
//! macro made on one block of data works on another.
//!
//! A token is a base followed by methods:
//!
//! - bases: a declared input (`Input`, `Rates`), `Output` (the output rule's anchor), `Row` or
//! `Column` (the current item of the Repeat step around it), and `Name:Total` (a defined name or
//! a table's data; a name can hold periods, and the longest one that exists is used, so
//! `Name:Sales.Q1.cell(1,1)` is a cell of `Sales.Q1`);
//! - methods, 1-based and chainable: `.col(n)` or `.col("Amount")` (by an input's header row,
//! also after `.data`), `.row(n)`, `.cols(a..b)`, `.rows(a..)`, `.cell(r,c)` (relative to the
//! top-left cell; any whole numbers, so `cell(0,1)` is the cell above), `.offset(r,c)`,
//! `.resize(h,w)`, `.data` (without the header row), `.below(n)` and `.right(n)` (the n rows
//! below or columns right of the range).
//!
//! In parameters a token is written three ways: `{"$ref": "Input.col(2)"}` anywhere; the whole
//! string `"Input.col(2)"` in a reference parameter (`cell`, `range`, `target`, `to`, keys ending
//! in `Cell` or `Range`…); or `{{Input.col(2)}}` inside any string (`"=SUM({{Input.col(2)}})"`).
//! Each becomes A1 text before the command runs, so commands never see a token. Text that would
//! read as a token is written `{"$text": "Output"}`: it is passed as it is, never resolved or
//! interpolated (a defined name called `Output`, or `Dear {{name}}`). Plain A1
//! references in steps are absolute: they don't follow row inserts (as in VBA); a `Name:` anchor
//! does.
use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef};
use gridcraft_model::{RepeatOf, Workbook};
use serde_json::{Map, Value as Json};
/// A resolved reference: a block of cells on a sheet.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Place {
pub sheet: usize,
pub range: RangeRef,
}
/// Why a reference couldn't be worked out.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ResolveError {
/// It runs past the edge of the sheet.
Edge(String),
/// Anything else (an unknown input, a malformed token).
Bad(String),
}
impl ResolveError {
pub fn message(&self) -> &str {
match self {
ResolveError::Edge(m) | ResolveError::Bad(m) => m,
}
}
}
impl std::fmt::Display for ResolveError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.message())
}
}
type R<T> = Result<T, ResolveError>;
fn bad<T>(msg: impl Into<String>) -> R<T> {
Err(ResolveError::Bad(msg.into()))
}
/// What tokens refer to while a step runs.
#[derive(Clone, Copy, Debug)]
pub struct Env<'a> {
pub wb: &'a Workbook,
/// The sheet a reference without a sheet name is on.
pub active_sheet: usize,
/// Declared inputs, with whether each has a header row.
pub inputs: &'a [(String, Place, bool)],
pub output: Option<Place>,
/// The innermost Repeat step's current row or column.
pub item: Option<(RepeatOf, Place)>,
}
/// Parameter keys that hold references, where a whole-string token is resolved. Elsewhere a
/// string is text (a cell may hold "Row", a chart's type is "column"), and a token needs
/// `{"$ref": …}` or `{{…}}`.
const REF_KEYS: &[&str] = &[
"active",
"anchor",
"area",
"areas",
"at",
"cell",
"cells",
"changing",
"copyTo",
"destination",
"from",
"fromCell",
"location",
"objective",
"over",
"range",
"ranges",
"reference",
"resultCells",
"source",
"sources",
"target",
"to",
"variables",
];
/// Whether a parameter called `key` holds a reference.
pub fn is_ref_key(key: &str) -> bool {
let k = key.to_ascii_lowercase();
REF_KEYS.iter().any(|r| r.eq_ignore_ascii_case(key)) || ["cell", "cells", "range", "ranges", "ref"].iter().any(|end| k.ends_with(end))
}
/// Reference keys that other commands use for plain words: `to` is a replacement word in
/// Review › Change Spelling, `area` is `row` or `column` in a pivot field move. Under these a
/// bare `Row` or `Column` stays text; `Row.col(2)`, `{{Row}}` or `{"$ref": "Row"}` is the token.
fn is_general_key(key: &str) -> bool {
["area", "areas", "at", "destination", "from", "location", "source", "sources", "target", "to"].iter().any(|k| k.eq_ignore_ascii_case(key))
}
/// `Row` or `Column` on its own, with no method or sheet after it.
fn is_bare_item_word(text: &str) -> bool {
let t = text.trim();
t.eq_ignore_ascii_case("row") || t.eq_ignore_ascii_case("column")
}
/// The most nesting [`resolve_params`] walks into.
const MAX_JSON_DEPTH: usize = 64;
/// Whether `text` starts like a token (its base is one this environment knows). A reference on a
/// sheet with such a name (`Output!A1`) is not a token.
pub fn is_token(text: &str, env: &Env) -> bool {
starts_like_token(text, |base| env.inputs.iter().any(|(n, ..)| n.eq_ignore_ascii_case(base)))
}
/// Whether `text` would read as a token in a macro whose inputs are `inputs` (see [`is_token`]).
pub fn reads_as_token(text: &str, inputs: &[&str]) -> bool {
starts_like_token(text, |base| inputs.iter().any(|n| n.eq_ignore_ascii_case(base)))
}
fn starts_like_token(text: &str, is_input: impl Fn(&str) -> bool) -> bool {
let t = text.trim();
if t.get(..5).is_some_and(|h| h.eq_ignore_ascii_case("name:")) {
return true;
}
let n: usize = t.chars().take_while(|c| c.is_alphanumeric() || *c == '_').map(char::len_utf8).sum();
let base = t.get(..n).unwrap_or("");
if t.get(n..).unwrap_or("").trim_start().starts_with('!') {
return false;
}
is_builtin(base) || is_input(base)
}
fn is_builtin(base: &str) -> bool {
["Output", "Row", "Column"].iter().any(|b| b.eq_ignore_ascii_case(base))
}
/// Resolves a token, or a plain reference (`B2`, `Sheet2!A1:C3`, a defined name).
pub fn resolve_ref(text: &str, env: &Env) -> R<Place> {
let t = text.trim();
if is_token(t, env) {
return resolve_token(t, env);
}
plain_ref(t, env)
}
/// `A1`, `'My Sheet'!A1:B2` or a defined name or table.
fn plain_ref(t: &str, env: &Env) -> R<Place> {
let (sheet, body) = crate::cmd::split_sheet(t);
let si = match &sheet {
Some(name) => env.wb.sheet_index(name).ok_or_else(|| ResolveError::Bad(format!("There is no sheet named '{name}'.")))?,
None => env.active_sheet,
};
if let Some(range) = RangeRef::parse(body) {
return Ok(Place { sheet: si, range });
}
if sheet.is_none() {
return named(t, env);
}
bad(format!("'{t}' isn't a reference."))
}
/// A defined name (by its formula) or a table (its data rows).
fn named(name: &str, env: &Env) -> R<Place> {
if let Some(n) = env.wb.name(name, env.active_sheet) {
// The sheet part keeps its characters (a sheet may be called `US$`); only the reference
// loses its `$` anchors.
let f = n.formula.trim().trim_start_matches('=');
let (sheet, body) = crate::cmd::split_sheet(f);
let si = match sheet {
Some(s) => env.wb.sheet_index(&s),
None => n.scope.or(Some(env.active_sheet)),
};
if let (Some(sheet), Some(range)) = (si, RangeRef::parse(&body.replace('$', ""))) {
return Ok(Place { sheet, range });
}
return bad(format!("The name '{name}' doesn't refer to a range."));
}
if let Some((si, ti)) = env.wb.table(name)
&& let Some(t) = env.wb.sheet(si).and_then(|sh| sh.tables.get(ti))
{
// A table stands for its data rows; one with none (a header, perhaps a totals row) has
// nothing to give.
let Some(range) = t.data_range() else {
return bad(format!("The table '{name}' has no data rows."));
};
return Ok(Place { sheet: si, range });
}
bad(format!("There is no name or table called '{name}'."))
}
/// Token method names.
const METHODS: &[&str] = &["col", "row", "cols", "rows", "cell", "offset", "resize", "data", "below", "right"];
/// Where the name ends in `run`, the text after `Name:` up to the first character a name can't
/// hold: at the end or at a period, the longest choice that is a defined name or table. When
/// none is, the name ends before the first method (`Nope.cell`), so the error names it.
fn name_end(run: &str, env: &Env) -> usize {
// One pass over the names and tables, as a single lookup makes.
let fits = |name: &str| {
!name.is_empty()
&& run.get(..name.len()).is_some_and(|head| head.eq_ignore_ascii_case(name))
&& run.get(name.len()..).is_some_and(|rest| rest.is_empty() || rest.starts_with('.'))
};
let names = env.wb.names.iter().filter(|n| n.scope.is_none() || n.scope == Some(env.active_sheet)).map(|n| n.name.as_str());
let tables = env.wb.sheets.iter().flat_map(|sh| sh.tables.iter().map(|t| t.name.as_str()));
if let Some(end) = names.chain(tables).filter(|n| fits(n)).map(str::len).max() {
return end;
}
run.match_indices('.')
.map(|(i, _)| i)
.find(|&i| {
let next = run.get(i + 1..).unwrap_or("").split('.').next().unwrap_or("");
METHODS.iter().any(|m| m.eq_ignore_ascii_case(next))
})
.unwrap_or(run.len())
}
/// Resolves a token (see the module docs).
pub fn resolve_token(text: &str, env: &Env) -> R<Place> {
let t = text.trim();
let mut p = Parser { s: t, at: 0 };
let (mut place, mut header) = p.base(env)?;
// The row of column names `col("…")` reads: the input's header row, kept through `data`.
let mut names_row = header.then_some(place.range.start.row);
while p.eat('.') {
let name = p.ident();
if name.is_empty() {
return bad(format!("'{t}': expected a method name after '.'."));
}
let args = if p.eat('(') { p.args(t)? } else { vec![] };
let r = place.range;
let method = name.to_ascii_lowercase();
let range = match (method.as_str(), args.as_slice()) {
("col", [Arg::Int(n)]) => sub(r, None, Some((*n, *n)), t)?,
("col", [Arg::Text(h)]) => {
let Some(row) = names_row else {
return bad(format!("'{t}': col(\"{h}\") needs an input with a header row; use col(n)."));
};
let c = header_col(env, place, row, h).ok_or_else(|| ResolveError::Bad(format!("'{t}': no column is headed '{h}'.")))?;
sub(r, None, Some((c, c)), t)?
}
("row", [Arg::Int(n)]) => sub(r, Some((*n, *n)), None, t)?,
("cols", [Arg::Span(a, b)]) => sub(r, None, Some(span(*a, *b, i64::from(r.width()))), t)?,
("rows", [Arg::Span(a, b)]) => sub(r, Some(span(*a, *b, i64::from(r.height()))), None, t)?,
("cell", [Arg::Int(row), Arg::Int(col)]) => {
let c = shift(r.start, row - 1, col - 1, t)?;
RangeRef::cell(c)
}
("offset", [Arg::Int(dr), Arg::Int(dc)]) => RangeRef::new(shift(r.start, *dr, *dc, t)?, shift(r.end, *dr, *dc, t)?),
("resize", [Arg::Int(h), Arg::Int(w)]) => {
if *h < 1 || *w < 1 {
return bad(format!("'{t}': resize needs at least 1 row and 1 column."));
}
RangeRef::new(r.start, shift(r.start, h - 1, w - 1, t)?)
}
("data", []) => {
if header {
// A header row with nothing under it has no data rows to give.
if r.height() < 2 {
return bad(format!("'{t}': the range is only a header row, with no data under it."));
}
RangeRef::new(CellRef::new(r.start.row + 1, r.start.col), r.end)
} else {
r
}
}
("below", []) | ("below", [Arg::Int(_)]) => {
let n = count_arg(&args, t)?;
let top = shift(CellRef::new(r.end.row, r.start.col), 1, 0, t)?;
RangeRef::new(top, shift(CellRef::new(r.end.row, r.end.col), n, 0, t)?)
}
("right", []) | ("right", [Arg::Int(_)]) => {
let n = count_arg(&args, t)?;
let left = shift(CellRef::new(r.start.row, r.end.col), 0, 1, t)?;
RangeRef::new(left, shift(r.end, 0, n, t)?)
}
_ => {
return bad(format!("'{t}': '{name}' with these arguments isn't a token method ({}).", METHODS.join(", ")));
}
};
// After a method the result is plain cells, except that `col` and `cols` keep the input's top
// row, so a later `data` still takes the header off; only `data` keeps the header row for
// `col("…")`.
if !matches!(method.as_str(), "col" | "cols") {
header = false;
}
if method != "data" {
names_row = None;
}
place = Place { sheet: place.sheet, range };
}
p.skip_ws();
if p.at < p.s.len() {
return bad(format!("'{t}' isn't a token: unexpected '{}'.", p.s.get(p.at..).unwrap_or("")));
}
Ok(place)
}
/// The count `below(n)` and `right(n)` take: 1 when left out, and never less than 1.
fn count_arg(args: &[Arg], t: &str) -> R<i64> {
match args.first() {
Some(Arg::Int(n)) if *n < 1 => bad(format!("'{t}': the count must be at least 1.")),
Some(Arg::Int(n)) => Ok(*n),
_ => Ok(1),
}
}
/// `a..b` with open ends filled from 1 and `len`.
fn span(a: Option<i64>, b: Option<i64>, len: i64) -> (i64, i64) {
(a.unwrap_or(1), b.unwrap_or(len))
}
/// Rows `rows` and columns `cols` (1-based, inclusive) of `r`; `None` keeps them all.
fn sub(r: RangeRef, rows: Option<(i64, i64)>, cols: Option<(i64, i64)>, t: &str) -> R<RangeRef> {
let (h, w) = (i64::from(r.height()), i64::from(r.width()));
let (r0, r1) = rows.unwrap_or((1, h));
let (c0, c1) = cols.unwrap_or((1, w));
if r0 < 1 || c0 < 1 || r1 < r0 || c1 < c0 || r1 > h || c1 > w {
return bad(format!("'{t}' is outside the range it starts from ({} rows by {} columns).", h, w));
}
Ok(RangeRef::new(shift(r.start, r0 - 1, c0 - 1, t)?, shift(r.start, r1 - 1, c1 - 1, t)?))
}
fn shift(c: CellRef, dr: i64, dc: i64, t: &str) -> R<CellRef> {
let row = i64::from(c.row).checked_add(dr);
let col = i64::from(c.col).checked_add(dc);
match (row, col) {
(Some(row), Some(col)) if (0..i64::from(MAX_ROWS)).contains(&row) && (0..i64::from(MAX_COLS)).contains(&col) => {
Ok(CellRef::new(row as u32, col as u32))
}
(Some(row), Some(_)) if row < 0 => Err(ResolveError::Edge(format!("'{t}' runs past the top of the sheet."))),
(Some(row), Some(_)) if row >= i64::from(MAX_ROWS) => Err(ResolveError::Edge(format!("'{t}' runs past the last row of the sheet."))),
_ => Err(ResolveError::Edge(format!("'{t}' runs past the edge of the sheet."))),
}
}
/// The 1-based column of `place` whose header (in sheet row `row`) reads `h`.
fn header_col(env: &Env, place: Place, row: u32, h: &str) -> Option<i64> {
let sh = env.wb.sheet(place.sheet)?;
let r = place.range;
(r.start.col..=r.end.col)
// The header's value, however the sheet shows it: with Show Formulas on, the cell's
// display text is its formula.
.position(|c| crate::display::value_text(env.wb, sh, CellRef::new(row, c)).trim().eq_ignore_ascii_case(h.trim()))
.map(|i| i as i64 + 1)
}
#[derive(Debug)]
enum Arg {
Int(i64),
Span(Option<i64>, Option<i64>),
Text(String),
}
struct Parser<'a> {
s: &'a str,
at: usize,
}
impl Parser<'_> {
fn rest(&self) -> &str {
self.s.get(self.at..).unwrap_or("")
}
fn skip_ws(&mut self) {
let n = self.rest().len() - self.rest().trim_start().len();
self.at += n;
}
fn eat(&mut self, c: char) -> bool {
self.skip_ws();
if self.rest().starts_with(c) {
self.at += c.len_utf8();
true
} else {
false
}
}
fn ident(&mut self) -> String {
self.skip_ws();
let n: usize = self.rest().chars().take_while(|c| c.is_alphanumeric() || *c == '_').map(char::len_utf8).sum();
let out = self.rest().get(..n).unwrap_or("").to_string();
self.at += n;
out
}
fn int(&mut self) -> Option<i64> {
self.skip_ws();
let neg = self.rest().starts_with('-');
let start = self.at + usize::from(neg);
let digits: usize = self.s.get(start..).unwrap_or("").chars().take_while(char::is_ascii_digit).count();
if digits == 0 || digits > 9 {
return None;
}
let n: i64 = self.s.get(start..start + digits)?.parse().ok()?;
self.at = start + digits;
Some(if neg { -n } else { n })
}
/// The base of a token and whether it has a header row.
fn base(&mut self, env: &Env) -> R<(Place, bool)> {
self.skip_ws();
if self.rest().get(..5).is_some_and(|h| h.eq_ignore_ascii_case("name:")) {
self.at += 5;
// A name can hold periods (`Sales.Q1`), so it is the longest part of the run, up to
// a period or the end, that names something; methods follow it.
let n: usize = self.rest().chars().take_while(|c| c.is_alphanumeric() || matches!(c, '_' | '\\' | '.')).map(char::len_utf8).sum();
let run = self.rest().get(..n).unwrap_or("").to_string();
let end = name_end(&run, env);
let name = run.get(..end).unwrap_or("");
if name.is_empty() {
return bad("'Name:' needs a defined name after it.");
}
self.at += end;
return Ok((named(name, env)?, false));
}
let base = self.ident();
if base.eq_ignore_ascii_case("Output") {
return env.output.map(|p| (p, false)).ok_or_else(|| ResolveError::Bad("'Output' is used, but the macro has no output anchor.".into()));
}
if base.eq_ignore_ascii_case("Row") || base.eq_ignore_ascii_case("Column") {
return match env.item {
Some((_, p)) => Ok((p, false)),
None => bad(format!("'{base}' only means something inside a Repeat step.")),
};
}
match env.inputs.iter().find(|(n, ..)| n.eq_ignore_ascii_case(&base)) {
Some((_, p, header)) => Ok((*p, *header)),
None => bad(format!("'{base}' isn't an input of this macro.")),
}
}
fn args(&mut self, t: &str) -> R<Vec<Arg>> {
let mut out = Vec::new();
if self.eat(')') {
return Ok(out);
}
loop {
self.skip_ws();
if self.rest().starts_with('"') {
self.at += 1;
let n = self.rest().find('"').ok_or_else(|| ResolveError::Bad(format!("'{t}': a quote isn't closed.")))?;
out.push(Arg::Text(self.rest().get(..n).unwrap_or("").to_string()));
self.at += n + 1;
} else {
let a = self.int();
self.skip_ws();
if self.rest().starts_with("..") {
self.at += 2;
let b = self.int();
out.push(Arg::Span(a, b));
} else {
out.push(Arg::Int(a.ok_or_else(|| ResolveError::Bad(format!("'{t}': expected a whole number.")))?));
}
}
if out.len() > 4 {
return bad(format!("'{t}': too many arguments."));
}
if self.eat(')') {
return Ok(out);
}
if !self.eat(',') {
return bad(format!("'{t}': expected ',' or ')'."));
}
}
}
}
/// `place` as A1 text, with the sheet name when it isn't the active sheet.
pub fn a1(place: Place, env: &Env) -> String {
if place.sheet == env.active_sheet {
return place.range.a1();
}
let name = env.wb.sheet(place.sheet).map(|s| s.name.as_str()).unwrap_or("");
format!("{}!{}", quote_sheet(name), place.range.a1())
}
/// `Sheet1` or `'My Sheet'` (quotes doubled).
pub fn quote_sheet(name: &str) -> String {
// The engine's one rule: a name that reads as a cell reference, an R1C1 token or a boolean
// is quoted too, or `Q1!A1` would be read as a reference to cell Q1.
gridcraft_core::quote_sheet(name)
}
/// Step parameters with every token replaced by A1 text.
pub fn resolve_params(params: &Json, env: &Env) -> R<Json> {
walk(params, None, env, 0)
}
fn walk(v: &Json, key: Option<&str>, env: &Env, depth: usize) -> R<Json> {
if depth > MAX_JSON_DEPTH {
return bad("The step's parameters nest too deeply.");
}
Ok(match v {
Json::Object(o) => {
if o.len() == 1
&& let Some(Json::String(t)) = o.get("$ref")
{
return Ok(Json::String(a1(resolve_ref(t, env)?, env)));
}
// Text passed as it is: never a token, and no `{{…}}` in it is read.
if o.len() == 1
&& let Some(Json::String(t)) = o.get("$text")
{
return Ok(Json::String(t.clone()));
}
let mut out = Map::new();
for (k, x) in o {
out.insert(k.clone(), walk(x, Some(k), env, depth + 1)?);
}
Json::Object(out)
}
// Items of a list keep their key's kind (`ranges: ["Input", …]`).
Json::Array(a) => Json::Array(a.iter().map(|x| walk(x, key, env, depth + 1)).collect::<R<Vec<_>>>()?),
Json::String(s) => {
if key.is_some_and(|k| is_ref_key(k) && !(is_general_key(k) && is_bare_item_word(s))) && is_token(s, env) {
Json::String(a1(resolve_token(s, env)?, env))
} else {
Json::String(interpolate(s, env)?)
}
}
other => other.clone(),
})
}
/// Replaces each `{{token}}` in `s`. `{{{{` is a literal `{{`, for text that needs the braces
/// themselves (`"Dear {{{{name}}"` writes `Dear {{name}}`).
pub fn interpolate(s: &str, env: &Env) -> R<String> {
if !s.contains("{{") {
return Ok(s.to_string());
}
let mut out = String::with_capacity(s.len());
let mut rest = s;
while let Some(i) = rest.find("{{") {
out.push_str(rest.get(..i).unwrap_or(""));
if rest.get(i..i + 4) == Some("{{{{") {
out.push_str("{{");
rest = rest.get(i + 4..).unwrap_or("");
continue;
}
let after = rest.get(i + 2..).unwrap_or("");
let Some(j) = after.find("}}") else {
out.push_str(rest.get(i..).unwrap_or(""));
return Ok(out);
};
let token = after.get(..j).unwrap_or("");
out.push_str(&a1(resolve_ref(token, env)?, env));
rest = after.get(j + 2..).unwrap_or("");
}
out.push_str(rest);
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn wb() -> Workbook {
let mut wb = Workbook::new();
wb.sheets.push(std::sync::Arc::new(gridcraft_model::Sheet::new("My Data")));
let sh = wb.sheet_mut(0).unwrap();
for (i, h) in ["Name", "Amount", "Date"].iter().enumerate() {
sh.set_value(CellRef::new(1, 1 + i as u32), gridcraft_core::Value::text(*h));
}
wb.names.push(gridcraft_model::DefinedName {
name: "Total".into(),
scope: None,
formula: "Sheet1!$F$20".into(),
comment: String::new(),
hidden: false,
});
wb.names.push(gridcraft_model::DefinedName {
name: "Rates".into(),
scope: None,
formula: "'My Data'!$A$1:$A$5".into(),
comment: String::new(),
hidden: false,
});
wb
}
fn place(a1: &str) -> Place {
Place { sheet: 0, range: RangeRef::parse(a1).unwrap() }
}
fn check(env: &Env, token: &str, want: &str) {
match resolve_ref(token, env) {
Ok(p) => assert_eq!(a1(p, env), want, "{token}"),
Err(e) => panic!("{token}: {e}"),
}
}
#[test]
fn a_formula_header_matches_while_formulas_are_shown() {
// The header was read through the cell's display text, which is the formula while Show
// Formulas is on, so `col("Amount")` found no column headed by a formula.
let mut wb = wb();
let sh = wb.sheet_mut(0).unwrap();
let formula = Some(std::sync::Arc::new(gridcraft_model::Formula::new("\"Amo\"&\"unt\"")));
sh.set_cell(CellRef::new(1, 2), gridcraft_model::Cell { value: gridcraft_core::Value::text("Amount"), formula, style: Default::default() });
sh.show_formulas = true;
let inputs = vec![("Input".to_string(), place("B2:D11"), true)];
let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: None, item: None };
check(&env, "Input.col(\"Amount\")", "C2:C11");
}
#[test]
fn every_token_resolves() {
let wb = wb();
// Input B2:D11 with a header row; Other on another sheet.
let inputs = vec![
("Input".to_string(), place("B2:D11"), true),
("Other".to_string(), Place { sheet: 1, range: RangeRef::parse("C3:C4").unwrap() }, false),
];
let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: Some(place("G2")), item: Some((RepeatOf::Rows, place("B5:D5"))) };
for (token, want) in [
("Input", "B2:D11"),
("input", "B2:D11"),
("Input.col(2)", "C2:C11"),
("Input.col(\"Amount\")", "C2:C11"),
("Input.col(\"date\").data", "D3:D11"),
("Input.cols(2..3).data", "C3:D11"),
("Input.col(2).data.col(1)", "C3:C11"),
("Input.data", "B3:D11"),
("Input.data.col(2)", "C3:C11"),
("Input.data.col(\"Amount\")", "C3:C11"),
("Input.row(1)", "B2:D2"),
("Input.rows(2..)", "B3:D11"),
("Input.rows(2..4)", "B3:D5"),
("Input.rows(..2)", "B2:D3"),
("Input.cols(2..3)", "C2:D11"),
("Input.cell(1,1)", "B2"),
("Input.cell(3,2)", "C4"),
("Input.cell(0,1)", "B1"),
("Input.cell(12,3)", "D13"),
("Input.offset(0,4)", "F2:H11"),
("Input.offset(-1,-1)", "A1:C10"),
("Input.cell(1,1).resize(2,3)", "B2:D3"),
("Input.below()", "B12:D12"),
("Input.below(2)", "B12:D13"),
("Input.col(2).below", "C12"),
("Input.right", "E2:E11"),
(" Input . col( 2 ) ", "C2:C11"),
("Output", "G2"),
("Output.offset(1,0)", "G3"),
("Row", "B5:D5"),
("Row.cell(1,2)", "C5"),
("Column", "B5:D5"),
("Name:Total", "F20"),
("Name:Rates", "'My Data'!A1:A5"),
("Name:Rates.cell(2,1)", "'My Data'!A2"),
("Other", "'My Data'!C3:C4"),
("B7", "B7"),
("'My Data'!A1:B2", "'My Data'!A1:B2"),
("Total", "F20"),
] {
check(&env, token, want);
}
}
#[test]
fn bad_tokens_say_why() {
let wb = wb();
// The same cells, read with and without a header row.
let inputs = vec![("Input".to_string(), place("B2:D11"), false), ("Headed".to_string(), place("B2:D11"), true)];
let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: None, item: None };
for (token, needle, edge) in [
("Input.col(4)", "outside the range", false),
("Input.col(0)", "outside the range", false),
("Input.rows(5..2)", "outside the range", false),
("Headed.col(\"Nope\")", "no column is headed", false),
("Input.col(\"Name\")", "needs an input with a header row", false),
("Headed.col(2).col(\"Amount\")", "needs an input with a header row", false),
("Name:Rates.col(\"Name\")", "needs an input with a header row", false),
("Input.colour(1)", "isn't a token method", false),
("Input.col(1", "expected", false),
("Input.col(x)", "whole number", false),
("Input.col(99999999999)", "whole number", false),
("Input.", "method name", false),
("Input junk", "unexpected", false),
("Input.resize(0,1)", "at least 1 row", false),
("Output", "no output anchor", false),
("Row", "inside a Repeat", false),
("Name:", "needs a defined name", false),
("Name:Nothing", "no name or table", false),
("Nowhere!A1", "no sheet named", false),
("Input.offset(-2,0)", "past the top", true),
("Input.offset(0,-2)", "edge of the sheet", true),
("Input.offset(1048570,0)", "past the last row", true),
("Input.cell(1,16384)", "edge of the sheet", true),
] {
let e = resolve_ref(token, &env).unwrap_err();
assert!(e.message().contains(needle), "{token}: {e}");
assert_eq!(matches!(e, ResolveError::Edge(_)), edge, "{token}");
}
}
#[test]
fn params_resolve_tokens_in_reference_keys_and_braces_anywhere() {
let wb = wb();
let inputs = vec![("Input".to_string(), place("B2:D11"), true)];
let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: Some(place("F2")), item: None };
let p = json!({
"cell": "Output",
"range": "Input.col(2)",
"ranges": ["Input.row(1)", "A1"],
"input": "Row",
"value": "Input",
"formula": "=SUM({{Input.data.col(2)}})/{{Name:Total}}",
"nested": {"target": {"$ref": "Input.cell(1,1)"}, "n": 3, "flag": true},
"text": {"$ref": "Output"},
"type": "column",
"sheet": "Input",
"rowInputCell": "Input.cell(1,1)",
"unclosed": "a {{b"
});
let r = resolve_params(&p, &env).unwrap();
assert_eq!(
r,
json!({
"cell": "F2",
"range": "C2:C11",
"ranges": ["B2:D2", "A1"],
"input": "Row",
"value": "Input",
"formula": "=SUM(C3:C11)/F20",
"nested": {"target": "B2", "n": 3, "flag": true},
"text": "F2",
"type": "column",
"sheet": "Input",
"rowInputCell": "B2",
"unclosed": "a {{b"
})
);
// Deeply nested parameters are refused, not followed.
let mut deep = json!(1);
for _ in 0..100 {
deep = json!([deep]);
}
assert!(resolve_params(&deep, &env).is_err());
assert!(resolve_params(&json!({"cell": "Input.col(9)"}), &env).is_err());
}
#[test]
fn references_on_sheets_named_like_tokens_stay_references() {
// Sheets named after the built-in bases and after an input: `Output!A1` is a cell on the
// sheet Output, in a reference key, in `$ref` and in `{{…}}`.
let mut wb = Workbook::new();
for name in ["Output", "Row", "Column", "Input", "Rates"] {
wb.sheets.push(std::sync::Arc::new(gridcraft_model::Sheet::new(name)));
}
let inputs = vec![("Input".to_string(), place("B2:D11"), true), ("Rates".to_string(), place("F2:F5"), false)];
for output in [None, Some(place("H2"))] {
let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output, item: Some((RepeatOf::Rows, place("B5:D5"))) };
for (sheet, i) in [("Output", 1), ("Row", 2), ("Column", 3), ("Input", 4), ("Rates", 5)] {
for text in [format!("{sheet}!A1:B2"), format!("'{sheet}'!A1:B2")] {
let want = Place { sheet: i, range: RangeRef::parse("A1:B2").unwrap() };
assert_eq!(resolve_ref(&text, &env), Ok(want), "{text}");
let a = a1(want, &env);
let p = json!({"range": text, "target": {"$ref": text}, "formula": format!("=SUM({{{{{text}}}}})")});
let r = resolve_params(&p, &env).unwrap();
// A plain reference in a reference key is passed on as written.
assert_eq!(r, json!({"range": text, "target": a, "formula": format!("=SUM({a})")}), "{text}");
}
}
assert!(!is_token("Output !A1", &env));
// The bare names are still tokens.
check(&env, "Input.col(2)", "C2:C11");
check(&env, "Rates", "F2:F5");
check(&env, "Row", "B5:D5");
}
}
#[test]
fn defined_names_can_hold_periods() {
// `Sales.Q1` is one defined name, beside `Sales`; `Data.row` has a segment that reads like
// a method. The longest name that exists is used, and methods follow it.
let mut wb = wb();
for (name, formula) in [("Sales", "Sheet1!$A$1"), ("Sales.Q1", "Sheet1!$B$2:$C$5"), ("Data.row", "'My Data'!$E$1:$E$3")] {
wb.names.push(gridcraft_model::DefinedName {
name: name.into(),
scope: None,
formula: formula.into(),
comment: String::new(),
hidden: false,
});
}
let env = Env { wb: &wb, active_sheet: 0, inputs: &[], output: None, item: None };
for (token, want) in [
("Name:Sales.Q1", "B2:C5"),
("Name:sales.q1", "B2:C5"),
("Name:Sales.Q1.cell(1,1)", "B2"),
("Name:Sales.Q1.col(2).row(4)", "C5"),
("Name:Sales", "A1"),
("Name:Sales.offset(1,0)", "A2"),
("Name:Data.row", "'My Data'!E1:E3"),
("Name:Data.row.row(2)", "'My Data'!E2"),
("Name:Data.row.cell(1,1)", "'My Data'!E1"),
("Sales.Q1", "B2:C5"),
] {
check(&env, token, want);
}
// In a reference parameter, `$ref` and `{{…}}`, with and without a method.
let p = json!({
"range": "Name:Sales.Q1",
"cell": "Name:Sales.Q1.cell(1,1)",
"a": {"$ref": "Name:Sales.Q1"},
"b": {"$ref": "Name:Sales.Q1.cell(2,2)"},
"formula": "=SUM({{Name:Sales.Q1}})+{{Name:Sales.Q1.cell(1,2)}}"
});
let r = resolve_params(&p, &env).unwrap();
assert_eq!(r, json!({"range": "B2:C5", "cell": "B2", "a": "B2:C5", "b": "C3", "formula": "=SUM(B2:C5)+C2"}));
// Unknown names and bad methods still fail, naming what is wrong.
for (token, needle) in [
("Name:Nope.Q1", "no name or table called 'Nope.Q1'"),
("Name:Nope.cell(1,1)", "no name or table called 'Nope'"),
("Name:Sales.Q1.bogus(1)", "'bogus' with these arguments isn't a token method"),
("Name:Sales.Q1.cell(1", "expected"),
("Name:Sales.Q1.", "method name"),
("Name:.cell(1,1)", "needs a defined name"),
] {
let e = resolve_ref(token, &env).unwrap_err();
assert!(e.message().contains(needle), "{token}: {e}");
let e = resolve_params(&json!({"formula": format!("={{{{{token}}}}}")}), &env).unwrap_err();
assert!(e.message().contains(needle), "{token} in braces: {e}");
}
}
#[test]
fn sheet_names_are_quoted_when_needed() {
// The engine's rule: a name that reads as a cell reference, an R1C1 token or a boolean
// is quoted too, and a period on its own is not a reason to quote.
for (name, want) in [
("Sheet1", "Sheet1"),
("My Data", "'My Data'"),
("O'Hara", "'O''Hara'"),
("2024", "'2024'"),
("", "''"),
("Q1", "'Q1'"),
("R1C1", "'R1C1'"),
("TRUE", "'TRUE'"),
("Sales.Q1", "Sales.Q1"),
] {
assert_eq!(quote_sheet(name), want, "{name}");
}
}
#[test]
fn a_bare_row_or_column_under_a_general_key_is_text() {
// `to` is a replacement word in Change Spelling and `area` is `row` or `column` in a
// pivot field move, so a bare `Row` or `Column` there stays text, inside a Repeat or not;
// the token forms still resolve there, and the bare word resolves under `range`.
let wb = wb();
let inputs = vec![("Input".to_string(), place("B2:D11"), true)];
for item in [None, Some((RepeatOf::Rows, place("B5:D5")))] {
let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: Some(place("F2")), item };
let p = json!({"word": "Colum", "to": "Column", "area": "row", "from": "Input", "at": "Output"});
let r = resolve_params(&p, &env).unwrap();
assert_eq!(r, json!({"word": "Colum", "to": "Column", "area": "row", "from": "B2:D11", "at": "F2"}), "{item:?}");
}
let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: None, item: Some((RepeatOf::Rows, place("B5:D5"))) };
let p = json!({"to": "Row.col(2)", "range": "Row", "area": {"$ref": "Row"}, "text": "{{Row}}"});
let r = resolve_params(&p, &env).unwrap();
assert_eq!(r, json!({"to": "C5", "range": "B5:D5", "area": "B5:D5", "text": "B5:D5"}));
}
#[test]
fn a_header_row_alone_and_a_table_without_data_rows_are_refused() {
let mut wb = wb();
let sh = wb.sheet_mut(0).unwrap();
sh.tables.push(gridcraft_model::Table {
id: 1,
name: "Empty".into(),
range: RangeRef::parse("H1:J1").unwrap(),
header_row: true,
totals_row: false,
columns: Vec::new(),
style: String::new(),
banded_rows: false,
banded_cols: false,
first_col: false,
last_col: false,
filter_button: false,
filter: Vec::new(),
table_type: None,
connection_id: None,
});
let inputs = vec![("Input".to_string(), place("B2:D2"), true), ("Plain".to_string(), place("B2:D2"), false)];
let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: None, item: None };
assert!(resolve_ref("Input.data", &env).unwrap_err().to_string().contains("only a header row"));
assert!(resolve_ref("Input.data.col(2)", &env).unwrap_err().to_string().contains("only a header row"));
check(&env, "Plain.data", "B2:D2");
check(&env, "Input.col(2)", "C2");
assert!(resolve_ref("Name:Empty", &env).unwrap_err().to_string().contains("no data rows"));
}
#[test]
fn doubled_braces_are_literal_braces() {
let wb = wb();
let inputs = vec![("Input".to_string(), place("B2:D11"), true)];
let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: None, item: None };
assert_eq!(interpolate("Dear {{{{name}},", &env).unwrap(), "Dear {{name}},");
assert_eq!(interpolate("{{{{ {{Input.cell(1,1)}} }}", &env).unwrap(), "{{ B2 }}");
assert!(interpolate("Dear {{name}},", &env).is_err(), "an unknown token is still an error");
}
#[test]
fn text_marked_as_text_is_never_a_token() {
// A defined name called Output or Input in a reference parameter, a Name: prefix and
// braces: `$text` passes each as it is, in any key; unmarked, each reads as a token.
let wb = wb();
let inputs = vec![("Input".to_string(), place("B2:D11"), true)];
let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: None, item: None };
for text in ["Output", "Input", "Name:Nowhere", "Row", "Dear {{name}}"] {
let p = json!({"range": {"$text": text}, "input": {"$text": text}});
assert_eq!(resolve_params(&p, &env).unwrap(), json!({"range": text, "input": text}), "{text}");
assert_ne!(resolve_params(&json!({"range": text}), &env).ok(), Some(json!({"range": text})), "{text}");
assert!(reads_as_token(text, &["Input"]) || text.contains("{{"), "{text}");
}
assert!(!reads_as_token("Output!A1", &["Input"]) && !reads_as_token("B2", &["Input"]));
}
#[test]
fn a_defined_name_on_a_sheet_with_a_dollar_sign_resolves() {
let mut wb = wb();
wb.sheets.push(std::sync::Arc::new(gridcraft_model::Sheet::new("US$")));
wb.names.push(gridcraft_model::DefinedName {
name: "Dollars".into(),
scope: None,
formula: "'US$'!$F$20".into(),
comment: String::new(),
hidden: false,
});
let env = Env { wb: &wb, active_sheet: 0, inputs: &[], output: None, item: None };
check(&env, "Name:Dollars", "'US$'!F20");
}
#[test]
fn below_and_right_refuse_a_count_under_one() {
let wb = wb();
let inputs = vec![("Input".to_string(), place("B2:D11"), true)];
let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: None, item: None };
check(&env, "Input.below", "B12:D12");
check(&env, "Input.below(2)", "B12:D13");
check(&env, "Input.right(1)", "E2:E11");
for bad in ["Input.below(0)", "Input.below(-3)", "Input.right(0)"] {
assert!(resolve_ref(bad, &env).unwrap_err().to_string().contains("at least 1"), "{bad}");
}
}
}
File diff suppressed because it is too large Load Diff
+740
View File
@@ -0,0 +1,740 @@
use gridcraft_model::*;
use serde_json::json;
use super::*;
fn sample_macro(name: &str) -> Macro {
parse_macro(&json!({
"name": name,
"description": "Totals the amounts",
"shortcut": "Ctrl+Shift+T",
"inputs": [{"name": "Input", "rows": [1, 500], "cols": [2, 2], "header": true, "colTypes": ["text", "number"], "nonEmpty": true}],
"steps": [
{"type": "command", "id": "cell.set", "params": {"cell": "Output", "input": "=SUM({{Input.col(2)}})"}, "breakpoint": true, "note": "the total"},
{"type": "repeat", "over": "Input.rows(2..)", "of": "rows", "max": 100, "body": [
{"type": "if", "cell": "Row.cell(1,2)", "op": "gt", "value": 100, "then": [
{"command": "home.bold", "params": {"range": "Row"}}
], "else": [{"type": "stop", "message": "done"}]}
]},
{"type": "command", "id": "home.italic", "enabled": false}
],
"output": {"anchor": "Input.offset(0,3)", "onConflict": "shiftDown"},
"permissions": ["data.getData"]
}))
.unwrap()
}
#[test]
fn macros_read_from_json_in_both_step_forms() {
let m = sample_macro("Totals");
assert_eq!(m.v, MACRO_VERSION);
assert_eq!(m.inputs[0].col_types, [ColType::Text, ColType::Number]);
assert_eq!(m.output.on_conflict, OnConflict::ShiftDown);
assert_eq!(m.step_count(), 6);
assert!(m.steps[0].breakpoint && m.steps[0].enabled && !m.steps[2].enabled);
let StepKind::Repeat { body, max, .. } = &m.steps[1].kind else { panic!("{:?}", m.steps[1]) };
assert_eq!(*max, 100);
let StepKind::If { then, otherwise, op, .. } = &body[0].kind else { panic!() };
assert_eq!(*op, CompareOp::Gt);
// The short form of earlier scripts reads as a command step.
assert_eq!(then[0].kind, StepKind::Command { id: "home.bold".into(), params: json!({"range": "Row"}) });
assert!(matches!(&otherwise[0].kind, StepKind::Stop { message, rollback: false } if message == "done"));
// Writing and reading again gives the same macro; defaults stay out of the JSON.
let v = macro_json(&m);
assert_eq!(v["steps"][0]["type"], "command");
assert!(v["steps"][1].get("enabled").is_none() && v["steps"][1].get("breakpoint").is_none());
assert_eq!(v["steps"][2]["enabled"], false);
assert_eq!(parse_macro(&v).unwrap(), m);
// A minimal macro gets the defaults.
let m = parse_macro(&json!({"name": " Bare "})).unwrap();
assert_eq!((m.name.as_str(), m.output.on_conflict, m.inputs.len()), ("Bare", OnConflict::Ask, 0));
}
#[test]
fn macros_over_the_limits_are_refused() {
let flat = |n: usize| json!({"name": "Big", "steps": vec![json!({"command": "cell.set"}); n]});
assert!(parse_macro(&flat(MAX_MACRO_STEPS)).is_ok());
assert!(parse_macro(&flat(MAX_MACRO_STEPS + 1)).unwrap_err().contains("10000 steps"));
// Nested Repeat steps: 4 Repeats one inside the other are fine (the innermost holds a
// command, or nothing), a 5th is not.
let nest = |repeats: usize| {
let mut step = json!({"command": "cell.set"});
for _ in 0..repeats {
step = json!({"type": "repeat", "over": "Input", "body": [step]});
}
json!({"name": "Deep", "steps": [step]})
};
assert!(parse_macro(&nest(MAX_MACRO_DEPTH)).is_ok());
assert_eq!(parse_macro(&nest(MAX_MACRO_DEPTH)).unwrap().step_count(), MAX_MACRO_DEPTH + 1);
assert!(parse_macro(&nest(MAX_MACRO_DEPTH + 1)).unwrap_err().contains("4 deep"));
let mut empty = json!({"type": "repeat", "over": "Input", "body": []});
for _ in 1..MAX_MACRO_DEPTH {
empty = json!({"type": "if", "cell": "A1", "op": "eq", "value": 1, "then": [empty], "else": []});
}
let deep = parse_macro(&json!({"name": "Deep", "steps": [empty]})).unwrap();
assert_eq!(deep.step_count(), MAX_MACRO_DEPTH);
assert!(deep.check_limits().is_ok(), "the model's check agrees with the parser");
// Far deeper than serde would allow is refused before it is read.
assert!(parse_macro(&nest(200)).is_err());
for bad in [
json!({"name": ""}),
json!({"name": "x".repeat(256)}),
json!({"name": "a\nb"}),
json!({"name": "A", "steps": {}}),
json!({"name": "A", "steps": [1]}),
json!({"name": "A", "steps": [{"type": "nope"}]}),
json!({"name": "A", "steps": [{"type": "if", "cell": "A1", "op": "between"}]}),
json!({"name": "A", "inputs": [{"name": ""}]}),
json!({"name": "A", "inputs": [{"name": "Has space"}]}),
json!({"name": "A", "inputs": [{"name": "row"}]}),
json!({"name": "A", "inputs": [{"name": "In"}, {"name": "IN"}]}),
json!({"name": "A", "output": {"onConflict": "explode"}}),
json!([]),
] {
assert!(parse_macro(&bad).is_err(), "{bad}");
}
}
fn names_of(wb: &Workbook) -> Vec<String> {
wb.names.iter().map(|n| n.name.clone()).collect()
}
#[test]
fn macros_round_trip_through_xlsx_and_json() {
let mut wb = Workbook::new();
wb.macros.push(sample_macro("Totals"));
// Large enough to need several hidden names.
let long: Vec<Step> = (0..300).map(|r| Step::command("cell.set", json!({"cell": format!("A{r}"), "input": "x\"y".repeat(10)}))).collect();
wb.macros.push(Macro { name: "Long".into(), steps: long, ..Macro::default() });
for file in ["book.xlsx", "book.json"] {
let bytes = crate::io::save_bytes(&wb, file).unwrap();
let (back, warnings) = crate::io::open_bytes(file, &bytes).unwrap();
assert!(warnings.is_empty(), "{file}: {warnings:?}");
assert_eq!(back.macros, wb.macros, "{file}");
assert!(back.names.is_empty(), "{file}: macros stay out of the names once opened: {:?}", names_of(&back));
}
// In the xlsx file they are hidden names; the long one spans several.
let bytes = crate::io::save_bytes(&wb, "book.xlsx").unwrap();
let (raw, _) = gridcraft_xlsx::read_xlsx(&bytes).unwrap();
let names = names_of(&raw);
assert!(names.contains(&"_gc_macro_0_0".to_string()), "{names:?}");
assert!(names.iter().filter(|n| n.starts_with("_gc_macro_1_")).count() >= 2, "{names:?}");
assert!(raw.names.iter().all(|n| n.hidden));
// Saving without macros writes no macro names, even if stale ones linger.
let mut empty = raw.clone();
empty.macros.clear();
let (again, _) = gridcraft_xlsx::read_xlsx(&crate::io::save_bytes(&empty, "x.xlsx").unwrap()).unwrap();
assert!(again.names.iter().all(|n| !n.name.starts_with("_gc_macro_")));
// The JSON format keeps the field (and leaves it out when there are no macros).
let json = String::from_utf8(crate::io::save_bytes(&wb, "book.json").unwrap()).unwrap();
assert!(json.contains("\"macros\""));
let plain = String::from_utf8(crate::io::save_bytes(&Workbook::new(), "book.json").unwrap()).unwrap();
assert!(!plain.contains("\"macros\""));
}
fn hidden(name: &str, text: &str) -> DefinedName {
DefinedName { name: name.into(), scope: None, formula: format!("\"{}\"", text.replace('"', "\"\"")), comment: String::new(), hidden: true }
}
#[test]
fn a_visible_name_that_looks_like_macro_storage_is_left_alone() {
// Macro storage is hidden workbook-scope names; a visible `_gc_macro_0_0` someone defined
// is neither read as a macro nor removed when the workbook opens.
let mut wb = Workbook::new();
let name = DefinedName { hidden: false, ..hidden("_gc_macro_0_0", "x") };
wb.names.push(name.clone());
let warnings = load(&mut wb);
assert!(warnings.is_empty(), "{warnings:?}");
assert!(wb.macros.is_empty());
assert_eq!(wb.names, vec![name]);
}
#[test]
fn input_names_that_read_as_cell_references_are_refused() {
// A recorded step holds plain A1 text; an input called `Q1` would take its place.
let with_input = |name: &str| json!({"name": "M", "inputs": [{"name": name}]});
for name in ["Q1", "AB12", "Sum1", "a1"] {
let err = parse_macro(&with_input(name)).unwrap_err();
assert!(err.contains("reads as a cell reference"), "{name}: {err}");
}
for name in ["Amount", "Q", "Sales_Q1", "Total1a"] {
assert!(parse_macro(&with_input(name)).is_ok(), "{name}");
}
assert!(parse_macro(&with_input("Row")).unwrap_err().contains("token of its own"));
}
#[test]
fn a_hidden_macro_name_this_build_did_not_write_is_kept() {
// `_gc_macro_index_0` is not a numbered item: it is neither read nor dropped on open, and a
// save with macros keeps it beside the items written.
let mut wb = Workbook::new();
let theirs = hidden("_gc_macro_index_0", "{}");
wb.names.push(theirs.clone());
wb.macros.push(sample_macro("Totals"));
let (raw, _) = gridcraft_xlsx::read_xlsx(&crate::io::save_bytes(&wb, "book.xlsx").unwrap()).unwrap();
let names = names_of(&raw);
assert!(names.contains(&"_gc_macro_index_0".to_string()) && names.contains(&"_gc_macro_0_0".to_string()), "{names:?}");
let (back, warnings) = crate::io::open_bytes("book.xlsx", &crate::io::save_bytes(&wb, "book.xlsx").unwrap()).unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
assert_eq!(back.macros, wb.macros);
assert_eq!(names_of(&back), ["_gc_macro_index_0"]);
}
#[test]
fn workbook_snapshots_share_the_macro_list_until_it_changes() {
// An undo snapshot or a copy made for a cell edit holds the same list, not a copy of every
// step; the undo budget charges a list only once it is a snapshot's own.
let mut wb = Workbook::new();
wb.macros.push(sample_macro("Totals"));
let snapshot = wb.clone();
assert!(wb.macros.shares(&snapshot.macros));
assert_eq!(crate::history_retained_bytes(&snapshot, &wb), std::mem::size_of::<Workbook>());
wb.macros.push(sample_macro("More"));
assert!(!wb.macros.shares(&snapshot.macros), "a write copies the list first");
assert_eq!(snapshot.macros.len(), 1);
assert!(crate::history_retained_bytes(&snapshot, &wb) > std::mem::size_of::<Workbook>());
// The copy is per list, not per workbook copy: cloning again shares the new list.
let again = wb.clone();
assert!(wb.macros.shares(&again.macros));
let rebuilt: Macros = wb.macros.iter().cloned().collect();
assert_eq!(rebuilt, wb.macros);
assert_eq!(rebuilt.into_iter().count(), 2);
}
#[test]
fn a_macro_built_in_code_goes_through_the_size_check_too() {
// `check` is the one check for every path, so a macro that passes it never makes a save fail.
let big = Macro {
name: "Big".into(),
steps: vec![Step::command("cell.set", json!({"input": "x".repeat(crate::cmd::stash::MAX_ITEM_CHARS)}))],
..Macro::default()
};
assert!(big.check_limits().is_ok(), "the model's limits say nothing about the size");
assert!(check(&big).unwrap_err().contains("too large"));
// A macro that fails `check` fails the save in either format, rather than being written and
// dropped the next time the file opens: too large, or past the model's limits.
let bad_name = Macro { name: "Two\nlines".into(), ..Macro::default() };
assert!(check(&bad_name).is_err());
for m in [big, bad_name] {
let mut wb = Workbook::new();
wb.macros.push(m);
for file in ["book.xlsx", "book.json"] {
assert!(crate::io::save_bytes(&wb, file).is_err(), "{file}");
}
}
}
fn visible(name: &str) -> DefinedName {
DefinedName { name: name.into(), scope: None, formula: "Sheet1!$A$1".into(), comment: String::new(), hidden: false }
}
/// Saves `wb` to xlsx and returns the names in the file and the workbook opened from it.
fn xlsx_round_trip(wb: &Workbook) -> (Vec<String>, Workbook) {
let bytes = crate::io::save_bytes(wb, "book.xlsx").unwrap();
let raw = names_of(&gridcraft_xlsx::read_xlsx(&bytes).unwrap().0);
let (back, warnings) = crate::io::open_bytes("book.xlsx", &bytes).unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
(raw, back)
}
#[test]
fn a_visible_name_of_the_storage_shape_does_not_block_a_save() {
// The index a macro is stored under is GridCraft's choice, so a person's own visible name
// of that shape (here in capitals: names compare without case) is stepped around, not
// refused. Stale hidden items still go in the same pass.
let mut wb = Workbook::new();
wb.macros.push(sample_macro("A"));
wb.macros.push(sample_macro("B"));
wb.names.push(visible("_GC_MACRO_0_0"));
wb.names.push(hidden("_gc_macro_7_0", "stale"));
let (raw, back) = xlsx_round_trip(&wb);
assert_eq!(raw, ["_GC_MACRO_0_0", "_gc_macro_1_0", "_gc_macro_2_0"]);
assert_eq!(back.macros, wb.macros, "both macros, in their order");
assert_eq!(back.names, vec![visible("_GC_MACRO_0_0")], "the person's name is kept as it was");
assert_eq!(wb.names.len(), 2, "the open workbook is not changed by a save");
}
#[test]
fn a_huge_visible_index_takes_no_part_in_numbering() {
// A file can carry a visible name with any number in it. The macros take the free indices
// from 0 up, so no arithmetic depends on that number (one past the largest would overflow).
let mut wb = Workbook::new();
wb.macros.push(sample_macro("A"));
wb.names.push(visible(&format!("_gc_macro_{}_0", u64::MAX)));
wb.names.push(visible("_gc_macro_0_0"));
let (raw, back) = xlsx_round_trip(&wb);
assert_eq!(raw, [format!("_gc_macro_{}_0", u64::MAX), "_gc_macro_0_0".into(), "_gc_macro_1_0".into()]);
assert_eq!(back.macros, wb.macros);
assert_eq!(names_of(&back), names_of(&wb));
}
#[test]
fn a_visible_legacy_name_of_the_storage_shape_is_kept() {
// The stash reads a legacy `_sc_<key>` name as data whether or not it is hidden, so writing
// key `macro_0` would remove a person's visible `_sc_macro_0`. That key is stepped around too.
let mut wb = Workbook::new();
wb.macros.push(sample_macro("A"));
wb.names.push(visible("_sc_macro_0"));
let (raw, back) = xlsx_round_trip(&wb);
assert_eq!(raw, ["_sc_macro_0", "_gc_macro_1_0"]);
assert_eq!(back.macros, wb.macros);
assert_eq!(back.names, vec![visible("_sc_macro_0")]);
}
#[test]
fn a_name_defined_after_opening_does_not_block_the_next_save() {
// Once a file is open its macros are out of the names, so Define Name accepts a visible
// `_gc_macro_0_0`; the next save steps around it.
let mut s = crate::Session::new();
s.execute("file.new", json!({})).unwrap();
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).macros.push(sample_macro("A"));
s.execute("formulas.defineName", json!({"name": "_gc_macro_0_0", "refersTo": "=Sheet1!$A$1"})).unwrap();
let (raw, back) = xlsx_round_trip(&s.doc().unwrap().wb);
assert_eq!(raw, ["_gc_macro_0_0", "_gc_macro_1_0"]);
assert_eq!(back.macros.len(), 1);
assert_eq!(names_of(&back), ["_gc_macro_0_0"]);
}
#[test]
fn scripts_and_vba_stay_as_they_are() {
// Automate scripts keep their own storage, and a VBA project is carried through unchanged.
let mut wb = Workbook::new();
let scripts = json!({"Fill": [{"command": "cell.set", "params": {"cell": "A1", "input": "x"}}]});
wb.names.push(hidden("_sc_scripts", &scripts.to_string()));
wb.vba_project = Some(std::sync::Arc::new(b"\xd0\xcf\x11\xe0 synthetic vbaProject".to_vec()));
wb.macros.push(sample_macro("Totals"));
let bytes = crate::io::save_bytes(&wb, "book.xlsm").unwrap();
let (back, warnings) = crate::io::open_bytes("book.xlsm", &bytes).unwrap();
// The only warning is the reader's note that the VBA project is kept and not run.
assert!(warnings.iter().all(|w| w.contains("VBA project")), "{warnings:?}");
assert_eq!(back.macros, wb.macros);
assert_eq!(names_of(&back), ["_sc_scripts"], "the scripts item is untouched");
assert_eq!(back.names[0].formula, wb.names[0].formula);
assert_eq!(back.vba_project, wb.vba_project);
}
#[test]
fn damaged_or_oversized_macros_are_dropped_with_a_warning() {
let mut wb = Workbook::new();
wb.names.push(hidden("_gc_macro_0_0", &json!({"name": "Good"}).to_string()));
wb.names.push(hidden("_gc_macro_1_0", "{not json"));
wb.names.push(hidden("_gc_macro_2_0", &json!({"name": "Huge", "steps": vec![json!({"command": "x"}); MAX_MACRO_STEPS + 1]}).to_string()));
wb.names.push(hidden("_gc_macro_3_0", &json!({"name": "good"}).to_string()));
wb.names.push(hidden("_gc_macro_10_0", &json!({"name": "Tenth"}).to_string()));
let bytes = gridcraft_xlsx::write_xlsx(&wb).unwrap();
let (back, warnings) = crate::io::open_bytes("hostile.xlsx", &bytes).unwrap();
let names: Vec<&str> = back.macros.iter().map(|m| m.name.as_str()).collect();
// Saved order is by number (10 after 3); a clashing name gets a suffix.
assert_eq!(names, ["Good", "good (2)", "Tenth"]);
assert!(back.names.is_empty(), "{:?}", names_of(&back));
assert_eq!(warnings.len(), 1, "{warnings:?}");
assert!(warnings[0].starts_with("2 macros"), "{warnings:?}");
// The JSON format: more than the limit, and one over the step limit.
let mut wb = Workbook::new();
wb.macros = (0..MAX_MACROS + 5).map(|i| Macro { name: format!("M{i}"), ..Macro::default() }).collect();
wb.macros[0].steps = vec![Step::command("x", json!({})); MAX_MACRO_STEPS + 1];
// Written as another program would: a GridCraft save refuses a macro that fails `check`.
let bytes = serde_json::to_vec(&wb).unwrap();
let (back, warnings) = crate::io::open_bytes("many.json", &bytes).unwrap();
// Only the first MAX_MACROS entries are read, as in xlsx.
assert_eq!(back.macros.len(), MAX_MACROS - 1);
assert_eq!(back.macros[0].name, "M1");
assert!(warnings[0].starts_with("6 macros"), "{warnings:?}");
// A macro too large for the hidden names is refused, so a save never fails on it.
let huge = "x".repeat(crate::cmd::stash::MAX_ITEM_CHARS);
let err = parse_macro(&json!({"name": "Huge", "steps": [{"command": "cell.set", "params": {"input": huge}}]})).unwrap_err();
assert!(err.contains("too large"), "{err}");
let mut wb = Workbook::new();
wb.macros.push(Macro { name: "Huge".into(), steps: vec![Step::command("cell.set", json!({"input": huge}))], ..Macro::default() });
// Written as another program would: a GridCraft save refuses a macro that fails `check`.
let bytes = serde_json::to_vec(&wb).unwrap();
let (back, warnings) = crate::io::open_bytes("huge.json", &bytes).unwrap();
assert!(back.macros.is_empty() && warnings[0].starts_with("1 macro "), "{warnings:?}");
}
#[test]
fn names() {
let mut wb = Workbook::new();
wb.macros.push(Macro { name: "Macro1".into(), ..Macro::default() });
assert_eq!(unique_name(&wb, "macro1"), "macro1 (2)");
assert_eq!(unique_name(&wb, "Other"), "Other");
assert_eq!(unique_name(&wb, ""), "Macro");
// A long name is shortened to make room for the suffix.
let long = "y".repeat(MAX_MACRO_NAME);
wb.macros.push(Macro { name: long.clone(), ..Macro::default() });
let renamed = unique_name(&wb, &long);
assert_eq!(renamed, format!("{} (2)", "y".repeat(MAX_MACRO_NAME - 4)));
wb.macros.push(Macro { name: renamed, ..Macro::default() });
assert_eq!(unique_name(&wb, &long).chars().count(), MAX_MACRO_NAME);
assert_eq!(find(&wb, " MACRO1 "), Some(0));
assert_eq!(find(&wb, "Macro2"), None);
let a = sample_macro("A");
let mut b = a.clone();
assert_eq!(fingerprint(&a), fingerprint(&b));
b.steps[0].note = "changed".into();
assert_ne!(fingerprint(&a), fingerprint(&b));
assert_eq!(next_name(&wb), "Macro2");
let paths: Vec<(String, &str)> = command_steps(&a.steps).into_iter().map(|(p, id, _)| (p, id)).collect();
assert_eq!(paths, [("1".into(), "cell.set"), ("2.1.then.1".into(), "home.bold"), ("3".into(), "home.italic")]);
}
#[test]
fn json_field_macros_get_the_name_check_too() {
// A JSON workbook can't bring in a name that macro.save and macro.rename refuse.
let mut wb = Workbook::new();
wb.macros.push(Macro { name: "a\nb".into(), ..Macro::default() });
wb.macros.push(Macro { name: "Fine".into(), ..Macro::default() });
// Written as another program would: a GridCraft save refuses a macro that fails `check`.
let bytes = serde_json::to_vec(&wb).unwrap();
let (back, warnings) = crate::io::open_bytes("ctl.json", &bytes).unwrap();
let names: Vec<&str> = back.macros.iter().map(|m| m.name.as_str()).collect();
assert_eq!(names, ["Fine"]);
assert!(warnings.len() == 1 && warnings[0].starts_with("1 macro "), "{warnings:?}");
}
#[test]
fn duplicate_names_at_the_length_limit_survive_save_and_reopen() {
// Two macros with the same 255-character name: the second is renamed within the limit, so a
// save and a second open keep both.
let long = "é".repeat(MAX_MACRO_NAME);
for file in ["dup.json", "dup.xlsx"] {
let mut wb = Workbook::new();
wb.macros.push(Macro { name: long.clone(), ..Macro::default() });
wb.macros.push(Macro { name: long.clone(), description: "second".into(), ..Macro::default() });
let (first, warnings) = crate::io::open_bytes(file, &crate::io::save_bytes(&wb, file).unwrap()).unwrap();
assert!(warnings.is_empty(), "{file}: {warnings:?}");
assert_eq!(first.macros.len(), 2, "{file}");
let renamed = &first.macros[1].name;
assert_eq!(renamed.chars().count(), MAX_MACRO_NAME, "{file}");
assert!(renamed.ends_with(" (2)") && renamed.starts_with("éé"), "{file}: {renamed}");
let (again, warnings) = crate::io::open_bytes(file, &crate::io::save_bytes(&first, file).unwrap()).unwrap();
assert!(warnings.is_empty(), "{file}: {warnings:?}");
assert_eq!(again.macros, first.macros, "{file}");
}
}
#[test]
#[allow(clippy::disallowed_methods)] // a native-only timing test: the clock is read off wasm
fn many_hidden_macro_names_are_read_in_one_pass() {
// A hostile file with many damaged macro items: only the first MAX_MACROS are read, and the
// names are gathered and removed in one pass instead of one scan of every name per item.
let mut wb = Workbook::new();
let items = 20_000;
for i in 0..items {
wb.names.push(hidden(&format!("_gc_macro_{i}_0"), "{not json"));
}
wb.names.push(hidden("_gc_other_0", "kept"));
let start = std::time::Instant::now();
let warnings = load(&mut wb);
let took = start.elapsed();
assert!(wb.macros.is_empty());
assert_eq!(names_of(&wb), ["_gc_other_0"]);
assert!(warnings.len() == 1 && warnings[0].starts_with(&format!("{items} macros")), "{warnings:?}");
assert!(took < std::time::Duration::from_secs(3), "{took:?}");
}
#[test]
fn a_damaged_json_macro_drops_alone() {
// One macro this build can't read (a newer step type, an unknown value, the wrong shape) is
// dropped with the usual warning; the workbook and its other macros open.
let mut wb = Workbook::new();
wb.sheet_mut(0).unwrap().set_value(gridcraft_core::CellRef::new(0, 0), gridcraft_core::Value::text("kept"));
let mut v: serde_json::Value = serde_json::from_slice(&crate::io::save_bytes(&wb, "book.json").unwrap()).unwrap();
v["macros"] = json!([
{"name": "Good"},
{"name": "Newer", "steps": [{"type": "fromALaterBuild"}]},
{"name": "Odd", "output": {"onConflict": "explode"}},
{"name": "Short", "steps": [{"command": "cell.set", "params": {"cell": "A1", "input": "x"}}]},
"not a macro"
]);
let (back, warnings) = crate::io::open_bytes("book.json", v.to_string().as_bytes()).unwrap();
let names: Vec<&str> = back.macros.iter().map(|m| m.name.as_str()).collect();
assert_eq!(names, ["Good", "Short"]);
assert!(matches!(&back.macros[1].steps[0].kind, StepKind::Command { id, .. } if id == "cell.set"));
assert!(back.macros_read.is_empty());
assert!(warnings.len() == 1 && warnings[0].starts_with("3 macros"), "{warnings:?}");
assert_eq!(back.sheet(0).unwrap().value(gridcraft_core::CellRef::new(0, 0)), gridcraft_core::Value::text("kept"));
}
#[test]
fn a_macro_in_a_newer_format_is_kept_unread_and_saved_back_unchanged() {
// A newer build's macro (`v` 2, with a field this build doesn't know) would lose that field
// if it were read here, so it is never read: it is kept as the file held it, saved back
// unchanged, and the newer build gets it back whole. Version 1 and a missing version are
// read as before.
let future = json!({"name": "Future", "v": MACRO_VERSION + 1, "fromALaterBuild": {"x": 1}, "steps": [{"command": "cell.set"}]});
let current = json!({"name": "Current", "v": MACRO_VERSION, "steps": [{"command": "cell.set"}]});
let missing = json!({"name": "Missing"});
let err = parse_macro(&future).unwrap_err();
assert!(err.contains("newer version") && err.contains("format 2"), "{err}");
assert!(parse_macro(&json!({"name": "Huge", "v": u64::from(u32::MAX) + 1})).unwrap_err().contains("newer version"));
// A macro built in code goes through the same check on every path.
assert!(Macro { name: "Built".into(), v: MACRO_VERSION + 1, ..Macro::default() }.check_limits().is_err());
let check = |file: &str, back: &Workbook, warnings: &[String]| {
let names: Vec<&str> = back.macros.iter().map(|m| m.name.as_str()).collect();
assert_eq!(names, ["Current", "Missing"], "{file}");
assert!(back.macros.iter().all(|m| m.v == MACRO_VERSION), "{file}");
assert_eq!(*back.macros_unread, std::slice::from_ref(&future), "{file}");
assert!(
warnings.len() == 1 && warnings[0].starts_with("1 macro ") && warnings[0].contains("newer version") && warnings[0].contains("unchanged"),
"{file}: {warnings:?}"
);
// Saved and opened again, it is still there, whole, and the others are as they were.
let (again, warnings) = crate::io::open_bytes(file, &crate::io::save_bytes(back, file).unwrap()).unwrap();
assert_eq!(warnings.len(), 1, "{file}: {warnings:?}");
assert_eq!(again.macros, back.macros, "{file}");
assert_eq!(again.macros_unread, back.macros_unread, "{file}");
};
// The JSON format's field.
let mut v: serde_json::Value = serde_json::from_slice(&crate::io::save_bytes(&Workbook::new(), "book.json").unwrap()).unwrap();
v["macros"] = json!([future, current, missing]);
let (back, warnings) = crate::io::open_bytes("book.json", v.to_string().as_bytes()).unwrap();
check("book.json", &back, &warnings);
let saved: serde_json::Value = serde_json::from_slice(&crate::io::save_bytes(&back, "book.json").unwrap()).unwrap();
assert!(saved["macros"].as_array().unwrap().contains(&future), "the file holds it as it was");
// Hidden names in xlsx.
let mut wb = Workbook::new();
for (i, m) in [&future, &current, &missing].into_iter().enumerate() {
wb.names.push(hidden(&format!("_gc_macro_{i}_0"), &m.to_string()));
}
let bytes = gridcraft_xlsx::write_xlsx(&wb).unwrap();
let (back, warnings) = crate::io::open_bytes("book.xlsx", &bytes).unwrap();
assert!(back.names.is_empty(), "{:?}", names_of(&back));
check("book.xlsx", &back, &warnings);
}
#[test]
fn kept_macros_share_the_save_limits_and_the_numbering() {
// A newer macro too large for the file is left out (keeping it would make every xlsx save
// fail), and readable and unread macros together stay within the macro limit, readable ones
// first. Kept ones are numbered after the readable ones, around a person's visible name.
let newer = |name: &str| json!({"name": name, "v": MACRO_VERSION + 1});
let mut big = newer("Big");
big["pad"] = json!(long(crate::cmd::stash::MAX_ITEM_CHARS));
let mut file: serde_json::Value = serde_json::from_slice(&crate::io::save_bytes(&Workbook::new(), "book.json").unwrap()).unwrap();
let mut list = vec![big, newer("Kept"), json!({"name": "Read"})];
file["macros"] = json!(list);
let (mut wb, warnings) = crate::io::open_bytes("book.json", file.to_string().as_bytes()).unwrap();
assert_eq!(*wb.macros_unread, [newer("Kept")]);
assert_eq!(warnings.len(), 2, "one kept, one left out: {warnings:?}");
wb.names.push(visible("_gc_macro_1_0"));
let (raw, back) = {
let bytes = crate::io::save_bytes(&wb, "book.xlsx").unwrap();
let raw = names_of(&gridcraft_xlsx::read_xlsx(&bytes).unwrap().0);
(raw, crate::io::open_bytes("book.xlsx", &bytes).unwrap().0)
};
assert_eq!(raw, ["_gc_macro_1_0", "_gc_macro_0_0", "_gc_macro_2_0"]);
assert_eq!((back.macros.len(), back.macros_unread.len()), (1, 1));
// Past the limit the rest are left out; and when a JSON file's list is full, an unread item
// it also holds in hidden names is the one left out: readable macros come first.
list = (0..MAX_MACROS).map(|i| json!({"name": format!("M{i}")})).collect();
let mut with_item = Workbook::new();
with_item.names.push(hidden("_gc_macro_0_0", &newer("InNames").to_string()));
let mut file: serde_json::Value = serde_json::from_slice(&crate::io::save_bytes(&with_item, "book.json").unwrap()).unwrap();
for extra in [None, Some(newer("Over"))] {
file["macros"] = json!(list.iter().cloned().chain(extra).collect::<Vec<_>>());
let (wb, warnings) = crate::io::open_bytes("book.json", file.to_string().as_bytes()).unwrap();
assert_eq!((wb.macros.len(), wb.macros_unread.len()), (MAX_MACROS, 0));
assert!(warnings.len() == 1 && warnings[0].contains("couldn't be opened"), "{warnings:?}");
}
}
#[test]
fn many_macros_with_one_name_all_survive_save_and_reopen() {
// MAX_MACROS macros with the same name, at the length limit, so the suffixes run from " (2)"
// to " (1000)" and the kept part of the name shrinks as they widen.
let long = "é".repeat(MAX_MACRO_NAME);
for file in ["same.json", "same.xlsx"] {
let mut wb = Workbook::new();
wb.macros = (0..MAX_MACROS).map(|i| Macro { name: long.clone(), description: format!("{i}"), ..Macro::default() }).collect();
let (first, warnings) = crate::io::open_bytes(file, &crate::io::save_bytes(&wb, file).unwrap()).unwrap();
assert!(warnings.is_empty(), "{file}: {warnings:?}");
assert_eq!(first.macros.len(), MAX_MACROS, "{file}");
let names: std::collections::HashSet<String> = first.macros.iter().map(|m| m.name.to_lowercase()).collect();
assert_eq!(names.len(), MAX_MACROS, "{file}: every name is different");
assert!(first.macros.iter().all(|m| m.name.chars().count() <= MAX_MACRO_NAME && m.check_limits().is_ok()), "{file}");
assert_eq!(first.macros[0].name, long);
assert_eq!(first.macros[9].name, format!("{} (10)", "é".repeat(MAX_MACRO_NAME - 5)));
assert_eq!(first.macros[MAX_MACROS - 1].name, format!("{} (1000)", "é".repeat(MAX_MACRO_NAME - 7)));
assert!(first.macros.iter().enumerate().all(|(i, m)| m.description == format!("{i}")), "{file}: order kept");
let (again, warnings) = crate::io::open_bytes(file, &crate::io::save_bytes(&first, file).unwrap()).unwrap();
assert!(warnings.is_empty(), "{file}: {warnings:?}");
assert_eq!(again.macros, first.macros, "{file}");
}
}
#[test]
fn naming_clashing_macros_takes_linear_work() {
// Each suffix is looked up about once, not once per earlier macro with the same name.
let mut taken = Taken::default();
// Names a file may already use, in the way of the first suffixes.
for n in 2..=500 {
taken.insert(&format!("Same ({n})"));
}
let mut out = Vec::new();
for _ in 0..MAX_MACROS {
let name = taken.unique("SAME");
taken.insert(&name);
out.push(name);
}
assert_eq!(out[..3], ["SAME", "SAME (501)", "SAME (502)"]);
assert!(taken.probes <= 3 * MAX_MACROS, "{} lookups", taken.probes);
// The smallest free suffix is still the one used.
let mut taken = Taken::default();
for name in ["A", "A (3)"] {
taken.insert(name);
}
assert_eq!(taken.unique("a"), "a (2)");
taken.insert("a (2)");
assert_eq!(taken.unique("a"), "a (4)");
}
#[test]
fn a_json_workbook_with_no_sheets_keeps_its_macros() {
// A JSON file with an empty sheet list opens as a new workbook; its macros come along.
let mut wb = Workbook::new();
wb.macros.push(sample_macro("A"));
let mut v: serde_json::Value = serde_json::from_slice(&crate::io::save_bytes(&wb, "book.json").unwrap()).unwrap();
v["sheets"] = json!([]);
let (back, warnings) = crate::io::open_bytes("book.json", v.to_string().as_bytes()).unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
assert_eq!(back.sheets.len(), 1);
assert_eq!(back.macros, wb.macros);
}
fn long(n: usize) -> String {
"x".repeat(n)
}
/// `sample_macro` as JSON with `change` made to it.
fn changed(change: fn(&mut serde_json::Value)) -> serde_json::Value {
let mut v = macro_json(&sample_macro("T"));
change(&mut v);
v
}
#[test]
fn every_field_a_file_sets_has_a_limit() {
// A named edit to the sample file's JSON.
type Edit = (&'static str, fn(&mut serde_json::Value));
// The sample's steps: 0 a command, 1 a Repeat whose body holds an If whose else holds a Stop.
let refused: [Edit; 14] = [
("shortcut", |v| v["shortcut"] = json!(long(MAX_MACRO_SHORTCUT + 1))),
("shortcut line break", |v| v["shortcut"] = json!("Ctrl+\nM")),
("permission count", |v| v["permissions"] = json!(vec!["data.getData"; MAX_MACRO_PERMISSIONS + 1])),
("permission text", |v| v["permissions"] = json!(["data getData"])),
("output anchor", |v| v["output"]["anchor"] = json!(long(MAX_MACRO_REF + 1))),
("default range", |v| v["inputs"][0]["defaultRef"] = json!(long(MAX_MACRO_REF + 1))),
("column types", |v| {
v["inputs"][0]["cols"] = json!([1, 0]);
v["inputs"][0]["colTypes"] = json!(vec!["any"; MAX_MACRO_COLUMNS + 1]);
}),
("command id", |v| v["steps"][0]["id"] = json!("cell set")),
("empty command id", |v| v["steps"][0]["id"] = json!("")),
("Repeat range", |v| v["steps"][1]["over"] = json!(long(MAX_MACRO_REF + 1))),
("If cell", |v| v["steps"][1]["body"][0]["cell"] = json!(long(MAX_MACRO_REF + 1))),
// As JSON the string takes its two quotes as well.
("If value", |v| v["steps"][1]["body"][0]["value"] = json!(long(MAX_MACRO_TEXT - 1))),
("Stop message", |v| v["steps"][1]["body"][0]["else"][0]["message"] = json!(long(MAX_MACRO_TEXT + 1))),
("Stop message, nested", |v| v["steps"][1]["body"][0]["else"][0]["message"] = json!(long(MAX_MACRO_TEXT * 2))),
];
for (what, change) in refused {
assert!(parse_macro(&changed(change)).is_err(), "{what} past its limit is refused");
}
let accepted: [Edit; 9] = [
("shortcut", |v| v["shortcut"] = json!(long(MAX_MACRO_SHORTCUT))),
("permission count", |v| v["permissions"] = json!(vec!["data.getData"; MAX_MACRO_PERMISSIONS])),
("output anchor", |v| v["output"]["anchor"] = json!(long(MAX_MACRO_REF))),
("column types", |v| {
v["inputs"][0]["cols"] = json!([1, 0]);
v["inputs"][0]["colTypes"] = json!(vec!["any"; MAX_MACRO_COLUMNS]);
}),
("If value", |v| v["steps"][1]["body"][0]["value"] = json!(long(MAX_MACRO_TEXT - 2))),
// Empty and NotEmpty ignore the value, which is then left out (null).
("If with no value", |v| {
v["steps"][1]["body"][0]["op"] = json!("empty");
v["steps"][1]["body"][0].as_object_mut().unwrap().remove("value");
}),
// How many times a Repeat may run is the runner's to bound, not the format's.
("Repeat max", |v| v["steps"][1]["max"] = json!(u32::MAX)),
("Stop message", |v| v["steps"][1]["body"][0]["else"][0]["message"] = json!(long(MAX_MACRO_TEXT))),
("no shortcut", |v| {
v.as_object_mut().unwrap().remove("shortcut");
}),
];
for (what, change) in accepted {
assert!(parse_macro(&changed(change)).is_ok(), "{what} at its limit is accepted");
}
// Every command there is passes as a step and as a permission.
for spec in crate::command_specs() {
let m = json!({"name": "T", "steps": [{"command": spec.id}], "permissions": [spec.id]});
assert!(parse_macro(&m).is_ok(), "{}", spec.id);
}
}
#[test]
fn an_input_declaration_is_checked_as_a_whole() {
let input = |i: serde_json::Value| json!({"name": "T", "inputs": [i]});
for i in [
json!({"name": "Input", "rows": [5, 2]}),
json!({"name": "Input", "cols": [3, 2]}),
json!({"name": "Input", "kind": "cell", "cols": [2, 5]}),
json!({"name": "Input", "kind": "cell", "rows": [0, 0]}),
json!({"name": "Input", "kind": "cell", "header": true}),
json!({"name": "Input", "kind": "cell", "colTypes": ["number", "text"]}),
json!({"name": "Input", "cols": [2, 2], "colTypes": ["number", "text", "any"]}),
] {
assert!(parse_macro(&input(i.clone())).is_err(), "{i}");
}
for i in [
json!({"name": "Input", "rows": [5, 0]}),
json!({"name": "Input", "rows": [2, 2], "cols": [0, 3]}),
json!({"name": "Input", "kind": "cell"}),
json!({"name": "Input", "kind": "cell", "rows": [1, 1], "cols": [1, 1], "colTypes": ["number"]}),
json!({"name": "Input", "cols": [1, 2], "colTypes": ["number", "text"]}),
] {
assert!(parse_macro(&input(i.clone())).is_ok(), "{i}");
}
// From a file, a macro whose declaration fails is left out with the usual warning, and the
// workbook's other macros open.
let mut file: serde_json::Value = serde_json::from_slice(&crate::io::save_bytes(&Workbook::new(), "book.json").unwrap()).unwrap();
file["macros"] = json!([input(json!({"name": "Input", "rows": [5, 2]})), {"name": "Fine"}]);
let (wb, warnings) = crate::io::open_bytes("book.json", file.to_string().as_bytes()).unwrap();
assert_eq!(wb.macros.iter().map(|m| m.name.as_str()).collect::<Vec<_>>(), ["Fine"]);
assert_eq!(warnings.len(), 1, "{warnings:?}");
}
#[test]
fn the_storage_rules_hold_together_through_both_formats() {
// A visible name of the storage shape, a macro in a newer format and one whose input breaks
// the declaration rules, in one file, saved and opened again in each format and then the
// other: the visible name stays, the newer macro comes back whole, the broken one is left out
// once, and the readable one survives.
let newer = json!({"name": "Kept", "v": MACRO_VERSION + 1, "fromALaterBuild": true});
let broken = json!({"name": "Broken", "inputs": [{"name": "Input", "rows": [5, 2]}]});
let mut start = Workbook::new();
start.names.push(visible("_gc_macro_0_0"));
let mut file: serde_json::Value = serde_json::from_slice(&crate::io::save_bytes(&start, "book.json").unwrap()).unwrap();
file["macros"] = json!([newer, broken, {"name": "Good"}]);
let (wb, warnings) = crate::io::open_bytes("book.json", file.to_string().as_bytes()).unwrap();
assert_eq!(warnings.len(), 2, "one kept, one left out: {warnings:?}");
let expect = |wb: &Workbook, how: &str| {
assert_eq!(wb.macros.iter().map(|m| m.name.as_str()).collect::<Vec<_>>(), ["Good"], "{how}");
assert_eq!(*wb.macros_unread, std::slice::from_ref(&newer), "{how}");
assert_eq!(names_of(wb), ["_gc_macro_0_0"], "{how}");
};
expect(&wb, "opened");
for formats in [["book.xlsx", "book.json"], ["book.json", "book.xlsx"]] {
let mut now = wb.clone();
for file in formats {
let (back, warnings) = crate::io::open_bytes(file, &crate::io::save_bytes(&now, file).unwrap()).unwrap();
assert_eq!(warnings.len(), 1, "{file}: only the kept macro is reported again: {warnings:?}");
expect(&back, file);
now = back;
}
}
let bytes = crate::io::save_bytes(&wb, "book.xlsx").unwrap();
assert_eq!(names_of(&gridcraft_xlsx::read_xlsx(&bytes).unwrap().0), ["_gc_macro_0_0", "_gc_macro_1_0", "_gc_macro_2_0"]);
}
+98 -5
View File
@@ -8,6 +8,7 @@
use serde::Serialize;
use serde_json::Value as Json;
use std::collections::BTreeMap;
use crate::Selection;
@@ -18,6 +19,11 @@ pub const RECORD_LIMIT: usize = 100_000;
/// without a size cap a recording of a few opens of a large workbook keeps tens of MB alive.
pub const RECORD_MAX_BYTES: usize = 64 * 1024 * 1024;
/// Commands never recorded: recovery actions and the debugger, not steps of the person's work. A
/// recording that replayed Stop Action would stop the run it is a step of, and the debugger runs
/// on its own, never as a step of a script or macro.
pub const NOT_RECORDED: [&str; 2] = ["automate.stopAction", "macro.debug"];
/// One recorded command and where it ran.
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -34,33 +40,120 @@ pub struct RecordedStep {
pub params: Json,
}
/// Numbers each recording, so an undo group's mark from an earlier one isn't read as this one's.
static NEXT_RECORDING: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
/// The steps recorded since recording started.
#[derive(Clone, Debug, Default)]
#[derive(Clone, Debug)]
pub struct Recorder {
pub steps: Vec<RecordedStep>,
/// Steps not kept because the recording reached [`RECORD_LIMIT`] or [`RECORD_MAX_BYTES`].
/// Once a step is dropped, every later one is too, so `steps` is always the beginning of
/// what ran, never a sequence with gaps.
pub dropped: usize,
/// The size of `steps` serialized as JSON.
/// The size of `steps` and `ran` serialized as JSON.
bytes: usize,
/// What a step ran, by its index in `steps`, for a command that runs steps stored in the
/// workbook (`automate.runScript`, `macro.run`): the commands as they ran, so a recording
/// keeps them even when the script or macro is edited or deleted before the recording stops.
/// A run without an entry is left out when the recording is used.
pub ran: BTreeMap<usize, Vec<(String, Json)>>,
/// Which recording this is, and the number the next kept step gets: each step's number is in
/// `seqs`, beside it, so a mark stays right however many steps rollbacks take out.
generation: u64,
next_seq: u64,
seqs: Vec<u64>,
}
impl Default for Recorder {
fn default() -> Self {
let generation = NEXT_RECORDING.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
Recorder { steps: Vec::new(), dropped: 0, bytes: 0, ran: BTreeMap::new(), generation, next_seq: 0, seqs: Vec::new() }
}
}
/// `params` with the state a toggle set (Bold's `on`, from the command's result), as Excel
/// records `Bold = True`: playing the step back sets that state rather than flipping it again.
pub(crate) fn with_toggle_state(mut params: Json, result: &Json) -> Json {
if let Some(on @ Json::Bool(_)) = result.get("on") {
if params.is_null() {
params = Json::Object(serde_json::Map::new());
}
if let Json::Object(p) = &mut params
&& !p.contains_key("on")
{
p.insert("on".into(), on.clone());
}
}
params
}
impl Recorder {
/// Adds a step, or counts it as dropped once the buffer is full.
pub fn push(&mut self, step: RecordedStep) {
/// Adds a step, or counts it as dropped once the buffer is full; `true` when it was kept.
pub fn push(&mut self, step: RecordedStep) -> bool {
// Once a step has been dropped, or the step count is full, the rest are counted without
// being serialized (a `file.open` step holds a whole file).
if self.dropped > 0 || self.steps.len() >= RECORD_LIMIT {
self.dropped = self.dropped.saturating_add(1);
return;
return false;
}
let size = serde_json::to_vec(&step).map_or(0, |v| v.len());
if self.bytes.saturating_add(size) <= RECORD_MAX_BYTES {
self.bytes = self.bytes.saturating_add(size);
self.steps.push(step);
self.seqs.push(self.next_seq);
self.next_seq = self.next_seq.saturating_add(1);
true
} else {
self.dropped = self.dropped.saturating_add(1);
false
}
}
/// Attaches what the last kept step ran ([`Recorder::ran`]), within the byte budget: past it,
/// the step stays without it, and the recording leaves the run out and lists it as skipped.
pub fn attach_ran(&mut self, steps: Vec<(String, Json)>) {
let Some(index) = self.steps.len().checked_sub(1) else { return };
let size = serde_json::to_vec(&steps).map_or(0, |v| v.len());
if self.bytes.saturating_add(size) <= RECORD_MAX_BYTES {
self.bytes = self.bytes.saturating_add(size);
self.ran.insert(index, steps);
}
}
/// Where the recording is now, for [`Recorder::cut_back`]: this recording and the number its
/// next step gets.
pub(crate) fn mark(&self) -> (u64, u64) {
(self.generation, self.next_seq)
}
/// Takes back the steps recorded in the document with uid `doc` since `mark` (an undo group
/// on it was rolled back, so playing them back would make edits it no longer has). Steps in
/// other documents stay: their edits do too. Steps the buffer dropped stay counted.
/// What a step ran ([`Recorder::ran`]) goes with its step, and stays with a kept one under its
/// new index. A mark from another recording (the recording started again inside the group)
/// means every step of this one came after it.
pub(crate) fn cut_back(&mut self, (generation, seq): (u64, u64), doc: u64) {
let from = if generation == self.generation { seq } else { 0 };
let gone = |i: usize, step: &RecordedStep| step.doc == doc && self.seqs.get(i).is_some_and(|&n| n >= from);
if !self.steps.iter().enumerate().any(|(i, s)| gone(i, s)) {
return;
}
let mut ran = std::mem::take(&mut self.ran);
let (mut steps, mut seqs) = (Vec::with_capacity(self.steps.len()), Vec::with_capacity(self.seqs.len()));
for (i, step) in self.steps.iter().enumerate() {
if gone(i, step) {
continue;
}
if let Some(r) = ran.remove(&i) {
self.ran.insert(steps.len(), r);
}
steps.push(step.clone());
seqs.push(self.seqs.get(i).copied().unwrap_or(0));
}
(self.steps, self.seqs) = (steps, seqs);
let size = |v: Result<Vec<u8>, serde_json::Error>| v.map_or(0, |v| v.len());
self.bytes = self.steps.iter().map(|s| size(serde_json::to_vec(s))).sum::<usize>()
+ self.ran.values().map(|r| size(serde_json::to_vec(r))).sum::<usize>();
}
}
+118
View File
@@ -1081,6 +1081,48 @@ fn sort_and_filter() {
assert_eq!(r["hiddenRows"], 1);
}
/// Delete and the Clear commands on a filtered selection clear only the rows showing, as in
/// Excel (#1419); rows hidden by hand are cleared with the rest.
#[test]
fn clearing_a_filtered_selection_leaves_the_rows_the_filter_hides() {
let mut s = s();
let rows = [["Name", "Status"], ["John", "Active"], ["Mary", "Inactive"], ["David", "Active"], ["Sarah", "Inactive"], ["Michael", "Active"]];
s.execute("range.setValues", json!({"range": "A1", "values": rows})).unwrap();
s.execute("home.bold", json!({"range": "A2:A6", "on": true})).unwrap();
s.execute("review.newNote", json!({"cell": "A3", "text": "note", "author": "Example"})).unwrap();
s.execute("selection.set", json!({"cell": "A1"})).unwrap();
s.execute("data.filter", json!({})).unwrap();
s.execute("data.filterBy", json!({"column": "B", "values": ["Active"]})).unwrap();
let bold = |s: &Session, a: &str| {
let d = s.doc().unwrap();
d.wb.styles.get(d.wb.active().unwrap().style_id(CellRef::parse(a).unwrap())).font.bold
};
s.execute("edit.clearContents", json!({"range": "A2:A6"})).unwrap();
for a in ["A2", "A4", "A6"] {
assert_eq!(v(&s, a), Value::Empty, "{a} shows and is cleared");
}
assert_eq!(v(&s, "A3"), Value::from("Mary"));
assert_eq!(v(&s, "A5"), Value::from("Sarah"));
s.execute("edit.clearFormats", json!({"range": "A2:A6"})).unwrap();
assert!(!bold(&s, "A2"));
assert!(bold(&s, "A3"), "a filtered-out row keeps its format");
s.execute("edit.clearAll", json!({"range": "A2:B6"})).unwrap();
assert_eq!(v(&s, "B2"), Value::Empty);
assert_eq!(v(&s, "B3"), Value::from("Inactive"));
assert_eq!(v(&s, "A5"), Value::from("Sarah"));
assert!(s.doc().unwrap().wb.active().unwrap().comments.contains_key(&CellRef::parse("A3").unwrap()));
// Without the filter, a row hidden by hand is cleared like the rows around it.
s.execute("data.clearFilter", json!({})).unwrap();
s.execute("home.hideRows", json!({"rows": "5:5"})).unwrap();
s.execute("edit.clearContents", json!({"range": "A3:A5"})).unwrap();
assert_eq!(v(&s, "A3"), Value::Empty);
assert_eq!(v(&s, "A5"), Value::Empty);
}
/// Top 10 keeps the top or bottom N items or percent, ties at the threshold included.
#[test]
fn filter_top10_items_and_percent() {
@@ -1965,6 +2007,15 @@ fn a_whole_column_table_without_headers_stays_on_the_sheet() {
assert!(e.to_string().contains("pushed off the end"), "{e}");
}
#[test]
fn inserting_a_column_next_to_far_right_data_names_gridcraft_in_the_refusal() {
let mut sess = s();
sess.execute("cell.set", json!({"cell": "XFD1", "input": "2"})).unwrap();
let e = sess.execute("home.insertColumns", json!({"cols": "A:A"})).unwrap_err();
assert!(e.to_string().contains("GridCraft cannot shift nonblank cells"), "{e}");
assert!(!e.to_string().contains("Excel"), "{e}");
}
#[test]
fn inserting_cells_refuses_to_push_values_off_the_sheet() {
for (shift, edge, last_fit, last_ok) in [("right", "XFD1", "XFC1", "XFD1"), ("down", "A1048576", "A1048575", "A1048576")] {
@@ -1973,6 +2024,7 @@ fn inserting_cells_refuses_to_push_values_off_the_sheet() {
sess.execute("cell.set", json!({"cell": edge, "input": "2"})).unwrap();
let e = sess.execute("home.insertCells", json!({"range": "A1", "shift": shift})).unwrap_err();
assert!(e.to_string().contains("loss of data"), "{e}");
assert!(e.to_string().contains("GridCraft") && !e.to_string().contains("Excel"), "{e}");
assert_eq!(v(&sess, "A1"), Value::Number(1.0));
assert_eq!(v(&sess, edge), Value::Number(2.0));
// A value that still fits moves to the last valid cell.
@@ -2458,6 +2510,53 @@ fn saving_an_opened_semicolon_csv_keeps_semicolons() {
std::fs::remove_dir_all(dir).unwrap();
}
/// `file.exportCsv` writes displayed text by default (Excel's Save As CSV); `values: true` writes the
/// raw values for automation: full precision, ISO dates and times.
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn export_csv_values_writes_raw_values_and_the_default_stays_displayed_text() {
let mut s = s();
for (cell, input, code) in [
("A1", "1234.5", "$#,##0.00"),
("B1", "0.123456789", "0.00"),
("C1", "46023", "d-mmm-yy"),
("D1", "46023.5", "yyyy-mm-dd hh:mm"),
("E1", "0.75", "h:mm"),
("F1", "TRUE", "General"),
("G1", "a,b", "General"),
("H1", "=1/0", "General"),
("I1", "1", "h:mm"),
("J1", "1.5", "h:mm"),
("K1", "46023", "yyyy-mm-dd hh:mm"),
("L1", "1.5", "[h]:mm"),
("M1", "2.5", "[>=3]yyyy-mm-dd;[<3]hh:mm"),
] {
s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap();
s.execute("home.numberFormat", json!({"range": cell, "code": code})).unwrap();
}
#[allow(clippy::disallowed_methods)] // native-only test: a unique temp dir name
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let dir = std::env::temp_dir().join(format!("gridcraft-csv-values-{}-{nonce}", std::process::id()));
std::fs::create_dir(&dir).unwrap();
let displayed = dir.join("displayed.csv");
s.execute("file.exportCsv", json!({"path": displayed})).unwrap();
s.execute("file.exportCsv", json!({"path": dir.join("off.csv"), "values": false})).unwrap();
let raw = dir.join("raw.csv");
s.execute("file.exportCsv", json!({"path": raw, "values": true})).unwrap();
let saved = s.execute("file.saveBytes", json!({"format": "csv"})).unwrap();
let default = crate::io::base64_decode(saved["base64"].as_str().unwrap()).unwrap();
// The default export is byte for byte what Save As CSV writes, and `values: false` is the default.
assert_eq!(std::fs::read(&displayed).unwrap(), default);
assert_eq!(std::fs::read(dir.join("off.csv")).unwrap(), default);
let default = String::from_utf8(default).unwrap();
assert!(default.starts_with("\"$1,234.50\",0.12,1-Jan-26,"), "{default}");
assert_eq!(
std::fs::read_to_string(&raw).unwrap(),
"1234.5,0.123456789,2026-01-01,2026-01-01T12:00:00,18:00:00,TRUE,\"a,b\",#DIV/0!,00:00:00,12:00:00,2026-01-01T00:00:00,1.5,12:00:00\r\n"
);
std::fs::remove_dir_all(dir).unwrap();
}
/// A workbook whose B1 is `=[1]Sheet1!A1`, linked to `link` (an external relationship target).
fn linked_workbook(link: &str) -> Vec<u8> {
use std::io::{Cursor, Write};
@@ -2836,6 +2935,24 @@ fn checkboxes_keep_values_toggle_and_delete_in_two_passes() {
assert_ne!(fmt_of(&s, "B2"), "checkbox");
}
/// A checked box in a row the filter hides doesn't hold back Delete on the boxes showing, and
/// stays as it is (#1419).
#[test]
fn delete_on_filtered_checkboxes_skips_the_hidden_rows() {
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [["Status"], ["on"], ["off"], ["on"]]})).unwrap();
s.execute("insert.checkbox", json!({"range": "B2:B4"})).unwrap();
s.execute("cell.toggleCheckbox", json!({"cell": "B3"})).unwrap();
s.execute("selection.set", json!({"cell": "A1"})).unwrap();
s.execute("data.filter", json!({})).unwrap();
s.execute("data.filterBy", json!({"column": "A", "values": ["on"]})).unwrap();
s.execute("edit.clearContents", json!({"range": "B2:B4"})).unwrap();
assert_ne!(fmt_of(&s, "B2"), "checkbox", "the unchecked boxes showing are removed at once");
assert_ne!(fmt_of(&s, "B4"), "checkbox");
assert_eq!(fmt_of(&s, "B3"), "checkbox");
assert_eq!(v(&s, "B3"), Value::Bool(true));
}
#[test]
fn checkboxes_survive_xlsx_as_cell_controls() {
let mut s = s();
@@ -5085,6 +5202,7 @@ fn a_script_cannot_switch_windows() {
assert_eq!(v(&s, "A1"), Value::number(2.0));
}
mod macros;
mod seams;
#[test]
File diff suppressed because it is too large Load Diff
+85
View File
@@ -820,6 +820,91 @@ fn a_call_that_only_opens_a_dialog_is_not_recorded() {
assert_eq!(s.stop_recording().unwrap().dropped, 0);
}
#[test]
fn stop_action_runs_only_on_its_own_and_is_not_recorded() {
// A step of an action in progress may not stop the action: nested, the command is refused
// and the action's group stays. Nor is Stop Action a step of the person's work: a recording
// leaves it out, so playing the recording back never stops the run it is part of.
let mut s = s();
s.start_recording();
let r: crate::Result<()> = s.grouped("Outer", |s| {
set(s, "A1", "one");
let err = s.execute_nested("automate.stopAction", json!({})).unwrap_err().to_string();
assert!(err.contains("on its own"), "{err}");
set(s, "A2", "two");
Ok(())
});
r.unwrap();
assert_eq!(v(&s, "A2").display(), "two");
assert_eq!(undo_labels(&s), ["Outer"], "the action kept its group");
// On its own it stops a group left open; the recording keeps neither the stop nor the edits
// the stop rolled back, so playing it back makes only the edits the workbook has.
let _left = s.group_begin("Left open").unwrap();
set(&mut s, "A3", "three");
assert_eq!(s.execute("automate.stopAction", json!({})).unwrap(), json!({"stopped": true}));
assert!(v(&s, "A3").is_empty());
assert_eq!(recorded_ids(&s), ["cell.set", "cell.set"]);
// An edit recorded in another workbook while the group was open stays, as its edit does.
let first = s.active_index();
let _left = s.group_begin("Left open").unwrap();
set(&mut s, "A4", "four");
s.new_workbook();
set(&mut s, "B1", "other");
s.set_active(first);
assert_eq!(s.execute("automate.stopAction", json!({})).unwrap(), json!({"stopped": true}));
assert_eq!(recorded_ids(&s), ["cell.set", "cell.set", "cell.set"], "the other workbook's edit stays");
assert_eq!(s.recorder.as_ref().unwrap().steps.last().map(|st| st.params["cell"].clone()), Some(json!("B1")));
assert_eq!(s.stop_recording().unwrap().dropped, 0);
}
#[test]
fn rollbacks_take_back_their_own_recorded_steps_in_any_order() {
// Groups open on two workbooks, each with a recorded edit: rolling back the first takes out
// only its edit, and the second's mark still finds its own after that.
let mut s = s();
s.start_recording();
let a = s.active_index();
let _in_a = s.group_begin("A").unwrap();
set(&mut s, "A1", "a");
s.new_workbook();
let b = s.active_index();
let _in_b = s.group_begin("B").unwrap();
set(&mut s, "B1", "b");
s.set_active(a);
s.execute("automate.stopAction", json!({})).unwrap();
let cells = |s: &Session| s.recorder.as_ref().unwrap().steps.iter().map(|st| st.params["cell"].clone()).collect::<Vec<_>>();
assert_eq!(cells(&s), [json!("B1")]);
s.set_active(b);
s.execute("automate.stopAction", json!({})).unwrap();
assert!(cells(&s).is_empty());
// A recording started again inside a group: every step of it came after the group began.
let _again = s.group_begin("Again").unwrap();
s.start_recording();
set(&mut s, "C1", "c");
s.execute("automate.stopAction", json!({})).unwrap();
assert!(cells(&s).is_empty(), "{:?}", cells(&s));
}
#[test]
fn a_run_that_edits_and_opens_a_dialog_is_recorded() {
// A script with an edit before a menu-style step: the edit happened, so the run is a step of
// the recording (the person's confirming call is another). Only a call that opened a dialog
// and changed nothing is left out.
let mut s = s();
let edit_then_dialog = json!([{"command": "cell.set", "params": {"cell": "B1", "input": "1"}}, {"command": "home.formatCells", "params": {}}]);
let dialog_only = json!([{"command": "home.formatCells", "params": {}}]);
s.execute("automate.newScript", json!({"name": "Edit", "commands": edit_then_dialog})).unwrap();
s.execute("automate.newScript", json!({"name": "Ask", "commands": dialog_only})).unwrap();
s.start_recording();
s.execute("automate.runScript", json!({"name": "Ask"})).unwrap();
assert!(s.take_ui_requests().iter().any(|q| matches!(q, crate::UiRequest::Dialog(..))));
assert!(recorded_ids(&s).is_empty(), "{:?}", recorded_ids(&s));
s.execute("automate.runScript", json!({"name": "Edit"})).unwrap();
assert!(s.take_ui_requests().iter().any(|q| matches!(q, crate::UiRequest::Dialog(..))));
assert_eq!(recorded_ids(&s), ["automate.runScript"], "the edit happened, so the run is a step");
assert_eq!(s.stop_recording().unwrap().dropped, 0);
}
#[test]
fn the_recorder_keeps_top_level_calls_with_where_they_ran() {
let mut s = s();
+2
View File
@@ -23,9 +23,11 @@ mod pivot_unknown_func;
mod pivotchart_link;
mod range_fill_formats;
mod row_autofit;
mod sheet_copy_spill;
mod sheet_name_quoting;
mod sparse_clear_invalidation;
mod stored_list_shape;
mod table_resize_overlap;
mod table_total_row_last_row;
mod workbook_author;
mod wrap_row_height;
@@ -0,0 +1,33 @@
use gridcraft_core::{CellRef, Value};
use gridcraft_engine::Session;
use gridcraft_engine::calc::Calc;
use serde_json::{Value as Json, json};
fn run(s: &mut Session, command: &str, params: Json) {
s.execute(command, params).unwrap();
}
fn value(s: &Session, sheet: &str, cell: &str) -> Value {
let wb = &s.doc().unwrap().wb;
wb.sheet(wb.sheet_index(sheet).unwrap()).unwrap().value(CellRef::parse(cell).unwrap())
}
/// Issue #2053: the copy of a sheet holding a spill keeps the spill's values, in memory and after a full recalc.
#[test]
fn copied_sheet_keeps_spill_values_through_full_recalc() {
let mut s = Session::new();
run(&mut s, "file.new", json!({"sample": "grades"}));
run(&mut s, "home.mergeCells", json!({"range": "F:H"}));
run(&mut s, "cell.set", json!({"cell": "J54", "input": "=SEQUENCE(5)*C6"}));
run(&mut s, "selection.set", json!({"range": "15:15"}));
run(&mut s, "sheet.move", json!({"copy": true, "sheet": 0, "to": 1}));
run(&mut s, "cell.set", json!({"array": true, "cell": "D3", "input": "=SUM('Grades (2)'!J54#)"}));
let before = value(&s, "Grades (2)", "J55");
assert!(matches!(before, Value::Number(_)), "J55 should hold a spilled number, got {before:?}");
let mut fresh = (*s.doc().unwrap().wb).clone();
Calc::new().recalc_all(&mut fresh);
let after = fresh.sheet(fresh.sheet_index("Grades (2)").unwrap()).unwrap().value(CellRef::parse("J55").unwrap());
assert_eq!(before, after);
}
+50
View File
@@ -0,0 +1,50 @@
//! Regression tests for the default author of newly created workbooks.
use gridcraft_engine::{DocState, Session, Trust, io, model::Workbook};
#[test]
fn new_workbook_stamps_the_configured_user_name_at_creation() {
let mut session = Session::new();
session.prefs.user_name = "Ada Lovelace".into();
let first = session.new_workbook();
assert_eq!(session.documents()[first].wb.props.author, "Ada Lovelace");
// Changing the preference must not rewrite an existing workbook.
session.prefs.user_name = "Grace Hopper".into();
assert_eq!(session.documents()[first].wb.props.author, "Ada Lovelace");
// A new workbook uses the preference as it exists at creation.
let second = session.new_workbook();
assert_eq!(session.documents()[second].wb.props.author, "Grace Hopper");
assert_eq!(session.documents()[first].wb.props.author, "Ada Lovelace");
}
#[test]
fn opening_and_resaving_preserves_the_existing_workbook_author() {
// An existing XLSX has its own author metadata.
let mut source = Workbook::new();
source.props.author = "Original Spreadsheet Author".into();
let bytes = io::save_bytes(&source, "source.xlsx").unwrap();
let (opened, _) = io::open_bytes("source.xlsx", &bytes).unwrap();
let mut session = Session::new();
session.prefs.user_name = "Current GridCraft User".into();
session.add_document(DocState::new(opened, None, "source".into(), Trust::FromFile));
// Opening an existing workbook must not replace its author.
assert_eq!(session.doc().unwrap().wb.props.author, "Original Spreadsheet Author");
// The original author must survive another XLSX save and reopen.
let saved = io::save_bytes(&session.doc().unwrap().wb, "saved.xlsx").unwrap();
let (reopened, _) = io::open_bytes("saved.xlsx", &saved).unwrap();
assert_eq!(reopened.props.author, "Original Spreadsheet Author");
}
+2 -2
View File
@@ -175,8 +175,8 @@ fn write_struct_ref(s: &StructRef, at_shorthand: bool) -> String {
let _ = write!(out, "[{}]", s.specifiers.first().map(item).unwrap_or(""));
}
(Some(c), None) if s.specifiers.is_empty() => {
// Excel writes a name with spaces at its edges in the double-bracket form.
if c.trim() == c {
// Names with spaces at their edges, or starting with `@` (this-row shorthand), use the double-bracket form.
if c.trim() == c && !c.starts_with('@') {
let _ = write!(out, "[{}]", esc(c));
} else {
let _ = write!(out, "[[{}]]", esc(c));
+9
View File
@@ -558,3 +558,12 @@ fn reference_shapes_print_unchanged() -> Result<(), Box<dyn std::error::Error>>
}
Ok(())
}
#[test]
fn column_starting_with_at_round_trips() {
for name in ["@a", "a@b"] {
let s = StructRef { table: "T".into(), specifiers: vec![], col_start: Some(name.into()), col_end: None };
let text = format!("={}", printer::struct_ref(&s));
assert_eq!(parse(&text).ok(), Some(Expr::Struct(s)), "printed as {text}");
}
}
+105 -14
View File
@@ -25,7 +25,7 @@ fn base_source(a: &[Arg]) -> R<String> {
Ok(number_to_text(n))
}
Value::Empty => Ok(String::new()),
v => Ok(v.to_text()?.trim().to_string()),
v => Ok(v.to_text()?.to_string()),
}
}
@@ -62,6 +62,15 @@ fn format_base(v: i64, radix: u32, places: Option<f64>) -> R<Value> {
if v < min || v > max {
return Err(CellError::Num);
}
let places = places
.map(|p| {
let p = p.trunc();
if !(1.0..=10.0).contains(&p) {
return Err(CellError::Num);
}
Ok(p as usize)
})
.transpose()?;
if v < 0 {
let u = v + (1i64 << bits);
return text_val(to_radix(u, radix));
@@ -70,11 +79,6 @@ fn format_base(v: i64, radix: u32, places: Option<f64>) -> R<Value> {
match places {
None => text_val(s),
Some(p) => {
let p = p.trunc();
if !(1.0..=10.0).contains(&p) {
return Err(CellError::Num);
}
let p = p as usize;
if s.len() > p {
return Err(CellError::Num);
}
@@ -97,7 +101,13 @@ fn to_radix(mut v: i64, radix: u32) -> String {
}
fn places_arg(a: &[Arg]) -> R<Option<f64>> {
if has(a, 1) { Ok(Some(num(a, 1)?)) } else { Ok(None) }
if !has(a, 1) {
return Ok(None);
}
if matches!(a.get(1).map(scalar), Some(Value::Bool(_))) {
return Err(CellError::Value);
}
Ok(Some(num(a, 1)?))
}
fn convert_base(a: &[Arg], from: u32, to: u32) -> R<Value> {
@@ -175,6 +185,9 @@ fn bitxor(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
fn shift(a: &[Arg], left: bool) -> R<Value> {
let n = bit_operand(a, 0)?;
let s = num(a, 1)?.trunc();
if n == 0 {
return Ok(Value::Number(0.0));
}
if s.abs() > 53.0 {
return Err(CellError::Num);
}
@@ -248,11 +261,26 @@ im1!(imexp, |z: Cx| Ok(z.exp()));
im1!(imln, |z: Cx| z.ln());
im1!(imlog10, |z: Cx| z.ln().map(|l| Cx::new(l.re / std::f64::consts::LN_10, l.im / std::f64::consts::LN_10)));
im1!(imlog2, |z: Cx| z.ln().map(|l| Cx::new(l.re / std::f64::consts::LN_2, l.im / std::f64::consts::LN_2)));
// Excel takes the polar route, so IMSQRT("-4") is 1.22514845490862E-16+2i, not 2i. Its cosine
// reduces its argument with a 64-bit-mantissa pi, which is PI/2 + 2.5e-20 and shows up as the
// stray real part. `+ 0.0` turns a -0 imaginary part into +0, so the angle stays +pi.
// shortcut: parts above ~1E154 or below ~1E-155 keep the exact result (Excel gives #NUM! or 0), no known use.
const HALF_PI_EXCESS: f64 = 2.508_278_806_33e-20;
im1!(imsqrt, |z: Cx| {
let m = z.abs();
let re = ((m + z.re) / 2.0).sqrt();
let im = ((m - z.re) / 2.0).sqrt();
Ok(Cx::new(re, if z.im < 0.0 { -im } else { im }))
let im = z.im + 0.0;
let theta = match z.re {
0.0 => {
if im == 0.0 {
0.0
} else {
std::f64::consts::FRAC_PI_2.copysign(im)
}
}
x if x > 0.0 => (im / x).atan(),
x => (im / x).atan() + if im < 0.0 { -std::f64::consts::PI } else { std::f64::consts::PI },
};
let (half, root) = (theta / 2.0, z.abs().sqrt());
Ok(Cx::new(root * (half.cos() + HALF_PI_EXCESS * half.abs().sin()), root * half.sin()))
});
im1!(imsin, |z: Cx| Ok(z.sin()));
im1!(imcos, |z: Cx| Ok(z.cos()));
@@ -387,6 +415,12 @@ mod tests {
is_text(ev("BIN2OCT", vec![n(1111111111.0)]), "7777777777");
is_text(ev("DEC2BIN", vec![n(9.0), n(4.0)]), "1001");
is_text(ev("DEC2BIN", vec![n(-100.0)]), "1110011100");
is_err(ev("DEC2BIN", vec![n(-1.0), n(0.0)]), CellError::Num);
is_err(ev("DEC2HEX", vec![n(-1.0), n(11.0)]), CellError::Num);
is_err(ev("DEC2OCT", vec![n(-1.0), n(-1.0)]), CellError::Num);
is_err(ev("DEC2BIN", vec![n(1.0), crate::Arg::val(Value::Bool(true))]), CellError::Value);
is_text(ev("DEC2BIN", vec![n(-1.0), n(1.0)]), "1111111111");
is_text(ev("DEC2HEX", vec![n(-1.0), n(10.0)]), "FFFFFFFFFF");
is_err(ev("DEC2BIN", vec![n(512.0)]), CellError::Num);
is_text(ev("DEC2HEX", vec![n(100.0), n(4.0)]), "0064");
is_text(ev("DEC2HEX", vec![n(-54.0)]), "FFFFFFFFCA");
@@ -411,6 +445,21 @@ mod tests {
is_text(ev("OCT2HEX", vec![n(100.0), n(4.0)]), "0040");
is_text(ev("OCT2HEX", vec![n(7777777533.0)]), "FFFFFFFF5B");
is_err(ev("BIN2DEC", vec![n(102.0)]), CellError::Num);
for (f, input) in [
("BIN2DEC", " 10"),
("BIN2DEC", "10 "),
("BIN2DEC", "\t10"),
("BIN2HEX", " 10"),
("BIN2OCT", "10 "),
("HEX2DEC", " 10"),
("HEX2BIN", "10 "),
("HEX2OCT", "\t10"),
("OCT2DEC", " 10"),
("OCT2BIN", "10 "),
("OCT2HEX", "\t10"),
] {
is_err(ev(f, vec![t(input)]), CellError::Num);
}
is_err(ev("HEX2DEC", vec![t("GG")]), CellError::Num);
is_err(ev("BIN2DEC", vec![t("11111111111")]), CellError::Num);
is_err(ev("DEC2BIN", vec![t("abc")]), CellError::Value);
@@ -431,6 +480,21 @@ mod tests {
is_err(ev("BITAND", vec![n(1.5), n(5.0)]), CellError::Num);
is_err(ev("BITLSHIFT", vec![n(1.0), n(54.0)]), CellError::Num);
is_err(ev("BITLSHIFT", vec![n(2f64.powi(47)), n(1.0)]), CellError::Num);
for f in ["BITLSHIFT", "BITRSHIFT"] {
for shift in [-100.0, -54.0, -53.0, 53.0, 54.0, 100.0] {
close(ev(f, vec![n(0.0), n(shift)]), 0.0);
}
for shift in [-100.0, -54.0, 54.0, 100.0] {
is_err(ev(f, vec![n(1.0), n(shift)]), CellError::Num);
}
if f == "BITLSHIFT" {
is_err(ev(f, vec![n(1.0), n(53.0)]), CellError::Num);
} else {
close(ev(f, vec![n(1.0), n(53.0)]), 0.0);
}
is_err(ev(f, vec![n(0.0), t("bad")]), CellError::Value);
}
is_err(ev("BITRSHIFT", vec![n(-1.0), n(100.0)]), CellError::Num);
}
#[test]
@@ -509,9 +573,36 @@ mod tests {
cx(ev("IMLOG10", vec![t("3+4i")]), 0.698_970_004_336_018_8, 0.402_719_196_273_373_2);
cx(ev("IMLOG2", vec![t("3+4i")]), 2.321_928_094_887_362, 1.337_804_212_450_976_8);
cx(ev("IMSQRT", vec![t("1+i")]), 1.098_684_113_467_809_9, 0.455_089_860_562_227_3);
cx(ev("IMSQRT", vec![t("-4")]), 0.0, 2.0);
is_text(ev("IMSQRT", vec![t("-4")]), "2i");
is_text(ev("IMSQRT", vec![t("-9")]), "3i");
// Strings read back from Excel (16.0): its stray real part on negative reals included.
for (input, want) in [
("-4", "1.22514845490862E-16+2i"),
("-9", "1.83772268236293E-16+3i"),
("-1", "6.1257422745431E-17+i"),
("-2", "8.66310780426106E-17+1.4142135623731i"),
("-100", "6.1257422745431E-16+10i"),
("-1E10", "6.1257422745431E-12+100000i"),
("-1E-10", "6.1257422745431E-22+0.00001i"),
("-4+0j", "1.22514845490862E-16+2j"),
("-4-0i", "1.22514845490862E-16+2i"),
("-4-1E-20i", "1.22514845490862E-16-2i"),
("-1+1E-10i", "5.00000653944413E-11+i"),
("-0.5-1E-12i", "7.07212958930194E-13-0.707106781186548i"),
("4", "2"),
("2", "1.4142135623731"),
("0", "0"),
("3+4i", "2+i"),
("3+4j", "2+j"),
("-3+4i", "1+2i"),
("-3-4i", "1-2i"),
("-2i", "1-i"),
("2i", "1+i"),
("i", "0.707106781186548+0.707106781186547i"),
("5-12i", "3-2i"),
("-5+12i", "2+3i"),
("1E10+1E10i", "109868.411346781+45508.9860562227i"),
] {
is_text(ev("IMSQRT", vec![t(input)]), want);
}
is_text(ev("IMSQRT", vec![t("4")]), "2");
is_text(ev("IMSQRT", vec![t("-2i")]), "1-i");
is_text(ev("IMPOWER", vec![t("2+3i"), n(3.0)]), "-46+9i");
+8 -5
View File
@@ -311,11 +311,8 @@ fn hyperlink(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
let v = a.get(1).map(scalar).unwrap_or(Value::Empty);
return Ok(empty_to_zero(v));
}
let link = a.first().map(scalar).unwrap_or(Value::Empty);
if let Value::Error(e) = link {
return Err(e);
}
Ok(link)
// Excel returns the link as text, so HYPERLINK(1) is "1" and ISTEXT is TRUE.
Ok(Value::Text(text(a, 0)?.into()))
}
fn encodeurl(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
@@ -608,6 +605,12 @@ mod tests {
is_text(ev("HYPERLINK", vec![t("http://x.com")]), "http://x.com");
is_text(ev("HYPERLINK", vec![t("http://x.com"), t("Click")]), "Click");
close(ev("HYPERLINK", vec![t("http://x.com"), n(5.0)]), 5.0);
// Excel 16: without a friendly name the result is the link as text.
is_text(ev("HYPERLINK", vec![n(1.0)]), "1");
is_text(ev("HYPERLINK", vec![n(1.5)]), "1.5");
is_text(ev("HYPERLINK", vec![b(true)]), "TRUE");
is_text(ev("HYPERLINK", vec![rf(Value::Empty)]), "");
is_err(ev("HYPERLINK", vec![e(CellError::Div0)]), CellError::Div0);
is_text(ev("ENCODEURL", vec![t("a b&c/é")]), "a%20b%26c%2F%C3%A9");
is_err(ev("WEBSERVICE", vec![t("http://x")]), CellError::Value);
is_err(ev("FILTERXML", vec![t("<a/>"), t("//a")]), CellError::Value);
+61 -1
View File
@@ -58,6 +58,35 @@ fn reject_multi_cell_refs(spec: &FnSpec, args: &[Arg]) -> Option<Vec<Arg>> {
)
}
/// Lookups whose every call searches a lookup vector from its start, so a call lifted over `n`
/// needles does `n` x (vector length) work. Their search arguments don't lift.
const SCANS: &[&str] = &["VLOOKUP", "HLOOKUP", "LOOKUP", "MATCH", "XLOOKUP", "XMATCH"];
/// Work budget of a lifted [`SCANS`] lookup: needle cells times the length of the longest vector
/// it searches (the longest dimension of its largest non-lifted array). Each needle re-reads and
/// scans the vector (about 8 ns per entry), so without a cap a one-cell formula such as
/// `XMATCH(SEQUENCE(1048576), -SEQUENCE(1048576))` runs for hours and hangs opening the file.
/// Past the budget the call is `#NUM!`, as MMULT is past 2e9 multiply-adds. 5e8 keeps the worst
/// case to a few seconds and still admits 5,000 needles over a 100,000-row table.
const LIFTED_SCAN_BUDGET: usize = 500_000_000;
/// The length of the longest vector a lifted call of `spec` searches per needle: 0 unless `spec`
/// is one of the [`SCANS`] lookups and a non-lifted argument is an array.
fn scan_len(spec: &FnSpec, args: &[Arg]) -> usize {
if !SCANS.contains(&spec.name) {
return 0;
}
args.iter()
.enumerate()
.filter(|(i, _)| !spec.is_scalar_arg(*i))
.filter_map(|(_, a)| match &a.value {
Value::Array(arr) => Some(arr.rows.max(arr.cols)),
_ => None,
})
.max()
.unwrap_or(0)
}
pub(crate) fn call_lifted(spec: &FnSpec, args: &[Arg], ctx: &mut dyn Ctx) -> Value {
let rejected = if spec.value_args.is_empty() { None } else { reject_multi_cell_refs(spec, args) };
let args = rejected.as_deref().unwrap_or(args);
@@ -76,7 +105,8 @@ pub(crate) fn call_lifted(spec: &FnSpec, args: &[Arg], ctx: &mut dyn Ctx) -> Val
if !any {
return (spec.imp)(args, ctx);
}
if rows.saturating_mul(cols) > MAX_CELLS {
let needles = rows.saturating_mul(cols);
if needles > MAX_CELLS || needles.saturating_mul(scan_len(spec, args)) > LIFTED_SCAN_BUDGET {
return Value::Error(CellError::Num);
}
let mut cell_args: Vec<Arg> = args.to_vec();
@@ -149,6 +179,36 @@ mod tests {
is_err(huge, CellError::Num);
}
/// Security: a lifted lookup searches its whole lookup vector once per needle, so needles x
/// vector length past `LIFTED_SCAN_BUDGET` is `#NUM!` at once.
/// `XMATCH(SEQUENCE(1048576), -SEQUENCE(1048576))` used to run for hours (and hung opening
/// the 1.6 KB file holding it).
#[test]
fn lifted_lookups_stop_past_their_work_budget() {
use gridcraft_core::Array;
let tall = |n: usize| av(Array::filled(n, 1, nv(-1.0)).into());
let wide = |n: usize| av(Array::filled(1, n, nv(-1.0)).into());
is_err(ev("XMATCH", vec![tall(1_048_576), tall(1_048_576)]), CellError::Num);
// Just past the budget, for every lookup: 501 needles over 1,000,000 entries.
assert_eq!(super::LIFTED_SCAN_BUDGET, 500 * 1_000_000);
is_err(ev("XMATCH", vec![tall(501), tall(1_000_000)]), CellError::Num);
is_err(ev("MATCH", vec![tall(501), tall(1_000_000), n(0.0)]), CellError::Num);
is_err(ev("XLOOKUP", vec![tall(501), tall(1_000_000), tall(1_000_000)]), CellError::Num);
is_err(ev("VLOOKUP", vec![tall(501), tall(1_000_000), n(1.0), b(false)]), CellError::Num);
is_err(ev("HLOOKUP", vec![tall(501), wide(1_000_000), n(1.0), b(false)]), CellError::Num);
is_err(ev("LOOKUP", vec![tall(501), tall(1_000_000)]), CellError::Num);
// Within the budget lifted lookups work as before.
let keys = || av(col(&[10.0, 20.0, 30.0]));
let pos = ev("XMATCH", vec![av(col(&[30.0, 10.0, 99.0])), keys()]);
assert_eq!(rows_of(&pos), vec![vec![nv(3.0)], vec![nv(1.0)], vec![Value::Error(CellError::NA)]]);
let found = ev("XLOOKUP", vec![av(row(&[20.0, 30.0])), keys(), av(col(&[1.0, 2.0, 3.0]))]);
assert_eq!(rows_of(&found), vec![vec![nv(2.0), nv(3.0)]]);
// Other lifted functions aren't metered: INDEX reads one entry per position.
let picks: Vec<f64> = (1..=1000u32).map(f64::from).collect();
let out = ev("INDEX", vec![tall(1_048_576), av(col(&picks))]);
assert_eq!(rows_of(&out), vec![vec![nv(-1.0)]; 1000]);
}
#[test]
fn value_params_reject_multi_cell_references() {
let s = FnSpec { value_args: &[true, false], ..spec() };
+10
View File
@@ -694,6 +694,16 @@ pub(crate) fn specs() -> Vec<FnSpec> {
f!("ROUNDDOWN", 2, 2, MathTrig, S, "ROUNDDOWN(number, num_digits)", "Rounds a number toward zero to a given number of digits.", rounddown),
f!("MROUND", 2, 2, MathTrig, S, "MROUND(number, multiple)", "Rounds a number to the nearest multiple.", mround),
f!("CEILING", 2, 2, Compatibility, S, "CEILING(number, significance)", "Rounds a number up to a multiple of significance.", ceiling),
f!(
"ECMA.CEILING",
2,
2,
Compatibility,
S,
"ECMA.CEILING(number, significance)",
"Rounds a number up to a multiple of significance.",
ceiling
),
f!(
"CEILING.MATH",
1,
+5
View File
@@ -101,6 +101,11 @@ fn rounding() {
close(ev2("CEILING", 1.5, 0.1), 1.5);
close(ev2("CEILING", 0.234, 0.01), 0.24);
is_err(ev2("CEILING", 2.5, -1.0), Num);
// ECMA.CEILING is CEILING under a second name (Excel table from #2776).
close(ev2("ECMA.CEILING", 1.5, 1.0), 2.0);
close(ev2("ECMA.CEILING", -2.5, -2.0), -4.0);
close(ev2("ECMA.CEILING", 2.5, 0.0), 0.0);
is_err(ev2("ECMA.CEILING", 1.5, -1.0), Num);
close(ev2("FLOOR", 3.7, 2.0), 2.0);
close(ev2("FLOOR", -2.5, -2.0), -2.0);
close(ev2("FLOOR", -2.5, 2.0), -4.0);
+4 -1
View File
@@ -420,7 +420,10 @@ pub(super) fn geomean(v: &[f64]) -> R<f64> {
}
pub(super) fn harmean(v: &[f64]) -> R<f64> {
if v.is_empty() || v.iter().any(|&x| x <= 0.0) {
if v.is_empty() {
return Err(CellError::NA);
}
if v.iter().any(|&x| x <= 0.0) {
return Err(CellError::Num);
}
Ok(v.len() as f64 / v.iter().map(|x| 1.0 / x).sum::<f64>())
+40 -14
View File
@@ -22,7 +22,14 @@ fn t_cdf(t: f64, v: f64) -> f64 {
}
fn t_pdf(t: f64, v: f64) -> f64 {
(ln_gamma((v + 1.0) / 2.0) - ln_gamma(v / 2.0) - 0.5 * (v * std::f64::consts::PI).ln() - (v + 1.0) / 2.0 * (1.0 + t * t / v).ln()).exp()
let z = v / 2.0;
let log_gamma_ratio = if z >= 16.0 {
let inv_z = 1.0 / z;
0.5 * z.ln() - inv_z / 8.0 + inv_z.powi(3) / 192.0 - inv_z.powi(5) / 640.0 + 17.0 * inv_z.powi(7) / 14_336.0
} else {
ln_gamma(z + 0.5) - ln_gamma(z)
};
(log_gamma_ratio - 0.5 * (v.ln() + std::f64::consts::PI.ln()) - (v + 1.0) / 2.0 * (t * t / v).ln_1p()).exp()
}
/// t ≥ 0 with P(T > t) = q, q in (0, 0.5].
@@ -51,6 +58,12 @@ fn f_cdf(x: f64, d1: f64, d2: f64) -> f64 {
if x <= 0.0 {
return 0.0;
}
if x == 1.0 && d1 == d2 {
return 0.5;
}
if d1 >= 1e8 && d2 / d1 <= 1e-8 {
return reg_gamma_q(d2 / 2.0, d2 / (2.0 * (x + d2 / d1)));
}
reg_inc_beta(d1 * x / (d1 * x + d2), d1 / 2.0, d2 / 2.0)
}
@@ -58,6 +71,10 @@ fn f_upper(x: f64, d1: f64, d2: f64) -> f64 {
if x <= 0.0 {
return 1.0;
}
if d1 >= 1e8 && d2 / d1 <= 1e-8 {
// With a much larger numerator degree, the scaled beta tail converges to a gamma tail.
return reg_gamma_p(d2 / 2.0, d2 / (2.0 * (x + d2 / d1)));
}
reg_inc_beta(d2 / (d2 + d1 * x), d2 / 2.0, d1 / 2.0)
}
@@ -74,6 +91,8 @@ fn f_pdf(x: f64, d1: f64, d2: f64) -> f64 {
((d1 / 2.0) * (d1 / d2).ln() + (d1 / 2.0 - 1.0) * x.ln() - ((d1 + d2) / 2.0) * (1.0 + d1 * x / d2).ln() - ln_beta(d1 / 2.0, d2 / 2.0)).exp()
}
const MAX_BINOM_TRIALS: f64 = 2_147_483_646.0;
fn binom_pmf(k: f64, n: f64, p: f64) -> f64 {
if k < 0.0 || k > n {
return 0.0;
@@ -176,8 +195,14 @@ fn df_arg(a: &[Arg], i: usize) -> R<f64> {
pub(super) fn t_dist(a: &[Arg], _: C) -> R<Value> {
let x = num(a, 0)?;
let v = df_arg(a, 1)?;
let v = num(a, 1)?.trunc();
let cum = boolean(a, 2)?;
if v == 0.0 {
return Err(if cum { CellError::Num } else { CellError::Div0 });
}
if v < 0.0 {
return Err(CellError::Num);
}
num_val(if cum { t_cdf(x, v) } else { t_pdf(x, v) })
}
@@ -209,6 +234,9 @@ pub(super) fn tdist_legacy(a: &[Arg], _: C) -> R<Value> {
pub(super) fn t_inv(a: &[Arg], _: C) -> R<Value> {
let p = num(a, 0)?;
let v = df_arg(a, 1)?;
if p == 0.0 {
return Err(CellError::Div0);
}
if !(p > 0.0 && p < 1.0) {
return Err(CellError::Num);
}
@@ -347,7 +375,7 @@ fn f_args(a: &[Arg]) -> R<(f64, f64, f64)> {
let x = num(a, 0)?;
let d1 = num(a, 1)?.trunc();
let d2 = num(a, 2)?.trunc();
if d1 < 1.0 || d2 < 1.0 || d1 >= 1e10 || d2 >= 1e10 {
if d1 < 1.0 || d2 < 1.0 || d1 > 1e10 || d2 > 1e10 {
return Err(CellError::Num);
}
Ok((x, d1, d2))
@@ -378,7 +406,8 @@ pub(super) fn f_inv(a: &[Arg], _: C) -> R<Value> {
if p == 0.0 {
return num_val(0.0);
}
num_val(invert(|x| f_cdf(x, d1, d2), p, 0.0, 1.0, 1e60))
let value = if p < 0.5 { invert(|x| f_cdf(x, d1, d2), p, 0.0, 1.0, 1e60) } else { invert(|x| f_upper(x, d1, d2), 1.0 - p, 0.0, 1.0, 1e60) };
num_val(value)
}
pub(super) fn f_inv_rt(a: &[Arg], _: C) -> R<Value> {
@@ -410,7 +439,7 @@ pub(super) fn binom_dist(a: &[Arg], _: C) -> R<Value> {
let n = num(a, 1)?.trunc();
let p = num(a, 2)?;
let cum = boolean(a, 3)?;
if k < 0.0 || k > n || !(0.0..=1.0).contains(&p) {
if k < 0.0 || n > MAX_BINOM_TRIALS || k > n || !(0.0..=1.0).contains(&p) {
return Err(CellError::Num);
}
num_val(if cum { binom_cdf(k, n, p) } else { binom_pmf(k, n, p) })
@@ -421,7 +450,7 @@ pub(super) fn binom_dist_range(a: &[Arg], _: C) -> R<Value> {
let p = num(a, 1)?;
let s1 = num(a, 2)?.trunc();
let s2 = if has(a, 3) { num(a, 3)?.trunc() } else { s1 };
if !(0.0..=1e15).contains(&n) || !(0.0..=1.0).contains(&p) || s1 < 0.0 || s1 > n || s2 < s1 || s2 > n {
if !(0.0..=MAX_BINOM_TRIALS).contains(&n) || !(0.0..=1.0).contains(&p) || s1 < 0.0 || s1 > n || s2 < s1 || s2 > n {
return Err(CellError::Num);
}
if s2 - s1 > 10_000.0 {
@@ -499,13 +528,7 @@ pub(super) fn gamma_dist(a: &[Arg], _: C) -> R<Value> {
return num_val(reg_gamma_p(al, x / be));
}
if x == 0.0 {
return if al < 1.0 {
Err(CellError::Num)
} else if al == 1.0 {
num_val(1.0 / be)
} else {
num_val(0.0)
};
return if al <= 1.0 { Err(CellError::Num) } else { num_val(0.0) };
}
num_val(((al - 1.0) * x.ln() - x / be - al * be.ln() - ln_gamma(al)).exp())
}
@@ -548,7 +571,7 @@ pub(super) fn beta_dist(a: &[Arg], _: C) -> R<Value> {
if cum {
return num_val(reg_inc_beta(z, al, be));
}
if (z == 0.0 && al < 1.0) || (z == 1.0 && be < 1.0) {
if (z == 0.0 && al <= 1.0) || (z == 1.0 && be <= 1.0) {
return Err(CellError::Num);
}
let lp = if z == 0.0 || z == 1.0 {
@@ -608,6 +631,9 @@ pub(super) fn weibull_dist(a: &[Arg], _: C) -> R<Value> {
if x < 0.0 || al <= 0.0 || be <= 0.0 {
return Err(CellError::Num);
}
if x == 0.0 {
return num_val(0.0);
}
let t = (x / be).powf(al);
num_val(if cum { -(-t).exp_m1() } else { al / be.powf(al) * x.powf(al - 1.0) * (-t).exp() })
}
+102
View File
@@ -151,6 +151,19 @@ fn spread() {
let g = [4.0, 5.0, 8.0, 7.0, 11.0, 4.0, 3.0];
close_tol(ev("GEOMEAN", vec![c(&g)]), 5.476986969, 1e-9);
close_tol(ev("HARMEAN", vec![c(&g)]), 5.028375962, 1e-9);
let text_values = arr(vec![vec![tv("1"), tv("2")]]);
let bool_values = arr(vec![vec![Value::Bool(true), Value::Bool(false)]]);
let mixed_values = arr(vec![vec![nv(2.0), tv("ignored"), Value::Bool(true)]]);
is_err(ev("HARMEAN", vec![av(text_values.clone())]), CellError::NA);
is_err(ev("HARMEAN", vec![av(bool_values.clone())]), CellError::NA);
is_err(ev("HARMEAN", vec![rf(text_values)]), CellError::NA);
is_err(ev("HARMEAN", vec![rf(bool_values)]), CellError::NA);
close(ev("HARMEAN", vec![av(mixed_values.clone())]), 2.0);
close(ev("HARMEAN", vec![rf(mixed_values)]), 2.0);
close(ev("HARMEAN", vec![b(true)]), 1.0);
close(ev("HARMEAN", vec![n(2.0), n(4.0)]), 8.0 / 3.0);
is_err(ev("HARMEAN", vec![av(arr(vec![vec![nv(0.0)]]))]), CellError::Num);
is_err(ev("HARMEAN", vec![av(arr(vec![vec![nv(-1.0)]]))]), CellError::Num);
is_err(ev("GEOMEAN", vec![c(&[1.0, 0.0])]), CellError::Num);
close_tol(ev("TRIMMEAN", vec![c(&[4.0, 5.0, 6.0, 7.0, 2.0, 3.0, 4.0, 5.0, 1.0, 2.0, 3.0]), n(0.2)]), 3.777777778, 1e-9);
is_err(ev("TRIMMEAN", vec![c(&[1.0]), n(1.0)]), CellError::Num);
@@ -360,6 +373,7 @@ fn normal() {
#[test]
fn t_chi_f() {
close_tol(ev("T.DIST", vec![n(0.0), n(6e307), b(false)]), 0.3989422804, 1e-10);
close_tol(ev("T.DIST", vec![n(60.0), n(1.0), b(true)]), 0.99469533, D7);
close_tol(ev("T.DIST", vec![n(8.0), n(3.0), b(false)]), 0.00073691, 1e-5);
close_tol(ev("T.DIST.2T", vec![n(1.959999998), n(60.0)]), 0.054644930, D7);
@@ -397,6 +411,7 @@ fn t_chi_f() {
close_tol(ev("F.DIST.RT", vec![n(15.2069), n(6.0), n(4.0)]), 0.01, 1e-6);
close_tol(ev("FDIST", vec![n(15.2069), n(6.0), n(4.0)]), 0.01, 1e-6);
close_tol(ev("F.INV", vec![n(0.01), n(6.0), n(4.0)]), 0.10930991, D7);
close_tol(ev("F.INV", vec![n(0.99), n(6.0), n(4.0)]), 15.20686, D7);
close_tol(ev("F.INV.RT", vec![n(0.01), n(6.0), n(4.0)]), 15.20686, D7);
close_tol(ev("FINV", vec![n(0.01), n(6.0), n(4.0)]), 15.20686, D7);
close_tol(ev("F.TEST", vec![c(&[6.0, 7.0, 9.0, 15.0, 21.0]), c(&[20.0, 28.0, 31.0, 38.0, 40.0])]), 0.64831785, D7);
@@ -404,6 +419,48 @@ fn t_chi_f() {
is_err(ev("F.DIST", vec![n(1.0), n(0.0), n(4.0), b(true)]), CellError::Num);
}
#[test]
fn f_distributions_accept_ten_billion_degrees() {
close_tol(ev("F.DIST", vec![n(0.0), n(10_000_000_000.0), n(1.0), b(false)]), 0.0, 0.0);
close_tol(ev("F.DIST.RT", vec![n(0.0), n(1.0), n(10_000_000_000.0)]), 1.0, 0.0);
close_tol(ev("F.DIST", vec![n(0.01), n(1_000_000_000.0), n(1.0), b(true)]), 1.52397e-23, 1e-28);
close_tol(ev("F.DIST", vec![n(1.0), n(10_000_000_000.0), n(10_000_000_000.0), b(true)]), 0.5, 0.0);
close_tol(ev("F.DIST", vec![n(1_000_000.0), n(10_000_000_000.0), n(1.0), b(true)]), 0.999202115, 1e-8);
close_tol(ev("F.INV", vec![n(0.5), n(10_000_000_000.0), n(1.0)]), 2.198110953542527, 2e-6);
close_tol(ev("F.DIST.RT", vec![n(0.0), n(9_999_999_999.0), n(1.0)]), 1.0, 0.0);
is_err(ev("F.DIST.RT", vec![n(0.0), n(10_000_000_001.0), n(1.0)]), CellError::Num);
is_err(ev("F.DIST.RT", vec![n(0.0), n(1.0), n(10_000_000_001.0)]), CellError::Num);
}
#[test]
fn t_dist_zero_degrees_error_depends_on_mode() {
for df in [0.0, 0.9, -0.1] {
is_err(ev("T.DIST", vec![n(0.0), n(df), b(false)]), CellError::Div0);
is_err(ev("T.DIST", vec![n(0.0), n(df), b(true)]), CellError::Num);
}
is_err(ev("T.DIST", vec![n(0.0), n(-1.0), b(false)]), CellError::Num);
close_tol(ev("T.DIST", vec![n(0.0), n(1.99), b(false)]), 1.0 / std::f64::consts::PI, 1e-15);
close_tol(ev("T.DIST", vec![n(0.0), n(1.0), b(false)]), 1.0 / std::f64::consts::PI, 1e-15);
}
#[test]
fn t_density_is_stable_for_large_degrees() {
close_tol(ev("T.DIST", vec![n(0.0), n(100_000_000.0), b(false)]), 0.398942279404077, 1e-12);
close_tol(ev("T.DIST", vec![n(0.0), n(10_000_000_000.0), b(false)]), 0.3989422803914591, 1e-12);
close_tol(ev("T.DIST", vec![n(1.0), n(10_000_000_000_000_000.0), b(false)]), 0.24197072451914337, 1e-12);
}
#[test]
fn t_inv_zero_probability_returns_div0() {
is_err(ev("T.INV", vec![n(-0.1), n(1.0)]), CellError::Num);
is_err(ev("T.INV", vec![n(0.0), n(1.0)]), CellError::Div0);
close_tol(ev("T.INV", vec![n(0.75), n(2.0)]), 0.8164966, D7);
is_err(ev("T.INV", vec![n(1.0), n(1.0)]), CellError::Num);
is_err(ev("T.INV", vec![n(1.1), n(1.0)]), CellError::Num);
is_err(ev("T.INV", vec![n(0.0), n(0.0)]), CellError::Num);
is_err(ev("T.INV.2T", vec![n(0.0), n(1.0)]), CellError::Num);
}
#[test]
fn binom_inv_huge_trials_returns() {
// Above 2^53 neighbouring f64 values are more than 1 apart; the bisection must still end.
@@ -427,7 +484,15 @@ fn discrete() {
close_tol(ev("BINOM.DIST", vec![n(6.0), n(10.0), n(0.5), b(false)]), 0.205078125, 1e-12);
close_tol(ev("BINOM.DIST", vec![n(6.0), n(10.0), n(0.5), b(true)]), 0.828125, 1e-12);
close_tol(ev("BINOMDIST", vec![n(6.0), n(10.0), n(0.5), b(false)]), 0.205078125, 1e-12);
close(ev("BINOM.DIST", vec![n(1.0), n(2_147_483_646.0), n(0.0), b(false)]), 0.0);
close(ev("BINOM.DIST", vec![n(1.0), n(2_147_483_646.9), n(0.0), b(false)]), 0.0);
is_err(ev("BINOM.DIST", vec![n(1.0), n(2_147_483_647.0), n(0.0), b(false)]), CellError::Num);
is_err(ev("BINOM.DIST", vec![n(1.0), n(2_147_483_647.9), n(0.0), b(false)]), CellError::Num);
is_err(ev("BINOM.DIST", vec![n(11.0), n(10.0), n(0.5), b(false)]), CellError::Num);
close(ev("BINOM.DIST.RANGE", vec![n(2_147_483_646.0), n(0.0), n(1.0)]), 0.0);
close(ev("BINOM.DIST.RANGE", vec![n(2_147_483_646.9), n(0.0), n(1.0)]), 0.0);
is_err(ev("BINOM.DIST.RANGE", vec![n(2_147_483_647.0), n(0.0), n(1.0)]), CellError::Num);
is_err(ev("BINOM.DIST.RANGE", vec![n(2_147_483_647.9), n(0.0), n(1.0)]), CellError::Num);
close_tol(ev("BINOM.DIST.RANGE", vec![n(60.0), n(0.75), n(48.0)]), 0.083974967, 1e-6);
close_tol(ev("BINOM.DIST.RANGE", vec![n(60.0), n(0.75), n(45.0), n(50.0)]), 0.523629793, 1e-6);
close(ev("BINOM.INV", vec![n(6.0), n(0.5), n(0.75)]), 4.0);
@@ -466,6 +531,19 @@ fn continuous() {
is_err(ev("GAMMA", vec![n(0.0)]), CellError::Num);
close_tol(ev("BETA.DIST", vec![n(2.0), n(8.0), n(10.0), b(true), n(1.0), n(3.0)]), 0.6854706, D7);
close_tol(ev("BETA.DIST", vec![n(2.0), n(8.0), n(10.0), b(false), n(1.0), n(3.0)]), 1.4837646, D7);
is_err(ev("BETA.DIST", vec![n(0.0), n(1.0), n(1.0), b(false)]), CellError::Num);
is_err(ev("BETA.DIST", vec![n(1.0), n(1.0), n(1.0), b(false)]), CellError::Num);
is_err(ev("BETA.DIST", vec![n(0.0), n(0.5), n(2.0), b(false)]), CellError::Num);
is_err(ev("BETA.DIST", vec![n(1.0), n(2.0), n(0.5), b(false)]), CellError::Num);
close(ev("BETA.DIST", vec![n(0.0), n(2.0), n(1.0), b(false)]), 0.0);
close(ev("BETA.DIST", vec![n(1.0), n(1.0), n(2.0), b(false)]), 0.0);
close(ev("BETA.DIST", vec![n(0.0), n(1.0), n(1.0), b(true)]), 0.0);
close(ev("BETA.DIST", vec![n(1.0), n(1.0), n(1.0), b(true)]), 1.0);
is_err(ev("BETA.DIST", vec![n(2.0), n(1.0), n(2.0), b(false), n(2.0), n(6.0)]), CellError::Num);
is_err(ev("BETA.DIST", vec![n(6.0), n(2.0), n(1.0), b(false), n(2.0), n(6.0)]), CellError::Num);
close(ev("BETA.DIST", vec![n(4.0), n(1.0), n(1.0), b(false), n(2.0), n(6.0)]), 0.25);
close(ev("BETA.DIST", vec![n(2.0), n(1.0), n(1.0), b(true), n(2.0), n(6.0)]), 0.0);
close(ev("BETA.DIST", vec![n(6.0), n(1.0), n(1.0), b(true), n(2.0), n(6.0)]), 1.0);
close_tol(ev("BETADIST", vec![n(2.0), n(8.0), n(10.0), n(1.0), n(3.0)]), 0.6854706, D7);
close_tol(ev("BETA.INV", vec![n(0.685470581), n(8.0), n(10.0), n(1.0), n(3.0)]), 2.0, D7);
close_tol(ev("BETAINV", vec![n(0.685470581), n(8.0), n(10.0), n(1.0), n(3.0)]), 2.0, D7);
@@ -479,6 +557,30 @@ fn continuous() {
close_tol(r[0][1].clone(), 0.841344746, 1e-8);
}
#[test]
fn gamma_density_at_zero_shape_one_is_num() {
for scale in [0.1, 0.9, 1.0, 1.9, 2.0, 10_000_000_000.0] {
is_err(ev("GAMMA.DIST", vec![n(0.0), n(1.0), n(scale), b(false)]), CellError::Num);
}
is_err(ev("GAMMA.DIST", vec![n(0.0), n(0.5), n(1.0), b(false)]), CellError::Num);
close_tol(ev("GAMMA.DIST", vec![n(0.0), n(2.0), n(1.0), b(false)]), 0.0, 0.0);
close_tol(ev("GAMMA.DIST", vec![n(0.0), n(1.0), n(2.0), b(true)]), 0.0, 0.0);
close_tol(ev("GAMMA.DIST", vec![n(2.0), n(1.0), n(2.0), b(false)]), 0.5 * (-1.0f64).exp(), 1e-15);
close_tol(ev("GAMMA.DIST", vec![n(2.0), n(1.0), n(2.0), b(true)]), 1.0 - (-1.0f64).exp(), 1e-15);
is_err(ev("GAMMA.DIST", vec![n(-0.1), n(1.0), n(2.0), b(false)]), CellError::Num);
}
#[test]
fn weibull_density_at_zero_is_zero() {
for shape in [0.5, 1.0, 2.0] {
close_tol(ev("WEIBULL.DIST", vec![n(0.0), n(shape), n(1.0), b(false)]), 0.0, 0.0);
close_tol(ev("WEIBULL.DIST", vec![n(0.0), n(shape), n(1.0), b(true)]), 0.0, 0.0);
}
close_tol(ev("WEIBULL.DIST", vec![n(1.0), n(1.0), n(1.0), b(false)]), (-1.0f64).exp(), 1e-15);
close_tol(ev("WEIBULL.DIST", vec![n(1.0), n(1.0), n(1.0), b(true)]), 1.0 - (-1.0f64).exp(), 1e-15);
is_err(ev("WEIBULL.DIST", vec![n(-0.1), n(1.0), n(1.0), b(false)]), CellError::Num);
}
#[test]
fn aggregate_helper() {
let vals = vec![nv(1.0), nv(2.0), tv("x"), Value::Empty, Value::Bool(true), nv(6.0)];
+113 -12
View File
@@ -111,12 +111,31 @@ fn len(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
num_val(text(a, 0)?.chars().count() as f64)
}
fn lower_char(ch: char) -> char {
match ch.to_lowercase().next() {
Some(lower) => lower,
None => ch,
}
}
fn upper_char(ch: char) -> char {
let mut uppercase = ch.to_uppercase();
match uppercase.next() {
Some(upper) if uppercase.next().is_none() => upper,
_ => ch,
}
}
fn proper_upper_char(ch: char) -> char {
if ch == '\u{130}' { 'I' } else { upper_char(ch) }
}
fn lower(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
text_val(text(a, 0)?.to_lowercase())
text_val(text(a, 0)?.chars().map(lower_char).collect::<String>())
}
fn upper(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
text_val(text(a, 0)?.to_uppercase())
text_val(text(a, 0)?.chars().map(upper_char).collect::<String>())
}
fn proper(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
@@ -126,9 +145,9 @@ fn proper(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
for ch in s.chars() {
if ch.is_alphabetic() {
if prev_letter {
out.extend(ch.to_lowercase());
out.push(lower_char(ch));
} else {
out.extend(ch.to_uppercase());
out.push(proper_upper_char(ch));
}
prev_letter = true;
} else {
@@ -281,7 +300,11 @@ fn unichar(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
if !(1.0..=1_114_111.0).contains(&n) {
return Err(CellError::Value);
}
match char::from_u32(n as u32) {
let cp = n as u32;
if (0xFDD0..=0xFDEF).contains(&cp) || cp & 0xFFFE == 0xFFFE {
return Err(CellError::NA);
}
match char::from_u32(cp) {
Some(c) => text_val(c.to_string()),
None => Err(CellError::NA),
}
@@ -350,10 +373,10 @@ fn numbervalue(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
/// Formats `x` (already rounded) with `decimals` places and optional thousands separators.
fn format_fixed(x: f64, decimals: i64, commas: bool) -> String {
let d = decimals.max(0) as usize;
let s = format!("{:.*}", d, x.abs());
let s = x.abs().to_string();
let (int_part, frac) = match s.split_once('.') {
Some((i, f)) => (i.to_string(), Some(f.to_string())),
None => (s.clone(), None),
Some((i, f)) => (i, f),
None => (s.as_str(), ""),
};
let int_part = if commas {
let b = int_part.as_bytes();
@@ -366,12 +389,14 @@ fn format_fixed(x: f64, decimals: i64, commas: bool) -> String {
}
o
} else {
int_part
int_part.to_string()
};
let mut out = int_part;
if let Some(f) = frac {
if d > 0 {
let frac_len = frac.len().min(d);
out.push('.');
out.push_str(&f);
out.push_str(&frac[..frac_len]);
out.extend(std::iter::repeat_n('0', d - frac_len));
}
out
}
@@ -394,13 +419,21 @@ fn fixed(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
let (r, d) = rounded(a, 2.0)?;
let no_commas = opt_bool(a, 2, false)?;
let body = format_fixed(r, d, !no_commas);
if body.len() + if r < 0.0 { 1 } else { 0 } > 255 {
return Err(CellError::Value);
}
text_val(if r < 0.0 { format!("-{body}") } else { body })
}
fn dollar(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
let (r, d) = rounded(a, 2.0)?;
let negative = r < 0.0 || (r == 0.0 && d >= 0 && num(a, 0)? < 0.0);
let body = format_fixed(r, d, true);
text_val(if r < 0.0 { format!("(${body})") } else { format!("${body}") })
let out = if negative { format!("(${body})") } else { format!("${body}") };
if out.len() > 254 {
return Err(CellError::Value);
}
text_val(out)
}
// ---------------------------------------------------------------------------------------------
@@ -1123,6 +1156,20 @@ mod tests {
fn case_and_cleanup() {
is_text(ev("UPPER", vec![t("abc")]), "ABC");
is_text(ev("LOWER", vec![t("ABC")]), "abc");
is_text(ev("UPPER", vec![t("Straße")]), "STRAßE");
is_text(ev("LOWER", vec![t("İ")]), "i");
is_text(ev("PROPER", vec![t("İSTANBUL")]), "Istanbul");
is_text(ev("PROPER", vec![t("ß")]), "ß");
is_text(ev("PROPER", vec![t("ÉCOLE")]), "École");
let proper = ev("PROPER", vec![t("İSTANBUL Straße")]);
is_text(proper.clone(), "Istanbul Straße");
assert_eq!(
match proper {
Value::Text(s) => s.chars().count(),
_ => 0,
},
"Istanbul Straße".chars().count()
);
is_text(ev("PROPER", vec![t("this is a TITLE")]), "This Is A Title");
is_text(ev("PROPER", vec![t("2-way street's")]), "2-Way Street'S");
is_text(ev("TRIM", vec![t(" a b c ")]), "a b c");
@@ -1181,8 +1228,15 @@ mod tests {
close(ev("CODE", vec![t("€")]), 128.0);
is_err(ev("CODE", vec![t("")]), CellError::Value);
is_text(ev("UNICHAR", vec![n(9731.0)]), "☃");
for cp in [0xFDD0, 0xFDEF, 0xFFFE, 0xFFFF, 0x1FFFE, 0x1FFFF, 0x10FFFE, 0x10FFFF] {
is_err(ev("UNICHAR", vec![n(f64::from(cp))]), CellError::NA);
}
for (cp, expected) in [(0xFDCF, "\u{FDCF}"), (0xFDF0, "\u{FDF0}"), (0xFFFD, "\u{FFFD}"), (0x1FFFD, "\u{1FFFD}")] {
is_text(ev("UNICHAR", vec![n(f64::from(cp))]), expected);
}
close(ev("UNICODE", vec![t("☃")]), 9731.0);
is_err(ev("UNICHAR", vec![n(0.0)]), CellError::Value);
is_err(ev("UNICHAR", vec![n(1_114_112.0)]), CellError::Value);
is_err(ev("UNICHAR", vec![n(55296.0)]), CellError::NA);
}
@@ -1237,10 +1291,57 @@ mod tests {
is_text(ev("DOLLAR", vec![n(0.5), n(0.0)]), "$1");
is_text(ev("USDOLLAR", vec![n(1.0)]), "$1.00");
is_text(ev("USDOLLAR", vec![n(-1234.567), n(1.0)]), "($1,234.6)");
let fixed_16 = format!("1.1{}", "0".repeat(15));
let dollar_16 = format!("$1.1{}", "0".repeat(15));
let fixed_20 = format!("1.005{}", "0".repeat(17));
let dollar_20 = format!("$1.005{}", "0".repeat(17));
let fixed_127 = format!("1.1{}", "0".repeat(126));
let dollar_127 = format!("$1.1{}", "0".repeat(126));
is_text(ev("FIXED", vec![n(1.1), n(16.0)]), &fixed_16);
is_text(ev("DOLLAR", vec![n(1.1), n(16.0)]), &dollar_16);
is_text(ev("FIXED", vec![n(1.005), n(20.0)]), &fixed_20);
is_text(ev("DOLLAR", vec![n(1.005), n(20.0)]), &dollar_20);
is_text(ev("FIXED", vec![n(1.1), n(127.0)]), &fixed_127);
is_text(ev("DOLLAR", vec![n(1.1), n(127.0)]), &dollar_127);
is_text(ev("T", vec![t("x")]), "x");
is_text(ev("T", vec![n(1.0)]), "");
}
#[test]
fn fixed_and_dollar_output_limits() {
assert!(matches!(ev("FIXED", vec![n(1e191), n(0.0)]), Value::Text(ref s) if s.len() == 255));
is_err(ev("FIXED", vec![n(1e192), n(0.0)]), CellError::Value);
assert!(matches!(ev("FIXED", vec![n(-1e253), n(0.0), b(true)]), Value::Text(ref s) if s.starts_with('-')));
is_err(ev("FIXED", vec![n(-1e191), n(0.0)]), CellError::Value);
assert!(matches!(ev("FIXED", vec![n(1e189), n(1.0)]), Value::Text(ref s) if s.len() == 255));
is_err(ev("FIXED", vec![n(1e190), n(1.0)]), CellError::Value);
assert!(matches!(ev("DOLLAR", vec![n(1e189), n(0.0)]), Value::Text(ref s) if s.len() == 254));
is_err(ev("DOLLAR", vec![n(1e190), n(0.0)]), CellError::Value);
assert!(matches!(ev("DOLLAR", vec![n(-1e188), n(0.0)]), Value::Text(ref s) if s.len() == 254));
is_err(ev("DOLLAR", vec![n(-1e189), n(0.0)]), CellError::Value);
assert!(matches!(ev("DOLLAR", vec![n(1.23456789e187), n(2.0)]), Value::Text(ref s) if s.len() == 254));
is_err(ev("DOLLAR", vec![n(1.23456789e188), n(2.0)]), CellError::Value);
is_err(ev("DOLLAR", vec![n(-1.23456789e186), n(2.0)]), CellError::Value);
assert!(matches!(ev("DOLLAR", vec![n(1e189), n(-1.0)]), Value::Text(ref s) if s.len() == 254));
is_err(ev("DOLLAR", vec![n(1e190), n(-1.0)]), CellError::Value);
}
#[test]
fn dollar_keeps_negative_sign_when_nonnegative_precision_rounds_to_zero() {
is_text(ev("DOLLAR", vec![n(-0.0001), n(0.0)]), "($0)");
is_text(ev("DOLLAR", vec![n(-0.0001), n(2.0)]), "($0.00)");
is_text(ev("DOLLAR", vec![n(0.0), n(0.0)]), "$0");
is_text(ev("DOLLAR", vec![n(-0.0), n(0.0)]), "$0");
is_text(ev("DOLLAR", vec![n(0.0001), n(0.0)]), "$0");
is_text(ev("DOLLAR", vec![n(-0.0001), n(-1.0)]), "$0");
is_text(ev("DOLLAR", vec![n(-4.0), n(-1.0)]), "$0");
is_text(ev("DOLLAR", vec![n(-5.0), n(-1.0)]), "($10)");
is_text(ev("DOLLAR", vec![n(-0.4), n(0.0)]), "($0)");
is_text(ev("DOLLAR", vec![n(-0.5), n(0.0)]), "($1)");
is_text(ev("FIXED", vec![n(-0.0001), n(0.0)]), "0");
}
#[test]
fn before_after() {
is_text(ev("TEXTBEFORE", vec![t("Red riding hood"), t(" ")]), "Red");
+1 -3
View File
@@ -13,7 +13,5 @@ gridcraft-core = { workspace = true }
sha2 = { workspace = true }
gridcraft-formula = { workspace = true }
serde = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]
serde_json = { workspace = true }
thiserror = { workspace = true }
+2
View File
@@ -8,6 +8,7 @@ mod b64;
pub mod cell;
mod cellmap;
pub mod features;
pub mod macros;
pub mod password;
pub mod preserved;
pub mod sheet;
@@ -17,6 +18,7 @@ pub mod workbook;
pub use cell::{Cell, CellPicture, Formula};
pub use features::*;
pub use macros::*;
pub use password::PasswordHash;
pub use preserved::{Preserved, PreservedPart, PreservedRel, PreservedSheet, PreservedXml};
pub use sheet::{DEFAULT_COL_WIDTH, DEFAULT_ROW_HEIGHT, LineIndex, Sheet, SheetViewMode, Visibility, points_to_chars};
+539
View File
@@ -0,0 +1,539 @@
//! GridCraft macros: typed, declarative command lists that run as one undo step. They are not VBA:
//! a VBA project in a file is carried through unchanged (`Workbook::vba_project`) and never run.
use serde::{Deserialize, Serialize};
/// The macro format version written by this build.
pub const MACRO_VERSION: u32 = 1;
/// The most macros a workbook keeps.
pub const MAX_MACROS: usize = 1_000;
/// The most steps in one macro, counting the steps inside Repeat and If.
pub const MAX_MACRO_STEPS: usize = 10_000;
/// How deeply Repeat and If may nest (a top-level step is at depth 1).
pub const MAX_MACRO_DEPTH: usize = 4;
/// The longest macro or input name, in characters.
pub const MAX_MACRO_NAME: usize = 255;
/// The longest macro description, step note, Stop message or If value (as JSON), in characters.
pub const MAX_MACRO_TEXT: usize = 4_096;
/// The longest reference or token in a macro (an output anchor, an input's default range, a
/// Repeat's range, an If's cell), in characters.
pub const MAX_MACRO_REF: usize = 1_024;
/// The longest shortcut text (`Ctrl+Shift+M`), in characters.
pub const MAX_MACRO_SHORTCUT: usize = 64;
/// The most commands a macro's permissions list.
pub const MAX_MACRO_PERMISSIONS: usize = 256;
/// The most column types an input lists: one per column of a sheet.
pub const MAX_MACRO_COLUMNS: usize = 16_384;
/// A recorded or hand-made macro: a list of typed steps that runs as one undo step. Most steps run
/// a GridCraft command; Repeat, If and Stop add bounded control flow. References in step
/// parameters can be tokens (`"Input.col(2)"`) that are resolved against the declared inputs when
/// the macro runs, so a macro recorded on one block of data works on another.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Macro {
/// Format version ([`MACRO_VERSION`]).
pub v: u32,
pub name: String,
pub description: String,
/// Keyboard shortcut (`Ctrl+Shift+M`) for a user interface to offer; the engine only stores it.
#[serde(skip_serializing_if = "Option::is_none")]
pub shortcut: Option<String>,
/// The ranges the macro works on, checked before it runs.
pub inputs: Vec<NamedInput>,
pub steps: Vec<Step>,
/// Where results go and what to do when they would land on existing data.
pub output: OutputRule,
/// Commands that need permission (reading or writing files) which the macro may run.
pub permissions: Vec<String>,
}
impl Default for Macro {
fn default() -> Self {
Macro {
v: MACRO_VERSION,
name: String::new(),
description: String::new(),
shortcut: None,
inputs: vec![],
steps: vec![],
output: OutputRule::default(),
permissions: vec![],
}
}
}
impl Macro {
/// Steps in the macro, counting those inside Repeat and If.
pub fn step_count(&self) -> usize {
fn count(steps: &[Step]) -> usize {
steps.iter().map(|s| 1usize.saturating_add(s.kind.children().map(|b| count(b)).sum::<usize>())).sum()
}
count(&self.steps)
}
/// Checks every field a file can set: the format version ([`check_version`]); the lengths of
/// the name, description, shortcut, notes, messages, references and command ids; the counts of
/// steps ([`MAX_MACRO_STEPS`]), inputs, permissions and column types; the nesting depth
/// ([`MAX_MACRO_DEPTH`]); and each input's declaration. Command parameters are bounded only by
/// the size of the whole macro, which [`MAX_MACRO_STEPS`] and the workbook's item limit cap.
/// Every struct is taken apart field by field, so a field added later doesn't compile here
/// until someone decides its limit.
pub fn check_limits(&self) -> Result<(), String> {
let Macro { v, name, description, shortcut, inputs, steps, output, permissions } = self;
check_version(u64::from(*v))?;
let name = name.trim();
if name.is_empty() {
return Err("A macro needs a name.".into());
}
if name.chars().count() > MAX_MACRO_NAME {
return Err(format!("A macro name can be at most {MAX_MACRO_NAME} characters long."));
}
if name.chars().any(char::is_control) {
return Err("A macro name can't hold line breaks or other control characters.".into());
}
check_text("A macro description", description, MAX_MACRO_TEXT, false)?;
if let Some(shortcut) = shortcut {
check_text("A shortcut", shortcut, MAX_MACRO_SHORTCUT, true)?;
}
let OutputRule { anchor, on_conflict: _ } = output;
if let Some(anchor) = anchor {
check_text("The output anchor", anchor, MAX_MACRO_REF, true)?;
}
if permissions.len() > MAX_MACRO_PERMISSIONS {
return Err(format!("A macro can list at most {MAX_MACRO_PERMISSIONS} permissions."));
}
for p in permissions {
check_id("A permission", p)?;
}
// Count without recursing past the depth limit (a hostile file can nest deeper).
// A Repeat or If at the top is nested 1 deep; one inside it, 2 deep.
fn walk(steps: &[Step], level: usize, count: &mut usize) -> Result<(), String> {
for s in steps {
*count = count.saturating_add(1);
if *count > MAX_MACRO_STEPS {
return Err(format!("A macro can have at most {MAX_MACRO_STEPS} steps."));
}
let Step { kind, enabled: _, breakpoint: _, note } = s;
check_text("A step note", note, MAX_MACRO_TEXT, false)?;
match kind {
StepKind::Command { id, params: _ } => check_id("A step's command", id)?,
StepKind::Repeat { over, of: _, max: _, body: _ } => check_text("A Repeat step's range", over, MAX_MACRO_REF, true)?,
StepKind::If { cell, op: _, value, then: _, otherwise: _ } => {
check_text("An If step's cell", cell, MAX_MACRO_REF, true)?;
// Empty and NotEmpty ignore the value, which is then null.
if serde_json::to_string(value).map_or(usize::MAX, |t| t.chars().count()) > MAX_MACRO_TEXT {
return Err(format!("An If step's value can be at most {MAX_MACRO_TEXT} characters long."));
}
}
StepKind::Stop { message, rollback: _ } => check_text("A Stop message", message, MAX_MACRO_TEXT, false)?,
}
if kind.children().next().is_some() && level > MAX_MACRO_DEPTH {
return Err(format!("Repeat and If steps can nest at most {MAX_MACRO_DEPTH} deep."));
}
for body in kind.children() {
walk(body, level + 1, count)?;
}
}
Ok(())
}
walk(steps, 1, &mut 0)?;
if inputs.len() > 64 {
return Err("A macro can declare at most 64 inputs.".into());
}
for (k, i) in inputs.iter().enumerate() {
let NamedInput { name, kind, rows, cols, header, col_types, non_empty: _, default_ref } = i;
let n = name.as_str();
let ident = n.chars().next().is_some_and(char::is_alphabetic) && n.chars().all(|c| c.is_alphanumeric() || c == '_');
if !ident || n.chars().count() > MAX_MACRO_NAME {
return Err(format!("'{n}' can't name an input: use letters, digits and _, starting with a letter."));
}
if ["Output", "Row", "Column", "Name"].iter().any(|r| r.eq_ignore_ascii_case(n)) {
return Err(format!("'{n}' is a token of its own and can't name an input."));
}
// A step written with plain A1 text (a recorded one) must keep meaning the cell.
if gridcraft_core::RangeRef::parse(n).is_some() {
return Err(format!("'{n}' reads as a cell reference and can't name an input."));
}
if inputs.iter().take(k).any(|o| o.name.eq_ignore_ascii_case(n)) {
return Err(format!("Two inputs are called '{n}'."));
}
for (what, [least, most]) in [("rows", rows), ("columns", cols)] {
if *most != 0 && least > most {
return Err(format!("Input '{n}' allows {least} to {most} {what}: the least can't be more than the most."));
}
}
// A cell is one row and one column ([1, 1], or the default [1, 0]) with no header.
let one = |b: &[u32; 2]| *b == [1, 1] || *b == [1, 0];
if *kind == InputKind::Cell && (!one(rows) || !one(cols) || *header || col_types.len() > 1) {
return Err(format!("Input '{n}' is a single cell: one row and one column, no header, at most one column type."));
}
let most = if cols[1] == 0 { MAX_MACRO_COLUMNS } else { usize::try_from(cols[1]).unwrap_or(usize::MAX).min(MAX_MACRO_COLUMNS) };
if col_types.len() > most {
return Err(format!("Input '{n}' lists the types of {} columns, but has at most {most}.", col_types.len()));
}
if let Some(r) = default_ref {
check_text(&format!("The default range of input '{n}'"), r, MAX_MACRO_REF, true)?;
}
}
Ok(())
}
}
/// Refuses `text` longer than `max` characters, and, for a one-line field, one that holds line
/// breaks or other control characters.
fn check_text(what: &str, text: &str, max: usize, one_line: bool) -> Result<(), String> {
if text.chars().count() > max {
return Err(format!("{what} can be at most {max} characters long."));
}
if one_line && text.chars().any(char::is_control) {
return Err(format!("{what} can't hold line breaks or other control characters."));
}
Ok(())
}
/// Refuses a command id that isn't one: empty, longer than a name, or holding anything other than
/// letters, digits, `.` and `_` (every command id is of that form).
fn check_id(what: &str, id: &str) -> Result<(), String> {
let ok = !id.is_empty() && id.len() <= MAX_MACRO_NAME && id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'_');
if ok { Ok(()) } else { Err(format!("{what} must be a command id (letters, digits, . and _): '{}'", id.chars().take(64).collect::<String>())) }
}
/// The macros of a workbook. Workbook snapshots share the list until one of them changes it, so
/// an undo entry or a copy made for a cell edit holds the same list rather than a copy of every
/// step. Reads go through `Deref` to the list; a write through `DerefMut` first copies the list
/// when another snapshot shares it.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Macros(std::sync::Arc<Vec<Macro>>);
impl Macros {
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
/// Whether both hold the one list, with no copy made between them.
pub fn shares(&self, other: &Macros) -> bool {
std::sync::Arc::ptr_eq(&self.0, &other.0)
}
/// A rough size of the list in memory, for the undo budget: each macro's JSON, which holds
/// every name, text and step parameter, plus its structs. An undo step that keeps a copy of
/// the list (after a save, rename or delete) is charged for all of it. The JSON is counted as
/// it is written, without building it.
pub fn estimated_bytes(&self) -> usize {
/// Counts the bytes written to it.
struct Count(usize);
impl std::io::Write for Count {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.0 = self.0.saturating_add(buf.len());
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
self.0.iter().fold(0usize, |bytes, m| {
let mut json = Count(0);
// Writing to a counter fails only for a value JSON can't hold, which a macro never is.
let _ = serde_json::to_writer(&mut json, m);
let steps = m.step_count().saturating_mul(std::mem::size_of::<Step>());
bytes.saturating_add(std::mem::size_of::<Macro>()).saturating_add(json.0).saturating_add(steps)
})
}
}
impl From<Vec<Macro>> for Macros {
fn from(list: Vec<Macro>) -> Macros {
Macros(std::sync::Arc::new(list))
}
}
impl std::ops::Deref for Macros {
type Target = Vec<Macro>;
fn deref(&self) -> &Vec<Macro> {
&self.0
}
}
impl std::ops::DerefMut for Macros {
fn deref_mut(&mut self) -> &mut Vec<Macro> {
std::sync::Arc::make_mut(&mut self.0)
}
}
impl IntoIterator for Macros {
type Item = Macro;
type IntoIter = std::vec::IntoIter<Macro>;
fn into_iter(self) -> Self::IntoIter {
std::sync::Arc::unwrap_or_clone(self.0).into_iter()
}
}
impl<'a> IntoIterator for &'a Macros {
type Item = &'a Macro;
type IntoIter = std::slice::Iter<'a, Macro>;
fn into_iter(self) -> Self::IntoIter {
self.0.iter()
}
}
impl FromIterator<Macro> for Macros {
fn from_iter<I: IntoIterator<Item = Macro>>(iter: I) -> Macros {
Macros(std::sync::Arc::new(iter.into_iter().collect()))
}
}
/// Refuses a format version newer than [`MACRO_VERSION`]: this build would drop the fields it
/// doesn't know, and a save would then write the rest back as if it were the whole macro.
pub fn check_version(v: u64) -> Result<(), String> {
if v > u64::from(MACRO_VERSION) {
return Err(format!("This macro was made by a newer version of GridCraft (macro format {v}); this version reads format {MACRO_VERSION}."));
}
Ok(())
}
/// Reads the `macros` field of a JSON workbook as raw entries, whatever they hold, so they can be
/// checked one at a time.
pub(crate) fn entries<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<serde_json::Value>, D::Error> {
Ok(match serde_json::Value::deserialize(d)? {
serde_json::Value::Array(list) => list,
serde_json::Value::Null => vec![],
other => vec![other],
})
}
/// A range the macro works on, with the shape and content it expects.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct NamedInput {
/// The token that stands for it in step parameters (`Input`, `Rates`).
pub name: String,
pub kind: InputKind,
/// Allowed data rows (header excluded), `[min, max]`; a max of 0 means no limit.
pub rows: [u32; 2],
/// Allowed columns, `[min, max]`; a max of 0 means no limit.
pub cols: [u32; 2],
/// The first row holds column names.
pub header: bool,
/// What each column must hold, first column first; columns past the list may hold anything.
pub col_types: Vec<ColType>,
/// Every data cell must have something in it.
pub non_empty: bool,
/// The range used when a run doesn't name one (else the current selection, for the first input).
#[serde(skip_serializing_if = "Option::is_none")]
pub default_ref: Option<String>,
}
impl Default for NamedInput {
fn default() -> Self {
NamedInput {
name: "Input".into(),
kind: InputKind::Range,
rows: [1, 0],
cols: [1, 0],
header: false,
col_types: vec![],
non_empty: false,
default_ref: None,
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum InputKind {
/// A single cell.
Cell,
#[default]
Range,
}
/// What a column of an input must hold. Empty cells pass unless the input is `non_empty`.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ColType {
Number,
Text,
/// A number shown with a date or time format.
Date,
Bool,
#[default]
Any,
}
impl ColType {
/// The plural noun used in messages ("must hold numbers").
pub fn noun(self) -> &'static str {
match self {
ColType::Number => "numbers",
ColType::Text => "text",
ColType::Date => "dates",
ColType::Bool => "TRUE or FALSE",
ColType::Any => "anything",
}
}
}
/// One macro step and its editor flags.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Step {
#[serde(flatten)]
pub kind: StepKind,
/// A disabled step is skipped.
#[serde(default = "yes", skip_serializing_if = "is_true")]
pub enabled: bool,
/// The debugger stops before this step.
#[serde(default, skip_serializing_if = "is_false")]
pub breakpoint: bool,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub note: String,
}
fn yes() -> bool {
true
}
fn is_true(b: &bool) -> bool {
*b
}
fn is_false(b: &bool) -> bool {
!*b
}
impl Step {
pub fn new(kind: StepKind) -> Step {
Step { kind, enabled: true, breakpoint: false, note: String::new() }
}
/// A step that runs command `id` with `params`.
pub fn command(id: impl Into<String>, params: serde_json::Value) -> Step {
Step::new(StepKind::Command { id: id.into(), params })
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum StepKind {
/// Runs a GridCraft command. String parameters may hold tokens, resolved when the step runs.
Command {
id: String,
#[serde(default)]
params: serde_json::Value,
},
/// Runs `body` once for each row (or column) of the range `over`, a token or reference; inside
/// it, `Row` (or `Column`) stands for the current one. At most `max` times.
Repeat {
over: String,
#[serde(default)]
of: RepeatOf,
#[serde(default = "repeat_max")]
max: u32,
#[serde(default)]
body: Vec<Step>,
},
/// Compares the value of `cell` (a token or reference to one cell) with `value` and runs
/// `then` or `else`.
If {
cell: String,
op: CompareOp,
#[serde(default)]
value: serde_json::Value,
#[serde(default)]
then: Vec<Step>,
#[serde(default, rename = "else")]
otherwise: Vec<Step>,
},
/// Ends the run with a message. The changes made so far are kept, or undone with `rollback`.
Stop {
#[serde(default)]
message: String,
#[serde(default, skip_serializing_if = "is_false")]
rollback: bool,
},
}
fn repeat_max() -> u32 {
10_000
}
impl StepKind {
/// The step lists inside this step (Repeat's body, If's branches).
pub fn children(&self) -> impl Iterator<Item = &Vec<Step>> {
let (a, b) = match self {
StepKind::Repeat { body, .. } => (Some(body), None),
StepKind::If { then, otherwise, .. } => (Some(then), Some(otherwise)),
_ => (None, None),
};
a.into_iter().chain(b)
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum RepeatOf {
#[default]
Rows,
Columns,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum CompareOp {
Eq,
Ne,
Gt,
Ge,
Lt,
Le,
/// The cell is empty (`value` is ignored).
Empty,
NotEmpty,
/// The cell's text contains `value`'s text, ignoring case.
Contains,
}
/// Where a macro's results go.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct OutputRule {
/// What the `Output` token stands for: a token (`Input.offset(0,4)`), a reference or `Name:X`.
#[serde(skip_serializing_if = "Option::is_none")]
pub anchor: Option<String>,
/// What to do when the cells the macro writes, outside its inputs, already hold data.
pub on_conflict: OnConflict,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum OnConflict {
/// Ask first: overwrite, shift the data down or right, or cancel. Programmatic runs fail.
#[default]
Ask,
Fail,
Overwrite,
ShiftDown,
ShiftRight,
}
impl OnConflict {
pub fn parse(s: &str) -> Option<OnConflict> {
Some(match s.to_ascii_lowercase().replace(['_', '-', ' '], "").as_str() {
"ask" => OnConflict::Ask,
"fail" | "cancel" => OnConflict::Fail,
"overwrite" => OnConflict::Overwrite,
"shiftdown" => OnConflict::ShiftDown,
"shiftright" => OnConflict::ShiftRight,
_ => return None,
})
}
pub fn name(self) -> &'static str {
match self {
OnConflict::Ask => "ask",
OnConflict::Fail => "fail",
OnConflict::Overwrite => "overwrite",
OnConflict::ShiftDown => "shiftDown",
OnConflict::ShiftRight => "shiftRight",
}
}
}
+17
View File
@@ -181,6 +181,20 @@ pub struct Workbook {
/// separator back (a `;` file stays a `;` file). `None` = not read from CSV: CSV saves use `,`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub csv_delimiter: Option<u8>,
/// GridCraft macros (typed command lists, not VBA), in the order the macro list shows them.
/// The JSON format keeps them as this field; xlsx keeps them in hidden defined names.
#[serde(default, skip_serializing_if = "crate::macros::Macros::is_empty", skip_deserializing)]
pub macros: crate::macros::Macros,
/// The `macros` entries of a JSON file as read, before the engine checks each one on its own
/// and moves it into `macros`, so one damaged or newer macro doesn't stop the file opening.
/// Never written.
#[serde(rename = "macros", default, skip_serializing, deserialize_with = "crate::macros::entries")]
pub macros_read: Vec<serde_json::Value>,
/// Macros in a newer format than this build reads, as the file held them. They are never read,
/// listed or run; a save writes them back unchanged, so the newer build gets them back whole.
/// Shared between snapshots, as nothing changes them.
#[serde(skip)]
pub macros_unread: Arc<Vec<serde_json::Value>>,
/// Unmodelled file content kept for saving (package root and workbook level). Not in JSON.
#[serde(skip)]
pub preserved: Option<Arc<crate::preserved::Preserved>>,
@@ -278,6 +292,9 @@ impl Workbook {
phonetics: vec![],
vba_project: None,
csv_delimiter: None,
macros: crate::macros::Macros::default(),
macros_read: vec![],
macros_unread: Arc::default(),
preserved: None,
source_dir: None,
source_path: None,
+13 -1
View File
@@ -15,7 +15,7 @@ use gridcraft_core::{CellError, DateSystem, Value};
pub use builtin::{builtin_format, builtin_id};
pub use decimal::format_general_fit;
use parse::{SecKind, Section, Tok};
use parse::{DatePart, SecKind, Section, Tok};
use render::Out;
/// Width (in characters) of General output in a cell.
@@ -193,6 +193,18 @@ impl NumberFormat {
self.num_sections().iter().any(|s| s.kind == SecKind::Date)
}
/// For a date or time format, whether the section that formats `value` shows calendar parts (year, month,
/// day) and whether it shows time parts, as `(date, time)`. `None` for any other format and for
/// elapsed-time codes like `[h]:mm`, whose value is a duration, not a moment.
pub fn date_shape(&self, value: f64) -> Option<(bool, bool)> {
let s = self.select(value).map(|(s, _)| s).filter(|s| s.kind == SecKind::Date)?;
let parts = || s.toks.iter().filter_map(|t| if let Tok::Date(d) = t { Some(*d) } else { None });
if parts().any(|d| matches!(d, DatePart::ElapsedH(_) | DatePart::ElapsedM(_) | DatePart::ElapsedS(_))) {
return None;
}
Some((parts().any(DatePart::is_calendar), parts().any(|d| !d.is_calendar())))
}
pub fn is_percent(&self) -> bool {
self.num_sections().iter().any(|s| s.kind == SecKind::Number && s.percent > 0)
}
+4 -1
View File
@@ -8,7 +8,7 @@ use super::updates_ui;
use super::{
CellRef, HashMap, INTERFACE_SCALE_MAX, INTERFACE_SCALE_MIN, Json, Notice, Perf, RangeRef, Session, SheetApp, UiRequest, UiState, backstage,
clamp_interface_scale, clean_error, control, desktop_theme, dialogs, editor, floating_pictures, formula_bar, formula_locale, grid, i18n, json,
keytips, panes, pivot_pane, printing, ribbon, tabs, text_box, theme, widgets,
keytips, macro_ui, panes, pivot_pane, printing, ribbon, tabs, text_box, theme, widgets,
};
mod clipboard;
@@ -72,6 +72,7 @@ impl SheetApp {
clipboard_text_at_copy: None,
cut_text: None,
tab_run: None,
macros: macro_ui::MacroUi::default(),
started: now_ms(),
allow_close: false,
open_switch_windows: false,
@@ -235,6 +236,8 @@ impl SheetApp {
});
}
dialogs::show(self, &ctx);
macro_ui::forget_closed_editor(self);
macro_ui::question_box(self, &ctx);
widgets::message_box(self, &ctx);
widgets::toast(self, &ctx);
// Requests are admitted in `logic`, for the root viewport, so only its pass issues them.
+17
View File
@@ -55,6 +55,15 @@ impl SheetApp {
// Requests the command made (a dialog, a draft) belong to the window it left active.
self.follow_active_window();
self.after_engine();
if id == "formulas.autoSum"
&& let Some(ed) = self.editor.as_mut()
&& ed.text.starts_with('=')
&& ed.text.ends_with("()")
{
ed.caret = ed.text.chars().count().saturating_sub(1);
ed.anchor = ed.caret;
ed.sync_caret = true;
}
if id == "view.arrangeAll"
&& self.native_windows
&& let Ok(r) = &r
@@ -330,6 +339,14 @@ impl SheetApp {
let page = params.get("page").and_then(Json::as_str).and_then(backstage::Page::parse).unwrap_or(backstage::Page::Home);
self.open_backstage(page);
}
// The macro step editor stays until it closes itself (its draft, overlays and debug
// run go with it), so another dialog can't replace it.
_ if self.dialog.as_ref().is_some_and(|d| d.name == "macroEditor") => {
self.message = Some((
"Macro".into(),
"Close the macro step editor first: it keeps its window until it closes itself, so its steps and debug run are not lost.".into(),
));
}
other => self.dialog = dialogs::Dialog::open(self, other, params),
}
}
+7
View File
@@ -12,6 +12,13 @@ impl SheetApp {
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
return;
}
// The macro step editor asks about its own unsaved steps and debug run first; once it has
// closed, the window close goes on.
if self.dialog.as_ref().is_some_and(|d| d.name == "macroEditor") {
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
crate::macro_editor::request_close(self, true);
return;
}
if !self.session.documents().iter().any(|d| d.is_dirty()) {
return;
}
+17 -16
View File
@@ -264,6 +264,23 @@ pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workboo
if objects_hidden(wb) {
return painted;
}
// Source outlines stay in cell space beneath every floating object.
if let Some(ch) = sh.charts.iter().find(|ch| app.selected_chart == Some(ch.id))
&& anchor_rect(geo, sh, &ch.anchor).intersects(p.clip_rect())
{
for s in &ch.series {
if let Ok(gridcraft_engine::formula::Expr::Ref(rf)) = gridcraft_engine::formula::parse(&s.values)
&& rf.sheet_name().is_none_or(|n| n.eq_ignore_ascii_case(&sh.name))
{
let rr = geo.range_rect(sh, rf.range());
let color = Tokens::get(p.ctx()).ref_color(0, true);
for pane in geo.range_panes(rf.range()) {
let pp = p.with_clip_rect(pane.intersect(p.clip_rect()));
pp.rect_stroke(rr, 0.0, Stroke::new(1.5, color), StrokeKind::Inside);
}
}
}
}
let mut done = |start: egui::layers::ShapeIdx, rect: Rect| painted.push(PaintedObject { shapes: start.0..p.add(egui::Shape::Noop).0, rect });
// The grid paints the active workbook: its pictures are cached under it.
let doc = app.session.active().map_or(0, |d| d.uid);
@@ -365,23 +382,7 @@ pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workboo
if app.selected_chart == Some(ch.id) {
selection_frame(p, r);
}
// The source-data highlight below is in cell space: mirrored with the cells.
done(start, r);
if app.selected_chart == Some(ch.id) {
// Highlight the source data like Excel does.
for s in &ch.series {
if let Ok(gridcraft_engine::formula::Expr::Ref(rf)) = gridcraft_engine::formula::parse(&s.values)
&& rf.sheet_name().is_none_or(|n| n.eq_ignore_ascii_case(&sh.name))
{
let rr = geo.range_rect(sh, rf.range());
let color = Tokens::get(p.ctx()).ref_color(0, true);
for pane in geo.range_panes(rf.range()) {
let pp = p.with_clip_rect(pane.intersect(p.clip_rect()));
pp.rect_stroke(rr, 0.0, Stroke::new(1.5, color), StrokeKind::Inside);
}
}
}
}
}
paint_slicers(app, p, geo, wb, si, sh);
painted
+7
View File
@@ -282,6 +282,13 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) -
None => err("no dialog is open"),
},
"ui.dialog.cancel" => {
// The macro step editor's draft, overlays and debug run go with its own close.
if app.dialog.as_ref().is_some_and(|d| d.name == "macroEditor") {
return match crate::macro_editor::close_now(app) {
Ok(()) => ok(Json::Null),
Err(e) => err(e),
};
}
app.dialog = None;
ok(Json::Null)
}
+35 -18
View File
@@ -22,7 +22,6 @@ mod data;
mod find;
mod format_cells;
mod insert_function;
mod macros;
mod names;
mod paste;
mod show;
@@ -73,6 +72,8 @@ pub struct Dialog {
focus_initialized: bool,
/// The formula box that takes references clicked or dragged on the grid (New Formatting Rule).
pick: Option<RefPick>,
/// The linked cell's initial display text; unchanged text must not replace a number or formula.
original_link_text: Option<String>,
}
/// A formula box in point mode, as in Excel: a click on the grid writes the cell's absolute
@@ -166,6 +167,7 @@ impl Dialog {
self.values.insert(pick.key.into(), json!(ed.text));
}
/// Creates a command-backed form with its initial field values.
fn form(name: &str, title: &str, command: &'static str, fields: Vec<Field>, defaults: Json) -> Dialog {
Dialog {
name: name.into(),
@@ -184,10 +186,12 @@ impl Dialog {
back: None,
focus_initialized: false,
pick: None,
original_link_text: None,
}
}
fn custom(name: &str, title: &str, defaults: Json) -> Dialog {
/// Creates a dialog whose body handles its own controls instead of a standard form.
pub(crate) fn custom(name: &str, title: &str, defaults: Json) -> Dialog {
Dialog {
name: name.into(),
title: title.into(),
@@ -205,9 +209,11 @@ impl Dialog {
back: None,
focus_initialized: false,
pick: None,
original_link_text: None,
}
}
/// Opens the named dialog with defaults from the active workbook, including a cell's stored hyperlink.
pub fn open(app: &mut SheetApp, name: &str, params: Json) -> Option<Dialog> {
let sel = app.session.active().map(|d| d.selection.current().a1()).unwrap_or_default();
let active = app.session.active().map(|d| d.selection.active.a1()).unwrap_or_default();
@@ -495,17 +501,30 @@ impl Dialog {
json!({"url": url}),
)
}
"insertLink" => Dialog::form(
"insertLink",
"Insert Hyperlink",
"insert.link",
vec![
Text { key: "text", label: "Display:" },
Text { key: "target", label: "Link to (web address, e-mail or Sheet!A1):" },
Text { key: "tooltip", label: "ScreenTip:" },
],
json!({"target": "https://"}),
),
"insertLink" => {
let mut d = Dialog::form(
"insertLink",
"Insert Hyperlink",
"insert.link",
vec![
Text { key: "text", label: "Display:" },
Text { key: "target", label: "Link to (web address, e-mail or Sheet!A1):" },
Text { key: "tooltip", label: "ScreenTip:" },
],
json!({"target": "https://"}),
);
if let Some(doc) = app.session.active()
&& let Some(sheet) = doc.wb.active()
&& let Some(link) = sheet.hyperlinks.get(&doc.selection.active)
{
let text = gridcraft_engine::display::cell_text(&doc.wb, sheet, doc.selection.active);
d.values.insert("target".into(), json!(link.target));
d.values.insert("text".into(), json!(text));
d.values.insert("tooltip".into(), json!(link.tooltip));
d.original_link_text = Some(text);
}
d
}
"sparkline" => Dialog::form(
"sparkline",
"Create Sparklines",
@@ -946,11 +965,9 @@ impl Dialog {
)
}
"journal" => Dialog::custom("journal", "Action Journal", json!({})),
"macros" => {
let mut d = Dialog::custom("macros", "Macros", json!({}));
d.result = app.session.run("view.macros", json!({})).ok();
d
}
"macros" => crate::macro_ui::open_dialog(app, &params),
"recordMacro" => return crate::macro_ui::open_record(app, &params),
"macroEditor" => return crate::macro_editor::open(app, &params),
"statistics" => {
let mut d = Dialog::custom("statistics", "Workbook Statistics", json!({}));
d.result = app.session.run("review.workbookStatistics", json!({})).ok();
-40
View File
@@ -1,40 +0,0 @@
use super::{Dialog, Json, SheetApp, json};
/// The Macros dialog: lists GridCraft's own recorded scripts and (if the file carries one) the
/// preserved VBA project. Mirrors Excel's Developer ▸ Macros list, but is honest that VBA is
/// preserved and not executed.
pub(super) fn macros(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog) {
ui.label("Automation in this workbook. GridCraft runs its own scripts; a file's VBA project is preserved but not executed.");
ui.add_space(6.0);
let items: Vec<Json> = d.result.as_ref().and_then(Json::as_array).cloned().unwrap_or_default();
if items.is_empty() {
ui.weak("This workbook has no scripts or macros.");
return;
}
// Scripts that came with a file stay disabled until the user trusts the workbook (#2178).
if items.iter().any(|it| it.get("trusted").and_then(Json::as_bool) == Some(false)) {
ui.horizontal(|ui| {
ui.weak("Scripts in this workbook came from a file and are disabled.");
if ui.button("Trust Scripts").on_hover_text("Let this workbook's scripts run for the rest of this session").clicked() {
let _ = app.run("automate.trustScripts", json!({"trusted": true}));
d.result = app.session.run("view.macros", json!({})).ok();
}
});
ui.add_space(6.0);
}
egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| {
for it in &items {
let name = it.get("name").and_then(Json::as_str).unwrap_or("(unnamed)");
let is_vba = it.get("kind").and_then(Json::as_str) == Some("vba");
ui.horizontal(|ui| {
ui.label(egui::RichText::new(name).strong());
if is_vba {
ui.weak("— VBA project (preserved, not run)");
} else {
let steps = it.get("steps").and_then(Json::as_u64).unwrap_or(0);
ui.weak(format!("— {steps} step{}", if steps == 1 { "" } else { "s" }));
}
});
}
});
}
+27 -3
View File
@@ -7,7 +7,6 @@ use super::data::{advanced_filter_form, data_validation_form, sort};
use super::find::{find, go_to};
use super::format_cells::format_cells;
use super::insert_function::insert_function;
use super::macros::macros;
use super::names::{name_manager, paste_name};
use super::paste::{paste_special, paste_special_shortcuts};
use super::spelling::spelling;
@@ -16,9 +15,17 @@ use super::{Dialog, Field, Json, Key, Map, RefPick, SheetApp, json, vec2};
/// Builds the command params from form values for dialogs that need translation.
/// The parameters OK sends to the dialog's command: its field values, reshaped per dialog.
/// Unchanged hyperlink display text is omitted so its number or formula stays intact.
pub(crate) fn build_params(d: &Dialog) -> Json {
let mut v = Json::Object(d.values.clone());
match d.name.as_str() {
"insertLink" => {
if d.original_link_text.as_deref().is_some_and(|text| d.values.get("text").and_then(Json::as_str) == Some(text))
&& let Some(m) = v.as_object_mut()
{
m.remove("text");
}
}
"advancedFilter" => {
if let Some(m) = v.as_object_mut() {
m.remove("action");
@@ -107,6 +114,21 @@ pub(crate) fn build_params(d: &Dialog) -> Json {
pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
let Some(mut d) = app.dialog.take() else { return };
let home = app.ui_window();
// The macro step editor is a large resizable window of its own.
if d.name == "macroEditor" {
let mut open = true;
crate::macro_editor::window(app, ctx, &mut d, &mut open);
if !open {
app.macros.editor = None;
app.grid.macro_overlay = None;
} else if app.ui_window() != home {
// Something run from the editor switched windows: the editor stays in its own.
app.return_dialog(home, d);
} else {
app.dialog = Some(d);
}
return;
}
if d.name == "pasteSpecial" {
paste_special_shortcuts(&mut d, ctx);
}
@@ -116,7 +138,8 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
let width = match d.name.as_str() {
"formatCells" => 560.0,
"about" => 660.0,
"insertFunction" | "commandSearch" | "nameManager" | "manageRules" | "journal" | "agents" | "macros" => 520.0,
"insertFunction" | "commandSearch" | "nameManager" | "manageRules" | "journal" | "agents" => 520.0,
"macros" => 480.0,
"recommendedCharts" => 600.0,
"analysisToolPak" => 420.0,
"solver" => 460.0,
@@ -176,7 +199,8 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
}
});
}
"macros" => macros(app, ui, &mut d),
"macros" => crate::macro_ui::macros_dialog(app, ui, &mut d, &mut open),
"recordMacro" => crate::macro_ui::record_dialog(app, ui, &mut d, &mut open),
"statistics" | "accessibility" | "errorChecking" | "evaluateFormula" | "comments" => {
let r = if d.name == "comments" {
app.session.active().and_then(|doc| doc.wb.active().map(|s| json!(s.comments.iter().map(|(c, m)| json!({"cell": c.a1(), "author": m.author, "text": m.text})).collect::<Vec<_>>())))
+71
View File
@@ -0,0 +1,71 @@
//! List drag and drop shared by the PivotTable Fields pane and the macro step editor: a drag
//! source that carries a payload, where a drop lands among a list's rows, the line that marks it,
//! and the label that follows the pointer.
use egui::{DragAndDrop, Id, Pos2, Rect, Sense, Stroke, Ui, pos2, vec2};
use crate::theme::{self, Tokens};
/// Where a drop at `y` lands among rows laid out top to bottom.
pub fn insert_index(rows: &[Rect], y: f32) -> usize {
rows.iter().filter(|r| r.center().y < y).count()
}
/// Starts dragging `payload` when `r` (a widget sensing drags) begins a drag, and shows the grab
/// cursor over it.
pub fn drag_source<T: std::any::Any + Send + Sync>(ui: &Ui, r: &egui::Response, payload: T) {
if r.drag_started() {
DragAndDrop::set_payload(ui.ctx(), payload);
}
if r.hovered() && !DragAndDrop::has_any_payload(ui.ctx()) {
ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
}
}
/// A drag handle under a widget that only senses clicks (a checkbox): it covers where the widget
/// was last frame (stored under `id`) and is added first, so clicks still reach the widget on top.
pub fn drag_under<T: std::any::Any + Send + Sync>(ui: &mut Ui, id: Id, payload: T) {
let Some(rect) = ui.ctx().data(|d| d.get_temp::<Rect>(id)) else { return };
let r = ui.interact(rect, id, Sense::drag());
drag_source(ui, &r, payload);
}
/// While dragging: outlines `zone` and draws the line where a drop at `y` would land among `rows`.
pub fn drop_marker(ui: &Ui, zone: Rect, rows: &[Rect], y: f32) {
let t = Tokens::get(ui.ctx());
let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Foreground, Id::unique("dnd_drop_marker")));
painter.rect_stroke(zone, 4.0, Stroke::new(2.0, t.accent), egui::StrokeKind::Inside);
let k = insert_index(rows, y);
let y = match (rows.get(k), k.checked_sub(1).and_then(|j| rows.get(j))) {
(Some(r), _) => r.top() - 2.0,
(None, Some(r)) => r.bottom() + 2.0,
(None, None) => zone.top() + 4.0,
};
painter.line_segment([pos2(zone.left() + 4.0, y), pos2(zone.right() - 4.0, y)], Stroke::new(2.0, t.accent));
}
/// The dragged item's label following the pointer.
pub fn ghost(ui: &Ui, text: &str, at: Pos2) {
let t = Tokens::get(ui.ctx());
let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, Id::unique("dnd_ghost")));
let galley = painter.layout_no_wrap(text.to_string(), theme::ui_font(12.0), t.text);
let rect = Rect::from_min_size(at + vec2(12.0, 8.0), galley.size() + vec2(12.0, 6.0));
painter.rect_filled(rect, 3.0, t.input_bg);
painter.rect_stroke(rect, 3.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside);
painter.galley(rect.min + vec2(6.0, 3.0), galley, t.text);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_drop_lands_before_the_first_row_below_it() {
let rows: Vec<Rect> = (0..3).map(|i| Rect::from_min_size(pos2(0.0, i as f32 * 20.0), vec2(100.0, 20.0))).collect();
assert_eq!(insert_index(&rows, -5.0), 0);
assert_eq!(insert_index(&rows, 15.0), 1);
assert_eq!(insert_index(&rows, 45.0), 2);
assert_eq!(insert_index(&rows, 500.0), 3);
assert_eq!(insert_index(&[], 10.0), 0);
}
}
+24 -5
View File
@@ -518,9 +518,23 @@ fn editor_arrow_keys(
ed: &mut crate::editor::EditState,
mut action: Option<EditorAction>,
) -> (Option<EditorAction>, bool) {
let (up, down, left, right, shift) = ui.input(|i| {
(i.key_pressed(Key::ArrowUp), i.key_pressed(Key::ArrowDown), i.key_pressed(Key::ArrowLeft), i.key_pressed(Key::ArrowRight), i.modifiers.shift)
// The modifiers come with the key event: an arrow pressed and released within one frame (a
// control client's or an automation tool's keystroke) has already lost them from `i.modifiers`.
let arrow = ui.input(|i| {
i.events.iter().find_map(|e| match e {
egui::Event::Key { key: key @ (Key::ArrowUp | Key::ArrowDown | Key::ArrowLeft | Key::ArrowRight), pressed: true, modifiers, .. } => {
Some((*key, *modifiers))
}
_ => None,
})
});
let (up, down, left, right) = (
arrow.is_some_and(|(k, _)| k == Key::ArrowUp),
arrow.is_some_and(|(k, _)| k == Key::ArrowDown),
arrow.is_some_and(|(k, _)| k == Key::ArrowLeft),
arrow.is_some_and(|(k, _)| k == Key::ArrowRight),
);
let (shift, jump) = arrow.map_or((false, false), |(_, m)| (m.shift, m.command));
if (up || down) && !ed.autocomplete.is_empty() {
ui.input_mut(|i| {
i.consume_key(egui::Modifiers::NONE, Key::ArrowUp);
@@ -545,13 +559,18 @@ fn editor_arrow_keys(
let rtl = app.session.active().and_then(|d| d.wb.active()).is_some_and(|s| s.right_to_left);
let dc = if rtl { -dc } else { dc };
if ed.can_point() {
// Ctrl+arrow (⌘arrow) jumps the pointed cell to the edge of its data, Ctrl+Shift+arrow
// extends the pointed range there, as the same keys do on the selection.
ui.input_mut(|i| {
for key in [Key::ArrowUp, Key::ArrowDown, Key::ArrowLeft, Key::ArrowRight] {
i.consume_key(egui::Modifiers::NONE, key);
i.consume_key(egui::Modifiers::SHIFT, key);
for modifiers in
[egui::Modifiers::NONE, egui::Modifiers::SHIFT, egui::Modifiers::COMMAND, egui::Modifiers::COMMAND | egui::Modifiers::SHIFT]
{
i.consume_key(modifiers, key);
}
}
});
crate::grid::point_move(app, ed, dr, dc, shift);
crate::grid::point_move(app, ed, dr, dc, shift, jump);
(action, false)
} else {
action = Some((dr, dc, false, false));
+2
View File
@@ -248,6 +248,8 @@ pub struct GridState {
pub wheel_zoom: Option<WheelZoom>,
/// A one-finger touch drag in progress: it pans the sheet instead of selecting.
pub touch_pan: bool,
/// What the macro step editor shows on the grid (Check, debugging).
pub macro_overlay: Option<crate::macro_editor::MacroOverlay>,
}
/// Ctrl+wheel zoom for one (doc uid, sheet). egui eases a notch in over several frames and
+5 -3
View File
@@ -1014,11 +1014,13 @@ pub fn follow_link(app: &mut SheetApp, target: &str) {
}
}
/// Used by `EditState` users to keep the active cell visible while pointing.
pub fn point_move(app: &mut SheetApp, ed: &mut EditState, dr: i64, dc: i64, extend: bool) {
/// Moves the pointed cell one step, or with `jump` (Ctrl+arrow) to the edge of its data region;
/// `extend` (Shift) moves the far end of the pointed range instead. Keeps the pointed cell visible.
pub fn point_move(app: &mut SheetApp, ed: &mut EditState, dr: i64, dc: i64, extend: bool, jump: bool) {
let Some(d) = app.session.active() else { return };
let Some(sheet) = d.wb.active() else { return };
let from = ed.point_cell.unwrap_or(ed.cell);
let to = from.offset_clamped(dr, dc);
let to = if jump { gridcraft_engine::selection::jump(sheet, from, dr.signum(), dc.signum()) } else { from.offset_clamped(dr, dc) };
let anchor = if extend {
// The anchor is the corner opposite the moving end: the written range is normalized, so
// after extending up or left its first cell is the moving end, not the anchor.
+4 -1
View File
@@ -14,7 +14,7 @@ pub(super) fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Res
let other_focus = ctx.memory(|m| m.focused()).is_some_and(|f| f != resp.id && (editing || !stale.contains(&f)));
// An open filter menu owns the keyboard even when a click inside it left focus on nothing (#1516).
// So do the fill menu (its Esc closes it, not the copy) and the right-drag that opens it (an
// arrow key would move the selection the menu fills from).
// arrow key would move the selection the menu fills from), and a macro's question.
if editing
|| app.dialog.is_some()
|| other_focus
@@ -22,6 +22,7 @@ pub(super) fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Res
|| app.grid.filter_menu.is_some()
|| app.grid.fill_menu.is_some()
|| matches!(app.grid.drag, Drag::Fill { menu: true, .. })
|| app.macros.question.is_some()
{
return;
}
@@ -143,6 +144,8 @@ pub(super) fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Res
}
Key::F9 => app.run_or_alert("formulas.calculateNow", json!({})),
Key::F4 if !cmd => {}
// Ctrl+Shift+letter (⌘⇧letter) runs the macro with that shortcut, unless GridCraft uses it.
_ if cmd && shift && !m.alt && crate::macro_ui::run_shortcut(app, key) => {}
_ if cmd && !crate::keymap::altgr_text(ctx, m, ctx.os() == egui::os::OperatingSystem::Mac) => {
crate::ribbon::shortcut(app, key, m)
}
+37 -3
View File
@@ -5,8 +5,8 @@ use super::cell_paint::{
use super::geo::Geo;
use super::tables::{filter_button_rect, paint_filter_button};
use super::{
CellRef, CfCache, CfLook, Color32, FontId, HAlign, MAX_COLS, Painter, Pos2, RangeRef, Rect, Sheet, Stroke, StrokeKind, Tokens, Underline, Value,
Workbook, pos2, theme, vec2,
CellRef, CfCache, CfLook, Color32, FontId, HAlign, MAX_COLS, Painter, Pos2, RangeRef, Rect, Sheet, Stroke, StrokeKind, Tokens, Underline, VAlign,
Value, Workbook, pos2, theme, vec2,
};
/// Mirror what the grid painted into `layer` (the shape indices in `range`) for a right-to-left
@@ -68,6 +68,30 @@ fn mirror_shape(shape: &mut egui::Shape, axis: f32) {
}
}
/// Restrict top-aligned wrapped text to complete lines inside the cell.
///
/// Preserve the cell and viewport clip when every line fits. Otherwise,
/// exclude overflowing lines while retaining the visible fitting lines.
fn wrapped_text_clip(p: &Painter, rect: Rect, galley: &egui::Galley, y: f32, valign: VAlign) -> Rect {
let mut clip = rect.intersect(p.clip_rect());
if valign != VAlign::Top {
return clip;
}
let Some(last_row) = galley.rows.last() else {
return clip;
};
if y + last_row.rect().bottom() <= rect.bottom() + 0.01 {
return clip;
}
let last_full_bottom = galley.rows.iter().map(|row| y + row.rect().bottom()).rfind(|bottom| *bottom <= rect.bottom() + 0.01);
clip.max.y = last_full_bottom.map(|bottom| clip.bottom().min(bottom)).unwrap_or(clip.top()).max(clip.top());
clip
}
pub(crate) struct QuadrantPaint<'a> {
pub(crate) painter: &'a Painter,
pub(crate) geo: &'a Geo,
@@ -80,6 +104,11 @@ pub(crate) struct QuadrantPaint<'a> {
pub(crate) tokens: &'a Tokens,
}
/// Paint a worksheet quadrant, including backgrounds, borders, and text.
///
/// For top-aligned wrapped text, painting is limited to complete lines
/// fitting within the cell height while respecting viewport clipping.
/// Other vertical alignment modes retain their existing behavior.
pub(crate) fn paint_quadrant(mut quadrant: QuadrantPaint<'_>) {
let (r0, r1) = quadrant.rows;
let (c0, c1) = quadrant.cols;
@@ -288,6 +317,10 @@ fn should_expand_text(filter_space: f32, overflows: bool, across_end: u32, col:
filter_space == 0.0 && (overflows || across_end > col) && !in_merge
}
/// Paint one worksheet cell using its computed appearance and visible geometry.
///
/// Handles text layout, wrapping, alignment, overflow into neighbouring cells,
/// and clipping, while preserving cell decoration and merged-cell constraints.
fn paint_cell(q: &QuadrantPaint<'_>, painted: &(CellRef, Rect, Look, Option<CfLook>), merges: &[RangeRef]) {
let (cell, rect, lk, cfl) = painted;
let c = *cell;
@@ -363,7 +396,8 @@ fn paint_cell(q: &QuadrantPaint<'_>, painted: &(CellRef, Rect, Look, Option<CfLo
_ => text_left + pad + indent,
};
let y = wrapped_cell_text_y(st.align.v, *rect, galley.size().y, z);
p.with_clip_rect(rect.intersect(p.clip_rect())).galley(pos2(x, y), galley, color);
let text_clip = wrapped_text_clip(p, *rect, &galley, y, st.align.v);
p.with_clip_rect(text_clip).galley(pos2(x, y), galley, color);
return;
}
// Wrap Text off: Excel ignores the line breaks and draws the whole string on one line.
+3
View File
@@ -722,6 +722,9 @@ pub(crate) fn paint_overlays(input: OverlayInput<'_>) {
ui.label(tip);
});
}
if let Some(o) = &app.grid.macro_overlay {
crate::macro_editor::paint_overlay(p, geo, sh, o);
}
// Trace precedents / dependents arrows.
if let Some(trace) = &app.grid.trace
&& app.session.active().is_some_and(|d| d.uid == trace.uid && d.revision == trace.revision)
+9
View File
@@ -557,6 +557,15 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"AutoSave" => "Automatické ukládání",
"AutoSave: save after every change (workbooks saved to a file)" => "Automatické ukládání: ukládat po každé změně (sešity uložené do souboru)",
"All sheets" => "Všechny listy",
"Record Macro" => "Záznam makra",
"Stop Recording" => "Zastavit záznam",
"Use Relative References" => "Použít relativní odkazy",
"Run\nMacro" => "Spustit makro",
"Macros…" => "Makra…",
"Recording" => "Záznam",
"Stop" => "Zastavit",
"New Macro" => "Nové makro",
"Show" => "Zobrazit",
_ => return None,
})
}
+10
View File
@@ -462,6 +462,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"All sheets" => "Alle Blätter",
"New sheet (⇧F11)" => "Neues Blatt (⇧F11)",
"Text Box" => "Textfeld",
"Macros" => "Makros",
"Record Macro" => "Makro aufzeichnen",
"Stop Recording" => "Aufzeichnung beenden",
"Use Relative References" => "Relative Verweise verwenden",
"Run\nMacro" => "Makro ausführen",
"Macros…" => "Makros…",
"Recording" => "Aufzeichnung",
"Stop" => "Beenden",
"New Macro" => "Neues Makro",
"Show" => "Anzeigen",
_ => return None,
})
}
+8
View File
@@ -983,6 +983,14 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"A CSV file, web page or PDF is a copy: the workbook keeps its own file." => {
"Ένα αρχείο CSV, μια ιστοσελίδα ή ένα PDF είναι αντίγραφο: το βιβλίο εργασίας διατηρεί το δικό του αρχείο."
}
"Record Macro" => "Καταγραφή μακροεντολής",
"Stop Recording" => "Διακοπή καταγραφής",
"Use Relative References" => "Χρήση σχετικών αναφορών",
"Run\nMacro" => "Εκτέλεση μακροεντολής",
"Macros…" => "Μακροεντολές…",
"Recording" => "Καταγραφή",
"New Macro" => "Νέα μακροεντολή",
"Show" => "Εμφάνιση",
_ => return None,
})
}
+10
View File
@@ -478,6 +478,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"All sheets" => "Todas las hojas",
"New sheet (⇧F11)" => "Nueva hoja (⇧F11)",
"Text Box" => "Cuadro de texto",
"Macros" => "Macros",
"Record Macro" => "Grabar macro",
"Stop Recording" => "Detener grabación",
"Use Relative References" => "Usar referencias relativas",
"Run\nMacro" => "Ejecutar macro",
"Macros…" => "Macros…",
"Recording" => "Grabando",
"Stop" => "Detener",
"New Macro" => "Nueva macro",
"Show" => "Mostrar",
_ => return None,
})
}
+10
View File
@@ -450,6 +450,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"All sheets" => "Toutes les feuilles",
"New sheet (⇧F11)" => "Nouvelle feuille (⇧F11)",
"Text Box" => "Zone de texte",
"Macros" => "Macros",
"Record Macro" => "Enregistrer une macro",
"Stop Recording" => "Arrêter l’enregistrement",
"Use Relative References" => "Utiliser les références relatives",
"Run\nMacro" => "Exécuter la macro",
"Macros…" => "Macros…",
"Recording" => "Enregistrement",
"Stop" => "Arrêter",
"New Macro" => "Nouvelle macro",
"Show" => "Afficher",
_ => return None,
})
}
+8
View File
@@ -986,6 +986,14 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Report Layout" => "Layout rapporto",
"Compact" => "Compatto",
"Tabular" => "Tabulare",
"Record Macro" => "Registra macro",
"Stop Recording" => "Interrompi registrazione",
"Use Relative References" => "Usa riferimenti relativi",
"Run\nMacro" => "Esegui macro",
"Macros…" => "Macro…",
"Recording" => "Registrazione",
"New Macro" => "Nuova macro",
"Show" => "Mostra",
_ => return None,
})
}
+8
View File
@@ -423,6 +423,14 @@ pub(super) const CORE: &[(&str, &str)] = &[
("Maximum Iterations must be a whole number from 1 to 32767.", "最大反復回数には 1 から 32767 までの整数を指定してください。"),
("Maximum Change must be a number of 0 or more.", "変化の最大値には 0 以上の数値を指定してください。"),
("Calculation settings are saved with the workbook.", "計算の設定はブックと共に保存されます。"),
("Record Macro", "マクロの記録"),
("Stop Recording", "記録終了"),
("Use Relative References", "相対参照で記録"),
("Run\nMacro", "マクロの実行"),
("Macros…", "マクロ…"),
("Recording", "記録中"),
("New Macro", "新しいマクロ"),
("Show", "表示"),
];
/// English label → Japanese for the dialog, message, Backstage and menu strings beyond [`CORE`].
+10
View File
@@ -446,6 +446,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"All sheets" => "모든 시트",
"New sheet (⇧F11)" => "새 시트 (⇧F11)",
"Text Box" => "텍스트 상자",
"Macros" => "매크로",
"Record Macro" => "매크로 기록",
"Stop Recording" => "기록 중지",
"Use Relative References" => "상대 참조로 기록",
"Run\nMacro" => "매크로 실행",
"Macros…" => "매크로…",
"Recording" => "기록 중",
"Stop" => "중지",
"New Macro" => "새 매크로",
"Show" => "표시",
_ => return None,
})
}
+9
View File
@@ -527,6 +527,15 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Zoom In" => "Inzoomen",
"Zoom Out" => "Uitzoomen",
"Dismiss" => "Negeren",
"Record Macro" => "Macro opnemen",
"Stop Recording" => "Opname stoppen",
"Use Relative References" => "Relatieve verwijzingen gebruiken",
"Run\nMacro" => "Macro uitvoeren",
"Macros…" => "Macro's…",
"Recording" => "Opnemen",
"Stop" => "Stoppen",
"New Macro" => "Nieuwe macro",
"Show" => "Weergeven",
_ => return None,
})
}
+8
View File
@@ -977,6 +977,14 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Report Layout" => "Układ raportu",
"Compact" => "Kompaktowy",
"Tabular" => "Tabelaryczny",
"Record Macro" => "Zarejestruj makro",
"Stop Recording" => "Zatrzymaj rejestrowanie",
"Use Relative References" => "Użyj odwołań względnych",
"Run\nMacro" => "Uruchom makro",
"Macros…" => "Makra…",
"Recording" => "Rejestrowanie",
"New Macro" => "Nowe makro",
"Show" => "Pokaż",
_ => return None,
})
}
+8
View File
@@ -982,6 +982,14 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Salvamento automático: salvar após cada alteração (pastas de trabalho salvas em um arquivo)"
}
"All sheets" => "Todas as planilhas",
"Record Macro" => "Gravar Macro",
"Stop Recording" => "Parar Gravação",
"Use Relative References" => "Usar Referências Relativas",
"Run\nMacro" => "Executar Macro",
"Macros…" => "Macros…",
"Recording" => "Gravando",
"New Macro" => "Nova Macro",
"Show" => "Mostrar",
_ => return None,
})
}
+8
View File
@@ -1039,6 +1039,14 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Format Chart Area…" => "Formatare zonă diagramă…",
"Error checking options" => "Opțiuni de verificare a erorilor",
"Paste formulas that refer to the copied cells" => "Lipire formule care fac referire la celulele copiate",
"Record Macro" => "Înregistrare macrocomandă",
"Stop Recording" => "Oprire înregistrare",
"Use Relative References" => "Utilizare referințe relative",
"Run\nMacro" => "Rulare macrocomandă",
"Macros…" => "Macrocomenzi…",
"Recording" => "Înregistrare",
"New Macro" => "Macrocomandă nouă",
"Show" => "Afișare",
_ => return None,
})
}
+10
View File
@@ -448,6 +448,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"All sheets" => "Все листы",
"New sheet (⇧F11)" => "Новый лист (⇧F11)",
"Text Box" => "Надпись",
"Macros" => "Макросы",
"Record Macro" => "Запись макроса",
"Stop Recording" => "Остановить запись",
"Use Relative References" => "Относительные ссылки",
"Run\nMacro" => "Выполнить макрос",
"Macros…" => "Макросы…",
"Recording" => "Запись",
"Stop" => "Остановить",
"New Macro" => "Новый макрос",
"Show" => "Показать",
_ => return None,
})
}
+10
View File
@@ -446,6 +446,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"All sheets" => "Сви листови",
"New sheet (⇧F11)" => "Нови лист (⇧F11)",
"Text Box" => "Текстуално поље",
"Macros" => "Makroi",
"Record Macro" => "Snimi makro",
"Stop Recording" => "Zaustavi snimanje",
"Use Relative References" => "Koristi relativne reference",
"Run\nMacro" => "Pokreni makro",
"Macros…" => "Makroi…",
"Recording" => "Snimanje",
"Stop" => "Zaustavi",
"New Macro" => "Novi makro",
"Show" => "Prikaži",
_ => return None,
})
}
+10
View File
@@ -416,6 +416,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Print" => "Yazdır",
"Print What" => "Yazdırılacaklar",
"Text Box" => "Metin Kutusu",
"Macros" => "Makrolar",
"Record Macro" => "Makro Kaydet",
"Stop Recording" => "Kaydı Durdur",
"Use Relative References" => "Göreli Başvuruları Kullan",
"Run\nMacro" => "Makro Çalıştır",
"Macros…" => "Makrolar…",
"Recording" => "Kaydediliyor",
"Stop" => "Durdur",
"New Macro" => "Yeni Makro",
"Show" => "Göster",
_ => return None,
})
}
+8
View File
@@ -981,6 +981,14 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Report Layout" => "Report Layout",
"Compact" => "Compact",
"Tabular" => "Tabular",
"Record Macro" => "Записати макрос",
"Stop Recording" => "Зупинити запис",
"Use Relative References" => "Використовувати відносні посилання",
"Run\nMacro" => "Запустити макрос",
"Macros…" => "Макроси…",
"Recording" => "Запис",
"New Macro" => "Новий макрос",
"Show" => "Показати",
_ => return None,
})
}
+10
View File
@@ -418,6 +418,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Enable iterative calculation" => "Bật tính toán lặp",
"Calculation settings are saved with the workbook." => "Thiết lập tính toán được lưu cùng sổ làm việc.",
"Text Box" => "Hộp văn bản",
"Macros" => "Macro",
"Record Macro" => "Ghi Macro",
"Stop Recording" => "Dừng Ghi",
"Use Relative References" => "Dùng Tham chiếu Tương đối",
"Run\nMacro" => "Chạy Macro",
"Macros…" => "Macro…",
"Recording" => "Đang ghi",
"Stop" => "Dừng",
"New Macro" => "Macro Mới",
"Show" => "Hiện",
_ => return None,
})
}
+10
View File
@@ -438,6 +438,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"All sheets" => "所有工作表",
"New sheet (⇧F11)" => "新建工作表 (⇧F11)",
"Text Box" => "文本框",
"Macros" => "宏",
"Record Macro" => "录制宏",
"Stop Recording" => "停止录制",
"Use Relative References" => "使用相对引用",
"Run\nMacro" => "运行宏",
"Macros…" => "宏…",
"Recording" => "正在录制",
"Stop" => "停止",
"New Macro" => "新建宏",
"Show" => "显示",
_ => return None,
})
}
+65 -1
View File
@@ -135,6 +135,25 @@ pub enum Icon {
OpenFolder,
Info,
Print,
/// A macro: a script page with a run arrow.
Macro,
/// Record Macro: a ring around the recording dot.
Record,
/// Use Relative References: a cell and an arrow to a cell offset from it.
Relative,
/// A reference box's button: shrink the window to the box (Collapse) or restore it (Expand).
Collapse,
Expand,
/// The macro debugger's Run: a play triangle.
Play,
/// Step: an arrow down onto a line.
Step,
/// Continue: a bar and a play triangle.
Continue,
/// Run to Here: an arrow to a marked line.
RunTo,
/// Stop: a square.
StopSquare,
}
struct G<'a> {
@@ -194,11 +213,12 @@ impl G<'_> {
struct Palette {
paper: Color32,
cat: [Color32; 8],
recording: Color32,
}
impl Palette {
fn of(t: &Tokens) -> Palette {
Palette { paper: t.paper, cat: t.cat }
Palette { paper: t.paper, cat: t.cat, recording: t.recording }
}
fn cat(&self, c: Cat) -> Color32 {
@@ -731,6 +751,50 @@ fn paint_with(p: &Painter, rect: Rect, icon: Icon, c: Color32, pal: &Palette) {
g.line(&[(4.5, 9.0), (4.5, 14.5), (11.5, 14.5), (11.5, 9.0), (4.5, 9.0)], c, 1.1);
g.circle(12.3, 7.6, 0.6, Some(c), None);
}
Icon::Macro => {
g.rect(2.5, 1.5, 9.0, 12.0, Some(paper), Some(c));
for y in [4.5, 7.0, 9.5] {
g.stroke(&[(4.5, y), (9.5, y)], light);
}
g.poly(&[(9.5, 8.5), (15.0, 11.75), (9.5, 15.0)], green);
}
Icon::Record => {
g.circle(8.0, 8.0, 6.0, None, Some(c));
g.circle(8.0, 8.0, 3.5, Some(pal.recording), None);
}
Icon::Relative => {
g.rect(1.5, 1.5, 5.5, 5.0, Some(paper), Some(c));
g.rect(9.0, 9.5, 5.5, 5.0, None, Some(c));
g.line(&[(5.0, 7.5), (5.0, 12.0), (8.0, 12.0)], c, 1.1);
g.poly(&[(7.5, 10.2), (9.6, 12.0), (7.5, 13.8)], c);
}
Icon::Collapse | Icon::Expand => {
// A small sheet with a selected block and an arrow pointing up (collapse) or down.
g.rect(2.0, 6.5, 12.0, 7.5, None, Some(c));
g.rect(4.0, 9.0, 4.0, 3.0, Some(c), None);
if icon == Icon::Collapse {
g.poly(&[(8.0, 1.0), (11.5, 4.5), (4.5, 4.5)], c);
} else {
g.poly(&[(8.0, 5.5), (11.5, 2.0), (4.5, 2.0)], c);
}
}
Icon::Play => g.poly(&[(4.0, 2.5), (13.0, 8.0), (4.0, 13.5)], green),
Icon::Step => {
g.line(&[(8.0, 1.5), (8.0, 9.0)], c, 1.3);
g.poly(&[(4.8, 7.0), (11.2, 7.0), (8.0, 10.8)], c);
g.line(&[(3.0, 13.5), (13.0, 13.5)], c, 1.3);
}
Icon::Continue => {
g.rect(2.5, 2.5, 2.2, 11.0, Some(c), None);
g.poly(&[(6.5, 2.5), (14.0, 8.0), (6.5, 13.5)], green);
}
Icon::RunTo => {
g.line(&[(2.0, 5.0), (9.0, 5.0)], c, 1.3);
g.poly(&[(8.0, 2.0), (12.0, 5.0), (8.0, 8.0)], c);
g.line(&[(2.0, 11.5), (14.0, 11.5)], c, 1.6);
g.line(&[(13.0, 3.0), (13.0, 9.0)], c, 1.1);
}
Icon::StopSquare => g.rect(3.0, 3.0, 10.0, 10.0, Some(pal.recording), None),
}
}
@@ -342,8 +342,8 @@ pub const SEQUENCES: &[(&str, Entry)] = &[
("WJ", Entry::Finish(Action::Control("view.zoom"))), // Excel: Zoom To 100%; runs the 100% zoom handler (percent param)
("WL", Entry::Finish(Action::Command("view.normal"))), // Excel: Normal View; containment(Normal)
("WM", Entry::Continue), // Excel: Macros Menu; group level: containment(Macros)
("WMR", Entry::Finish(Action::Command("view.macros"))), // Excel: Record Macro; Record Macro (macros menu)
("WMV", Entry::Finish(Action::Command("view.macros"))), // Excel: View Macros; containment(Macros)
("WMR", Entry::Finish(Action::Dialog("recordMacro"))), // Excel: Record Macro; Record Macro dialog (stops recording while recording)
("WMV", Entry::Finish(Action::Dialog("macros"))), // Excel: View Macros; containment(Macros): the Macros dialog
("WN", Entry::Finish(Action::Command("view.newWindow"))), // Excel: New Window
("WP", Entry::Finish(Action::Command("view.pageLayout"))), // Excel: Page Layout View
("WQ", Entry::Finish(Action::Dialog("zoom"))), // Excel: Zoom; opens the Zoom dialog
+6 -6
View File
@@ -2370,17 +2370,17 @@
},
{
"seq": "WMR",
"kind": "command",
"target": "view.macros",
"kind": "dialog",
"target": "recordMacro",
"excel": "Record Macro",
"note": "Record Macro (macros menu)"
"note": "Record Macro dialog (stops recording while recording)"
},
{
"seq": "WMV",
"kind": "command",
"target": "view.macros",
"kind": "dialog",
"target": "macros",
"excel": "View Macros",
"note": "containment(Macros)"
"note": "containment(Macros): the Macros dialog"
},
{
"seq": "WN",
+14
View File
@@ -6,6 +6,15 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
/// GridCraft as craft-update knows it, with its 128 px icon and accent colour: the update
/// prompt shows both, and an AppImage's menu entry gets the icon.
#[cfg(not(target_arch = "wasm32"))]
pub const UPDATE_APP: craft_update::app::App = craft_update::app::App {
icon_png: Some(include_bytes!("../../../assets/app-icon/hicolor/128x128/apps/ai.storyteller.gridcraft.png")),
accent: Some([0x1f, 0x9d, 0x55]),
..craft_update::suite::GRIDCRAFT
};
pub mod backstage;
mod border_preview;
mod cell_pictures;
@@ -15,6 +24,7 @@ pub mod control;
mod craft_fonts_policy;
pub mod credits;
pub mod dialogs;
pub mod dnd;
#[cfg(not(target_arch = "wasm32"))]
pub mod doc_windows;
pub mod editor;
@@ -27,6 +37,8 @@ pub mod i18n;
pub mod icons;
pub mod keymap;
pub mod keytips;
pub mod macro_editor;
pub mod macro_ui;
pub mod outline;
pub mod panes;
pub mod paper_view;
@@ -266,6 +278,8 @@ pub struct SheetApp {
/// A run of Tabs: (doc uid, sheet, column it started in, cell the last Tab landed on).
/// Enter on that cell goes to the next row in the starting column, as in Excel (#911).
tab_run: Option<(u64, usize, u32, CellRef)>,
/// Automate › Macros: relative recording, recent macros, a macro's question.
pub macros: macro_ui::MacroUi,
pub started: f64,
/// Every unsaved workbook has been answered for quitting (or `app.quit` asked): the next
/// window close goes through.
File diff suppressed because it is too large Load Diff
+677
View File
@@ -0,0 +1,677 @@
//! Automate › Macros in the window: recording (with "Recording · 7 steps" and Stop in the status
//! bar), running a macro and answering its questions (approve it, make room for its output), the
//! Macros dialog (macros, Automate scripts and a file's VBA project), Macro Options, the Record
//! Macro dialog and macro shortcuts (Ctrl+Shift+letter, ⌘⇧letter on a Mac). Everything goes
//! through the `macro.*` and `automate.*` commands.
use egui::{Sense, Ui, vec2};
use serde_json::{Value as Json, json};
use crate::SheetApp;
use crate::dialogs::Dialog;
use crate::theme::{self, Tokens};
/// Shortcut rules live in the engine, so the dialogs and `macro.record` agree.
pub use gridcraft_engine::macros::{RESERVED_SHORTCUTS, parse_shortcut, shortcut_conflict, shortcut_text};
/// How many macros the Run Macro menu keeps as recent.
const RECENT: usize = 8;
/// Macro state that belongs to the window, not the workbook.
#[derive(Clone, Debug)]
pub struct MacroUi {
/// Use Relative References: record steps relative to the selection when recording started.
pub relative: bool,
/// Macros run from this window, most recent first (Run Macro's menu).
pub recent: Vec<String>,
/// A question from a macro command, answered with one of its buttons.
pub question: Option<Question>,
/// The step editor, while it is open.
pub editor: Option<crate::macro_editor::Editor>,
/// A debugger result from a question's button, for the editor to take in.
pub debug_result: Option<Json>,
}
impl Default for MacroUi {
fn default() -> Self {
MacroUi { relative: true, recent: vec![], question: None, editor: None, debug_result: None }
}
}
impl MacroUi {
fn note_recent(&mut self, name: &str) {
self.recent.retain(|n| !n.eq_ignore_ascii_case(name));
self.recent.insert(0, name.to_string());
self.recent.truncate(RECENT);
}
}
/// A question from a command (approve this macro? overwrite the data in the way?). Each choice
/// runs the command again with its parameters added; Cancel drops it.
#[derive(Clone, Debug)]
pub struct Question {
/// The workbook (`DocState::uid`) the question is about: the answer runs only there.
pub doc: u64,
pub title: String,
pub text: String,
pub command: String,
pub params: Json,
pub choices: Vec<(String, Json)>,
}
/// The macros of the active workbook: (name, description, shortcut).
pub fn macro_list(app: &SheetApp) -> Vec<(String, String, Option<String>)> {
app.session
.active()
.map(|d| d.wb.macros.iter().map(|m| (m.name.clone(), m.description.clone(), m.shortcut.clone())).collect())
.unwrap_or_default()
}
/// How many commands the recorder has captured, while recording.
pub fn recorded_steps(app: &SheetApp) -> Option<usize> {
app.session.recorder.as_ref().map(|r| r.steps.len().saturating_add(r.dropped))
}
/// Record Macro / Stop Recording: opens the Record Macro dialog, or stops and saves what was
/// recorded as a macro.
pub fn toggle_recording(app: &mut SheetApp) {
if app.session.is_recording() {
stop_recording(app);
} else {
app.open_dialog("recordMacro", json!({}));
}
}
/// Stops recording and says what was saved.
pub fn stop_recording(app: &mut SheetApp) {
match app.run("macro.record", json!({"stop": true})) {
Ok(v) => match v["saved"].as_str() {
Some(name) => {
let steps = v["steps"].as_u64().unwrap_or(0);
app.macros.note_recent(name);
let mut text = format!("Saved macro “{name}” ({steps} step{})", if steps == 1 { "" } else { "s" });
if let Some(k) = v["shortcutDropped"].as_str() {
text.push_str(&format!(" without {k}: another macro took it while recording"));
}
app.toast = Some((text, crate::now_ms()));
}
None => app.toast = Some(("Recording stopped".into(), crate::now_ms())),
},
Err(e) => app.message = Some(("Record Macro".into(), crate::clean_error(&e))),
}
}
/// Runs macro `name` on the selection; its questions (approve, make room) become a prompt.
pub fn run_macro(app: &mut SheetApp, name: &str) {
call(app, "Run Macro", "macro.run", json!({"name": name}));
}
/// Runs a macro command. Errors are shown; a question (approve it, overwrite or move the data in
/// the way) becomes a prompt whose buttons run it again. `Some` with the result when it ran.
pub fn call(app: &mut SheetApp, title: &str, id: &str, params: Json) -> Option<Json> {
match app.run(id, params.clone()) {
Ok(v) => {
note_result(app, id, &params, &v);
Some(v)
}
Err(e) => {
if !ask(app, title, id, &params, &e) {
app.message = Some((title.into(), crate::clean_error(&e)));
}
None
}
}
}
/// The question in an engine error that asks something: `approve: …` or `confirm: …`.
fn question_text(e: &str, prefix: &str) -> Option<String> {
let rest = e.split_once(prefix)?.1;
Some(rest.split_once(" (run again with").map(|(q, _)| q).unwrap_or(rest).to_string())
}
/// Turns a question from command `id` into a prompt whose buttons run it again: Run for a macro
/// that needs approval; Overwrite, Shift Down or Shift Right for a macro whose output would land
/// on data; OK (`overwrite: true`) for anything else. False when `e` is not a question.
pub fn ask(app: &mut SheetApp, title: &str, id: &str, params: &Json, e: &str) -> bool {
let (text, choices) = if let Some(q) = question_text(e, "approve: ") {
(q, vec![("Run".to_string(), json!({"approve": true}))])
} else if let Some(q) = question_text(e, "confirm: ") {
if id.starts_with("macro.") {
// The engine's hint for programs ("run again with onConflict …") becomes the buttons.
let what = q.split_once(" Overwrite them").map(|(a, _)| a).unwrap_or(&q).trim_end_matches(['.', ' ']);
(
format!("{what}. Overwrite them, or move the data out of the way?"),
vec![
("Overwrite".to_string(), json!({"onConflict": "overwrite"})),
("Shift Down".to_string(), json!({"onConflict": "shiftDown"})),
("Shift Right".to_string(), json!({"onConflict": "shiftRight"})),
],
)
} else {
(q, vec![("OK".to_string(), json!({"overwrite": true}))])
}
} else {
return false;
};
let doc = app.session.active().map_or(0, |d| d.uid);
app.macros.question = Some(Question { doc, title: title.into(), text, command: id.into(), params: params.clone(), choices });
true
}
/// After a macro command ran (directly or from a prompt's button): remember the macro and say
/// what it did.
pub fn note_result(app: &mut SheetApp, id: &str, params: &Json, v: &Json) {
if id == "macro.debug" {
app.macros.debug_result = Some(v.clone());
return;
}
if id != "macro.run" {
return;
}
let name = v["macro"].as_str().or(params["name"].as_str()).unwrap_or_default().to_string();
if name.is_empty() {
return;
}
app.macros.note_recent(&name);
let ran = v["ran"].as_u64().unwrap_or(0);
let text = match v["stopped"].as_str() {
Some(msg) => format!("Macro “{name}” stopped: {msg}"),
None => format!("Ran macro “{name}” ({ran} step{})", if ran == 1 { "" } else { "s" }),
};
app.toast = Some((text, crate::now_ms()));
}
/// Drops the step editor once its workbook is closed. The editor's dialog stays with its
/// workbook's window, so it may not be drawn again to notice.
pub fn forget_closed_editor(app: &mut SheetApp) {
if app.macros.editor.as_ref().is_some_and(|ed| !app.session.documents().iter().any(|d| d.uid == ed.doc)) {
app.macros.editor = None;
app.grid.macro_overlay = None;
}
}
/// The prompt for [`MacroUi::question`]: its text, a button for each choice and Cancel.
pub fn question_box(app: &mut SheetApp, ctx: &egui::Context) {
if app.message.is_some() {
return;
}
let Some(q) = app.macros.question.clone() else { return };
let mut answer: Option<Option<Json>> = None;
egui::Modal::new(egui::Id::unique("gridcraft.macro_question")).show(ctx, |ui| {
ui.set_width(400.0);
ui.label(egui::RichText::new(&q.title).strong());
ui.add(egui::Label::new(&q.text).wrap());
ui.add_space(8.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("Cancel").clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
answer = Some(None);
}
// Right to left: list the choices reversed, so the first one ends up next to Cancel
// and Enter picks it.
for (k, (label, extra)) in q.choices.iter().enumerate().rev() {
if ui.add(egui::Button::new(label.as_str()).min_size(vec2(72.0, 0.0))).clicked()
|| (k == 0 && ui.input(|i| i.key_pressed(egui::Key::Enter)))
{
answer = Some(Some(extra.clone()));
}
}
});
});
match answer {
// Another workbook became active meanwhile (an agent): the answer would run there.
Some(Some(_)) if app.session.active().is_none_or(|d| d.uid != q.doc) => {
app.macros.question = None;
let book = app.session.documents().iter().find(|d| d.uid == q.doc).map(|d| d.display_title()).unwrap_or_default();
app.message =
Some((q.title.clone(), format!("The question was about “{book}”, which is no longer the active workbook, so nothing was done.")));
}
Some(Some(extra)) => {
app.macros.question = None;
let mut params = q.params.clone();
if let (Some(m), Some(extra)) = (params.as_object_mut(), extra.as_object()) {
m.extend(extra.iter().map(|(k, v)| (k.clone(), v.clone())));
}
// A second question (approve, then make room) asks again.
call(app, &q.title, &q.command, params);
}
Some(None) => app.macros.question = None,
None => {}
}
}
/// The status bar while a macro is debugged ("Debugging “Name”", with Show when the editor is
/// closed) and while recording (a red dot, "Recording · 7 steps" and Stop).
pub fn status(app: &mut SheetApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
if let Some(name) = app.session.macro_debug.as_ref().map(|d| d.name.clone()) {
ui.add_space(12.0);
let (r, _) = ui.allocate_exact_size(vec2(10.0, 10.0), Sense::hover());
ui.painter().circle_filled(r.center(), 4.5, t.breakpoint);
ui.label(egui::RichText::new(format!("Debugging “{name}”")).font(theme::ui_font(12.5)).color(t.text));
if app.macros.editor.is_none() {
let show =
ui.add(egui::Button::new(egui::RichText::new(app.ui.language.tr("Show")).font(theme::ui_font(12.0))).min_size(vec2(40.0, 18.0)));
if show.on_hover_text("Open the macro in the step editor").clicked() {
app.open_dialog("macroEditor", json!({"macro": name}));
}
}
}
let Some(n) = recorded_steps(app) else { return };
ui.add_space(12.0);
let (r, _) = ui.allocate_exact_size(vec2(10.0, 10.0), Sense::hover());
ui.painter().circle_filled(r.center(), 4.5, t.recording);
let text = format!("{} · {n} step{}", app.ui.language.tr("Recording"), if n == 1 { "" } else { "s" });
ui.label(egui::RichText::new(text).font(theme::ui_font(12.5)).color(t.text));
let stop = ui.add(egui::Button::new(egui::RichText::new(app.ui.language.tr("Stop")).font(theme::ui_font(12.0))).min_size(vec2(40.0, 18.0)));
if stop.on_hover_text("Stop recording and save the macro").clicked() {
stop_recording(app);
}
}
// ---------------------------------------------------------------- shortcuts
/// The macro whose shortcut is Ctrl+Shift+`key` in the active workbook. GridCraft's own
/// shortcuts win over a macro's (a file can carry any letter).
pub fn shortcut_macro(app: &SheetApp, key: egui::Key) -> Option<String> {
let letter = parse_shortcut(key.name())?;
if RESERVED_SHORTCUTS.iter().any(|(c, _)| *c == letter) {
return None;
}
let wb = &app.session.active()?.wb;
wb.macros.iter().find(|m| m.shortcut.as_deref().and_then(parse_shortcut) == Some(letter)).map(|m| m.name.clone())
}
/// Ctrl+Shift+letter on the grid: runs the macro with that shortcut. True when one ran.
pub fn run_shortcut(app: &mut SheetApp, key: egui::Key) -> bool {
let Some(name) = shortcut_macro(app, key) else { return false };
run_macro(app, &name);
true
}
// ---------------------------------------------------------------- Macros dialog
/// What a row of the Macros dialog is.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Kind {
Macro,
/// An Automate script (`automate.*`).
Script,
}
impl Kind {
fn key(self) -> &'static str {
match self {
Kind::Macro => "macro",
Kind::Script => "script",
}
}
}
/// A row of the Macros dialog.
#[derive(Clone, Debug, PartialEq)]
pub struct Entry {
pub name: String,
pub kind: Kind,
pub description: String,
pub shortcut: Option<String>,
pub steps: u64,
}
/// The Macros dialog's rows: the workbook's macros, then its Automate scripts (from
/// `view.macros`, kept in the dialog's result), and whether the file carries a VBA project.
pub fn entries(app: &SheetApp, d: &Dialog) -> (Vec<Entry>, bool) {
let mut out: Vec<Entry> = app
.session
.active()
.map(|doc| {
doc.wb
.macros
.iter()
.map(|m| Entry {
name: m.name.clone(),
kind: Kind::Macro,
description: m.description.clone(),
shortcut: m.shortcut.clone(),
steps: m.step_count() as u64,
})
.collect()
})
.unwrap_or_default();
let mut vba = false;
for it in d.result.as_ref().and_then(Json::as_array).into_iter().flatten() {
if it.get("kind").and_then(Json::as_str) == Some("vba") {
vba = true;
continue;
}
let Some(name) = it.get("name").and_then(Json::as_str) else { continue };
let steps = it.get("steps").and_then(Json::as_u64).unwrap_or(0);
out.push(Entry { name: name.to_string(), kind: Kind::Script, description: String::new(), shortcut: None, steps });
}
(out, vba)
}
/// The Macros dialog (View › Macros, Automate › Macros, Alt+F8). Its result holds `view.macros`
/// (the Automate scripts and a file's VBA project), as before macros existed.
pub fn open_dialog(app: &mut SheetApp, params: &Json) -> Dialog {
let names = macro_list(app);
let first = app.macros.recent.iter().find(|r| names.iter().any(|(n, ..)| n.eq_ignore_ascii_case(r))).cloned();
let name = params["name"].as_str().map(str::to_string).or(first).or_else(|| names.first().map(|m| m.0.clone())).unwrap_or_default();
let doc = app.session.active().map_or(0, |d| d.uid);
let mut d = Dialog::custom("macros", "Macros", json!({"name": name, "kind": "macro", "doc": doc}));
d.result = app.session.run("view.macros", json!({})).ok();
d
}
/// The Macros dialog's body: the name box and the list on the left, the buttons on the right, the
/// description under them; Options… switches to the shortcut and description of one macro.
pub fn macros_dialog(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, open: &mut bool) {
// Its rows and the Options pane are the workbook's it opened on: another workbook becoming
// active (an agent) closes it rather than run, delete or change a macro of the same name there.
let active = app.session.active().map(|doc| doc.uid);
if d.values.get("doc").and_then(Json::as_u64).is_some_and(|doc| Some(doc) != active) {
*open = false;
return;
}
// Scripts can change while the dialog is open (Delete): read them again when the workbook does.
let rev = app.session.active().map(|doc| doc.revision).unwrap_or(0);
if d.values.get("revision").and_then(Json::as_u64) != Some(rev) {
d.result = app.session.run("view.macros", json!({})).ok();
d.values.insert("revision".into(), json!(rev));
}
if d.values.get("options").and_then(Json::as_bool) == Some(true) {
options(app, ui, d);
return;
}
// Scripts that came with a file stay disabled until the user trusts the workbook (#2178).
let untrusted = d
.result
.as_ref()
.and_then(Json::as_array)
.is_some_and(|items| items.iter().any(|it| it.get("trusted").and_then(Json::as_bool) == Some(false)));
if untrusted {
ui.horizontal(|ui| {
ui.weak("Scripts in this workbook came from a file and are disabled.");
if ui.button("Trust Scripts").on_hover_text("Let this workbook's scripts run for the rest of this session").clicked() {
let _ = app.run("automate.trustScripts", json!({"trusted": true}));
d.result = app.session.run("view.macros", json!({})).ok();
}
});
ui.add_space(6.0);
}
let (list, vba) = entries(app, d);
let mut name = d.values.get("name").and_then(Json::as_str).unwrap_or("").to_string();
let mut kind = if d.values.get("kind").and_then(Json::as_str) == Some("script") { Kind::Script } else { Kind::Macro };
let t = Tokens::get(ui.ctx());
ui.horizontal_top(|ui| {
ui.vertical(|ui| {
ui.set_width(330.0);
let label = ui.label("Macro name:");
if ui.add(egui::TextEdit::singleline(&mut name).desired_width(330.0).id_salt("macro_name")).labelled_by(label.id).changed() {
kind = Kind::Macro;
}
egui::Frame::NONE.fill(t.input_bg).stroke(egui::Stroke::new(1.0, t.input_border)).inner_margin(2.0).show(ui, |ui| {
ui.set_min_size(vec2(326.0, 200.0));
egui::ScrollArea::vertical().max_height(200.0).show(ui, |ui| {
if list.is_empty() {
ui.label(egui::RichText::new("This workbook has no scripts or macros.").italics().color(t.text_dim));
}
for e in &list {
let label = match (e.kind, &e.shortcut) {
(Kind::Macro, Some(k)) => format!("{} {k}", e.name),
(Kind::Macro, None) => e.name.clone(),
(Kind::Script, _) => format!("{} (script, {} step{})", e.name, e.steps, if e.steps == 1 { "" } else { "s" }),
};
let selected = e.kind == kind && e.name.eq_ignore_ascii_case(name.trim());
let r = ui.add(egui::Button::selectable(selected, label).min_size(vec2(322.0, 20.0)));
if r.clicked() {
name = e.name.clone();
kind = e.kind;
}
if r.double_clicked() {
*open = false;
run_entry(app, e);
}
}
});
});
});
ui.vertical(|ui| {
ui.add_space(18.0);
let w = vec2(100.0, 24.0);
let target = name.trim().to_string();
let found = list.iter().find(|e| e.kind == kind && e.name.eq_ignore_ascii_case(&target)).cloned();
let is_macro = found.as_ref().is_some_and(|e| e.kind == Kind::Macro);
if ui.add_enabled(found.is_some(), egui::Button::new("Run").min_size(w)).clicked()
&& let Some(e) = &found
{
*open = false;
run_entry(app, e);
}
if ui
.add_enabled(is_macro, egui::Button::new("Step Into").min_size(w))
.on_hover_text("Open the macro in the step editor and stop before its first step")
.clicked()
{
*open = false;
app.open_dialog("macroEditor", json!({"macro": target, "debug": true}));
}
if ui.add_enabled(is_macro, egui::Button::new("Edit").min_size(w)).clicked() {
*open = false;
app.open_dialog("macroEditor", json!({"macro": target}));
}
let free = !target.is_empty() && !list.iter().any(|e| e.kind == Kind::Macro && e.name.eq_ignore_ascii_case(&target));
if ui.add_enabled(free, egui::Button::new("Create").min_size(w)).on_hover_text("Write a new macro with this name").clicked() {
*open = false;
app.open_dialog("macroEditor", json!({"new": target}));
}
if ui.add_enabled(found.is_some(), egui::Button::new("Delete").min_size(w)).clicked()
&& let Some(e) = &found
{
let (title, command, what) = match e.kind {
Kind::Macro => ("Delete Macro", "macro.delete", "macro"),
Kind::Script => ("Delete Script", "automate.deleteScript", "script"),
};
app.macros.question = Some(Question {
doc: app.session.active().map_or(0, |d| d.uid),
title: title.into(),
text: format!("Do you want to delete {what} “{}”?", e.name),
command: command.into(),
params: json!({"name": e.name}),
choices: vec![("Delete".into(), json!({}))],
});
}
if ui.add_enabled(is_macro, egui::Button::new("Options…").min_size(w)).clicked()
&& let Some(e) = &found
{
let key = e.shortcut.as_deref().and_then(parse_shortcut).map(String::from).unwrap_or_default();
d.values.insert("options".into(), json!(true));
d.values.insert("shortcut".into(), json!(key));
d.values.insert("description".into(), json!(e.description));
}
ui.add_space(12.0);
if ui.add(egui::Button::new("Cancel").min_size(w)).clicked() {
*open = false;
}
});
});
ui.add_space(6.0);
ui.label("Description");
let desc = list.iter().find(|e| e.kind == kind && e.name.eq_ignore_ascii_case(name.trim())).map(|e| e.description.clone()).unwrap_or_default();
ui.label(egui::RichText::new(if desc.is_empty() { " ".to_string() } else { desc }).color(t.text_dim));
if vba {
ui.add_space(6.0);
ui.label(
egui::RichText::new("This workbook also has a VBA project. GridCraft keeps it when you save but does not run it.").color(t.text_dim),
);
}
d.values.insert("name".into(), json!(name));
d.values.insert("kind".into(), json!(kind.key()));
}
/// Runs a row of the Macros dialog: a macro on the selection, or an Automate script.
fn run_entry(app: &mut SheetApp, e: &Entry) {
match e.kind {
Kind::Macro => run_macro(app, &e.name),
Kind::Script => {
call(app, "Run Script", "automate.runScript", json!({"name": e.name}));
}
}
}
/// Macro Options: the shortcut letter and the description of the selected macro.
fn options(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog) {
let name = d.values.get("name").and_then(Json::as_str).unwrap_or("").trim().to_string();
ui.label(egui::RichText::new(format!("Macro name: {name}")).strong());
ui.add_space(4.0);
ui.label("Shortcut key:");
let mut key = d.values.get("shortcut").and_then(Json::as_str).unwrap_or("").to_string();
shortcut_field(ui, &mut key, "macro_shortcut");
let letter = parse_shortcut(&key);
let conflict = match (key.trim().is_empty(), letter, app.session.active()) {
(true, ..) => None,
(false, None, _) => Some("Use a letter from A to Z.".to_string()),
(false, Some(c), Some(doc)) => shortcut_conflict(&doc.wb, &name, c),
(false, Some(_), None) => None,
};
if let Some(c) = &conflict {
ui.label(egui::RichText::new(c).color(Tokens::get(ui.ctx()).danger));
}
ui.add_space(4.0);
let label = ui.label("Description:");
let mut desc = d.values.get("description").and_then(Json::as_str).unwrap_or("").to_string();
ui.add(egui::TextEdit::multiline(&mut desc).desired_rows(3).desired_width(f32::INFINITY).id_salt("macro_description")).labelled_by(label.id);
ui.add_space(6.0);
let mut done = false;
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("Cancel").clicked() {
done = true;
}
if ui.add_enabled(conflict.is_none(), egui::Button::new("OK")).clicked() {
done = save_options(app, &name, letter, &desc);
}
});
d.values.insert("shortcut".into(), json!(key));
d.values.insert("description".into(), json!(desc));
if done {
d.values.remove("options");
}
}
/// The shortcut letter box, after "Ctrl+Shift+" (or "⌘⇧" on a Mac). Keeps one capital letter.
fn shortcut_field(ui: &mut Ui, key: &mut String, id: &str) {
ui.horizontal(|ui| {
let label = ui.label(if ui.ctx().os() == egui::os::OperatingSystem::Mac { "⌘ ⇧ +" } else { "Ctrl+Shift+" });
ui.add(egui::TextEdit::singleline(key).char_limit(1).desired_width(24.0).id_salt(id)).labelled_by(label.id);
});
*key = key.to_ascii_uppercase();
}
/// Saves a macro's shortcut and description. True when saved (otherwise the error is shown).
pub fn save_options(app: &mut SheetApp, name: &str, letter: Option<char>, description: &str) -> bool {
let mut m = match app.run("macro.get", json!({"name": name})) {
Ok(m) if m.is_object() => m,
Ok(_) => return false,
Err(e) => {
app.message = Some(("Macro Options".into(), crate::clean_error(&e)));
return false;
}
};
if let Some(o) = m.as_object_mut() {
match letter {
Some(c) => o.insert("shortcut".into(), json!(shortcut_text(c))),
None => o.remove("shortcut"),
};
o.insert("description".into(), json!(description));
}
// Macro Options changes a macro that exists, so the save replaces it.
match app.run("macro.save", json!({"macro": m, "replace": true})) {
Ok(_) => true,
Err(e) => {
app.message = Some(("Macro Options".into(), crate::clean_error(&e)));
false
}
}
}
// ---------------------------------------------------------------- Record Macro dialog
/// Record Macro: the macro's name, shortcut and description before recording starts. While
/// recording, it stops instead (View › Macros › Record Macro toggles, as in Excel).
pub fn open_record(app: &mut SheetApp, params: &Json) -> Option<Dialog> {
if app.session.is_recording() {
stop_recording(app);
return None;
}
let suggested = app.session.active().map(|d| gridcraft_engine::macros::next_name(&d.wb)).unwrap_or_else(|| "Macro1".into());
let name = params["name"].as_str().map(str::to_string).filter(|n| !n.trim().is_empty()).unwrap_or(suggested);
Some(Dialog::custom("recordMacro", "Record Macro", json!({"name": name, "shortcut": "", "description": ""})))
}
/// What is wrong with the Record Macro dialog's name or shortcut, if anything.
pub fn record_problem(app: &SheetApp, name: &str, key: &str) -> Option<String> {
let doc = app.session.active()?;
if let Err(e) = gridcraft_engine::macros::check_new_name(&doc.wb, name) {
return Some(e);
}
if key.trim().is_empty() {
return None;
}
match parse_shortcut(key) {
None => Some("Use a letter from A to Z for the shortcut.".into()),
Some(c) => shortcut_conflict(&doc.wb, name, c),
}
}
/// The Record Macro dialog's body. OK starts recording; it stays off while the name or the
/// shortcut is not usable, and the reason shows under the fields.
pub fn record_dialog(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, open: &mut bool) {
let get = |d: &Dialog, k: &str| d.values.get(k).and_then(Json::as_str).unwrap_or("").to_string();
let mut name = get(d, "name");
let mut key = get(d, "shortcut");
let mut desc = get(d, "description");
let label = ui.label("Macro name:");
ui.add(egui::TextEdit::singleline(&mut name).desired_width(f32::INFINITY).id_salt("record_name")).labelled_by(label.id);
ui.add_space(4.0);
ui.label("Shortcut key:");
shortcut_field(ui, &mut key, "record_shortcut");
ui.add_space(4.0);
let label = ui.label("Description:");
ui.add(egui::TextEdit::multiline(&mut desc).desired_rows(3).desired_width(f32::INFINITY).id_salt("record_description")).labelled_by(label.id);
ui.checkbox(&mut app.macros.relative, "Use Relative References");
let problem = record_problem(app, &name, &key);
if let Some(p) = &problem {
ui.label(egui::RichText::new(p).color(Tokens::get(ui.ctx()).danger));
}
ui.add_space(6.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("Cancel").clicked() {
*open = false;
}
if ui.add_enabled(problem.is_none(), egui::Button::new("OK")).clicked() {
let mut p = json!({"name": name.trim(), "description": desc, "relative": app.macros.relative});
if !key.trim().is_empty() {
p["shortcut"] = json!(key.trim());
}
match app.run("macro.record", p) {
Ok(_) => *open = false,
Err(e) => app.message = Some(("Record Macro".into(), crate::clean_error(&e))),
}
}
});
d.values.insert("name".into(), json!(name));
d.values.insert("shortcut".into(), json!(key));
d.values.insert("description".into(), json!(desc));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn questions_are_read_from_the_error() {
let e = "approve: Run macro 'M'? It came with a workbook you opened. (run again with `approve: true`)";
assert_eq!(question_text(e, "approve: ").as_deref(), Some("Run macro 'M'? It came with a workbook you opened."));
assert_eq!(question_text("confirm: Replace it? (run again with `overwrite: true`)", "approve: "), None);
assert_eq!(question_text("confirm: Replace it?", "confirm: ").as_deref(), Some("Replace it?"));
}
}
+66 -1
View File
@@ -1,10 +1,75 @@
use super::controls::control_keytip;
use super::controls::{control_keytip, sep};
use super::{Icon, SheetApp, Ui, big_button, json};
use crate::theme::Tokens;
use crate::widgets::{big_split_button, small_button, small_toggle_button};
use egui::vec2;
pub(super) fn automate(app: &mut SheetApp, ui: &mut Ui) {
// Macros: the Macros dialog; Record Macro (Stop Recording while recording), Use Relative
// References; Run Macro, whose main part runs the last macro and whose menu lists them all.
if big_button(ui, Icon::Script, "Macros", "Macros and scripts in this workbook (Alt+F8)", false).clicked() {
app.open_dialog("macros", json!({}));
}
let recording = app.session.is_recording();
ui.vertical(|ui| {
let (label, tip) =
if recording { ("Stop Recording", "Stop recording and save the macro") } else { ("Record Macro", "Record your actions as a macro") };
let r = small_button(ui, Icon::Record, label, tip, false);
control_keytip(app, ui, &r, "macro.record");
if r.clicked() {
crate::macro_ui::toggle_recording(app);
}
let tip = "Record steps relative to the cells selected when recording starts, so the macro works on other data";
let rel = app.macros.relative;
let r = ui.add_enabled_ui(!recording, |ui| small_toggle_button(ui, Icon::Relative, "Use Relative References", tip, rel)).inner;
if r.clicked() && !recording {
app.macros.relative = !rel;
}
if small_button(ui, Icon::Macro, "New Macro", "Write a macro step by step", false).clicked() {
app.open_dialog("macroEditor", json!({}));
}
});
let macros = crate::macro_ui::macro_list(app);
let recent: Vec<String> = app.macros.recent.iter().filter(|r| macros.iter().any(|(n, ..)| n.eq_ignore_ascii_case(r))).cloned().collect();
let (main, arrow) = big_split_button(ui, Icon::Macro, "Run\nMacro", "Run a macro on the selection");
control_keytip(app, ui, &arrow, "macro.run");
if main.clicked() {
// The last macro run, or the only one; otherwise choose in the Macros dialog.
match recent.first().or(macros.first().map(|m| &m.0)) {
Some(name) if macros.len() == 1 || !recent.is_empty() => {
let name = name.clone();
crate::macro_ui::run_macro(app, &name);
}
_ => app.open_dialog("macros", json!({})),
}
}
egui::Popup::menu(&arrow).show(|ui| {
let t = Tokens::get(ui.ctx());
if !recent.is_empty() {
ui.label(egui::RichText::new(app.ui.language.tr("Recent")).small().color(t.text_dim));
for n in &recent {
if ui.add(egui::Button::new(n.as_str()).frame(false).min_size(vec2(200.0, 22.0))).clicked() {
crate::macro_ui::run_macro(app, n);
}
}
ui.separator();
}
if macros.is_empty() {
ui.label(egui::RichText::new("No macros in this workbook").italics().color(t.text_dim));
}
for (n, desc, _) in macros.iter().take(30) {
let r = ui.add(egui::Button::new(n.as_str()).frame(false).min_size(vec2(200.0, 22.0)));
let r = if desc.is_empty() { r } else { r.on_hover_text(desc) };
if r.clicked() {
crate::macro_ui::run_macro(app, n);
}
}
ui.separator();
if ui.add(egui::Button::new(app.ui.language.tr("Macros…")).frame(false).min_size(vec2(200.0, 22.0))).clicked() {
app.open_dialog("macros", json!({}));
}
});
sep(ui);
let __k = big_button(ui, Icon::Script, "Command\nPalette", "Search and run any command", false);
control_keytip(app, ui, &__k, "commandSearch");
if __k.clicked() {

Some files were not shown because too many files have changed in this diff Show More