mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-30 21:56:53 -06:00
Compare commits
305
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f2b7aa2f6 | ||
|
|
dc8078e168 | ||
|
|
75f7056e1b | ||
|
|
d0cc17852e | ||
|
|
d43baa2ceb | ||
|
|
15ea238fb7 | ||
|
|
b2f984a884 | ||
|
|
4afcaacd44 | ||
|
|
885a5dd6bc | ||
|
|
39020af9e9 | ||
|
|
7abaeba33c | ||
|
|
ecd72eccc4 | ||
|
|
ea0da03e12 | ||
|
|
569fb8b4c6 | ||
|
|
dec916081a | ||
|
|
da3e38750a | ||
|
|
3e4a2ccc9a | ||
|
|
27d5930d6b | ||
|
|
412571bc28 | ||
|
|
6f77f68fbc | ||
|
|
c305315610 | ||
|
|
e4d8aae522 | ||
|
|
92256075f2 | ||
|
|
9e2d5ef17f | ||
|
|
559a0369c3 | ||
|
|
1d38a25c8d | ||
|
|
6f067cce8a | ||
|
|
68a4b1a1c3 | ||
|
|
f6bcd77b01 | ||
|
|
400e5acd6c | ||
|
|
7d38697f71 | ||
|
|
39a10b1342 | ||
|
|
57e40d2d14 | ||
|
|
6ba1e19857 | ||
|
|
9c6de8abde | ||
|
|
0688e83752 | ||
|
|
b004335538 | ||
|
|
45cc92ae3c | ||
|
|
94433a3c4f | ||
|
|
2ff751791b | ||
|
|
9e0f14cab9 | ||
|
|
355840a2a2 | ||
|
|
bc1e19f1f6 | ||
|
|
d1f063636a | ||
|
|
6af06e9d30 | ||
|
|
f6b900dd5a | ||
|
|
3fc35e5d3b | ||
|
|
3923ab3dca | ||
|
|
70bcab56e2 | ||
|
|
708481b615 | ||
|
|
a1a491b10c | ||
|
|
7bf8ed7495 | ||
|
|
9e23c98f3a | ||
|
|
f01c02cc41 | ||
|
|
146161b4b4 | ||
|
|
f9b869b3a4 | ||
|
|
c22f46628c | ||
|
|
a81cb0f21e | ||
|
|
ff7ed4b57c | ||
|
|
3b73ac9b91 | ||
|
|
87342cb308 | ||
|
|
0f78a6b126 | ||
|
|
28a8e67e7a | ||
|
|
8b35f2ac12 | ||
|
|
a8e03ef40f | ||
|
|
fa6ccc6ae5 | ||
|
|
a38aed68cb | ||
|
|
7d828c01f7 | ||
|
|
4cc2c44678 | ||
|
|
ea5292c94a | ||
|
|
0edf2b850a | ||
|
|
2b6e088a6d | ||
|
|
acc32227cb | ||
|
|
1fadf98a10 | ||
|
|
f4f48f3b23 | ||
|
|
9595fc8ee7 | ||
|
|
9515c29969 | ||
|
|
8cfe970ff8 | ||
|
|
e40291fc3d | ||
|
|
c801e50e40 | ||
|
|
f8e1f8dfc0 | ||
|
|
4d26e395fc | ||
|
|
d4096eee5a | ||
|
|
34eee86053 | ||
|
|
6781aa4927 | ||
|
|
483276f719 | ||
|
|
3772f2c61a | ||
|
|
1cd45cc909 | ||
|
|
e85297e26c | ||
|
|
b552b7a929 | ||
|
|
910b50c1dd | ||
|
|
848b0e658a | ||
|
|
8fd6ec124b | ||
|
|
299beb5085 | ||
|
|
ffacf7405f | ||
|
|
59d85ca138 | ||
|
|
ea5eb2ae3c | ||
|
|
c442da9221 | ||
|
|
c142c70dbe | ||
|
|
1e2492b4d6 | ||
|
|
60c20095e6 | ||
|
|
a414b608d7 | ||
|
|
a0fd599fe6 | ||
|
|
0fdb4d03e4 | ||
|
|
baf01c4211 | ||
|
|
f73e7b7e19 | ||
|
|
12072c5529 | ||
|
|
a18ecc81aa | ||
|
|
a206178209 | ||
|
|
73b0edd085 | ||
|
|
aa86b0c327 | ||
|
|
cec19d6af6 | ||
|
|
3d6a09ec75 | ||
|
|
b9a8fa304c | ||
|
|
c523a4c48a | ||
|
|
eb7b2b93f4 | ||
|
|
bca9a10f8a | ||
|
|
1443ec417c | ||
|
|
5512c1a269 | ||
|
|
7c66aaa2d3 | ||
|
|
bf6a8520b8 | ||
|
|
65634c2bb7 | ||
|
|
0533480c97 | ||
|
|
b9aa4a6a9c | ||
|
|
ca2be34e8a | ||
|
|
9a22c4b2f2 | ||
|
|
24f0c3df92 | ||
|
|
1a00ce19fb | ||
|
|
0a9305f784 | ||
|
|
0a929eaf68 | ||
|
|
2e8284c191 | ||
|
|
f0b667aa02 | ||
|
|
ec58ec5f4b | ||
|
|
361fff982a | ||
|
|
fe14c0d86e | ||
|
|
891986bf66 | ||
|
|
0aae1ffeea | ||
|
|
bfcf50aad3 | ||
|
|
f225cd1026 | ||
|
|
9fe25835d4 | ||
|
|
f6f4585c93 | ||
|
|
91bb5eded8 | ||
|
|
5aa10132aa | ||
|
|
267d5bc503 | ||
|
|
90a46da496 | ||
|
|
00b22d5e33 | ||
|
|
fdd26876fb | ||
|
|
227078f56a | ||
|
|
dadffdb33f | ||
|
|
e9effe5dbd | ||
|
|
198fe482a1 | ||
|
|
43134b308e | ||
|
|
460a72d3bb | ||
|
|
8b018d89cd | ||
|
|
bc0d3035fb | ||
|
|
f66ebd6fb2 | ||
|
|
7dad14e77e | ||
|
|
d309c85c31 | ||
|
|
60e0039524 | ||
|
|
ce2ab9a473 | ||
|
|
c715c36c07 | ||
|
|
2cf5b808b3 | ||
|
|
3708c80168 | ||
|
|
567ed0abc9 | ||
|
|
65cbcdd6bd | ||
|
|
ad79b071ce | ||
|
|
d053b1f548 | ||
|
|
0719458114 | ||
|
|
5860857937 | ||
|
|
b40fa4e065 | ||
|
|
2fb31f11f2 | ||
|
|
8ea4e27918 | ||
|
|
63a29800a3 | ||
|
|
b995c7c9a0 | ||
|
|
1e34a76428 | ||
|
|
c225caf30a | ||
|
|
9331b05f62 | ||
|
|
9fdf03cfb5 | ||
|
|
3f8e6c2884 | ||
|
|
e88760b480 | ||
|
|
e285194643 | ||
|
|
4c11929fe2 | ||
|
|
da439b71ed | ||
|
|
3905a4318e | ||
|
|
471a40bd72 | ||
|
|
ac36eee23d | ||
|
|
3c722f4d86 | ||
|
|
54bd4eca9d | ||
|
|
6cd4cebfb7 | ||
|
|
f040b4ecaa | ||
|
|
fddf2d9a31 | ||
|
|
95c7e70ce6 | ||
|
|
3b25aa0d16 | ||
|
|
4a294aefa9 | ||
|
|
b09a5502e3 | ||
|
|
b44a5139ab | ||
|
|
f2a7252f93 | ||
|
|
9bbba198c6 | ||
|
|
37bf09e94a | ||
|
|
4668fc30ae | ||
|
|
b90710794a | ||
|
|
0fa6e78863 | ||
|
|
2e72407fc6 | ||
|
|
2b33fcf881 | ||
|
|
359cce4bc3 | ||
|
|
ee8e5d35c0 | ||
|
|
bac5b9c66e | ||
|
|
bb3db53489 | ||
|
|
2215934c9d | ||
|
|
879b96f858 | ||
|
|
a626705c95 | ||
|
|
fb4acb04a2 | ||
|
|
74ff8ac625 | ||
|
|
461395d0a0 | ||
|
|
551eda7e8e | ||
|
|
43b0e39e8e | ||
|
|
bbf36000d4 | ||
|
|
0df540f166 | ||
|
|
44a2f4e2f1 | ||
|
|
00b63468e6 | ||
|
|
673af7ecdc | ||
|
|
4672b6da51 | ||
|
|
712bb766ec | ||
|
|
8219c18b92 | ||
|
|
259b6e67e8 | ||
|
|
7ee9d77483 | ||
|
|
3ca1252571 | ||
|
|
378ce3a5a0 | ||
|
|
cef58da39e | ||
|
|
d7c3c18345 | ||
|
|
8440427c53 | ||
|
|
d1e51a4af4 | ||
|
|
456cf73886 | ||
|
|
a3b5f8e45c | ||
|
|
6b7c48f119 | ||
|
|
66e69cb946 | ||
|
|
7e383f460a | ||
|
|
080b6d993b | ||
|
|
6ab8d97118 | ||
|
|
114088d589 | ||
|
|
5ca886507c | ||
|
|
524826687c | ||
|
|
a8e8bf851e | ||
|
|
18ec913c4e | ||
|
|
602d4450a2 | ||
|
|
67fa9f7b4e | ||
|
|
bca1483e2e | ||
|
|
38ad2dba83 | ||
|
|
3eed81318f | ||
|
|
faff24f3aa | ||
|
|
66f7993ba5 | ||
|
|
bbf1bc441b | ||
|
|
ea691f703c | ||
|
|
3f5f6ea4f1 | ||
|
|
20eb282e75 | ||
|
|
6a004bf610 | ||
|
|
c0079aabe9 | ||
|
|
045cf428f5 | ||
|
|
6312c7cb40 | ||
|
|
8797b7183c | ||
|
|
f553619b33 | ||
|
|
f1a46a2b53 | ||
|
|
0ea5d3ee33 | ||
|
|
3c74dfcbb0 | ||
|
|
efce63f75f | ||
|
|
62cececa63 | ||
|
|
06bbcb504a | ||
|
|
77150be455 | ||
|
|
7d31c5a4ed | ||
|
|
3f9223c85f | ||
|
|
ccea7a8db3 | ||
|
|
b51d588cac | ||
|
|
ac7c1ae639 | ||
|
|
ee7040a340 | ||
|
|
c6dda8562f | ||
|
|
45e580ad91 | ||
|
|
bf456c9ff8 | ||
|
|
7a0f591712 | ||
|
|
709a995290 | ||
|
|
252ffa95b6 | ||
|
|
4c6962a97c | ||
|
|
3bc1d91636 | ||
|
|
4966b9c86d | ||
|
|
749558f978 | ||
|
|
d305566e57 | ||
|
|
7011395fcf | ||
|
|
b2740bdf33 | ||
|
|
23c0107511 | ||
|
|
20d2a3b998 | ||
|
|
54c08645b9 | ||
|
|
a4b4872602 | ||
|
|
471114e296 | ||
|
|
2287f63a95 | ||
|
|
eb3eaa052d | ||
|
|
0457f102a0 | ||
|
|
ab27d35386 | ||
|
|
f4e05534a3 | ||
|
|
83ae63f793 | ||
|
|
e165eaf3a6 | ||
|
|
7d1fee8c78 | ||
|
|
b7bce3bc09 | ||
|
|
d4f1e61c99 | ||
|
|
acfd38f309 | ||
|
|
9d9911dca1 | ||
|
|
d280e6ff00 |
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
+2
-1
@@ -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",
|
||||
|
||||
+2
-1
@@ -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
@@ -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) |
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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.";
|
||||
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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()}))
|
||||
}
|
||||
|
||||
+1503
-13
File diff suppressed because it is too large
Load Diff
@@ -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)));
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})?;
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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
@@ -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('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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
@@ -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(¶ms) });
|
||||
// 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(¶ms), r),
|
||||
_ => cmd::journal_params(¶ms),
|
||||
};
|
||||
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(¶ms));
|
||||
}
|
||||
self.push_journal(id, cmd::journal_params(¶ms));
|
||||
}
|
||||
// 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)
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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(¯o_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;
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(¶ms, None) };
|
||||
let params = if start.relative && origin == Origin::Recorded {
|
||||
relative(¶ms, 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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, ¤t, &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"]);
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -248,11 +248,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()));
|
||||
@@ -509,9 +524,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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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() };
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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, ¶ms),
|
||||
"recordMacro" => return crate::macro_ui::open_record(app, ¶ms),
|
||||
"macroEditor" => return crate::macro_editor::open(app, ¶ms),
|
||||
"statistics" => {
|
||||
let mut d = Dialog::custom("statistics", "Workbook Statistics", json!({}));
|
||||
d.result = app.session.run("review.workbookStatistics", json!({})).ok();
|
||||
|
||||
@@ -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" }));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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<_>>())))
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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`].
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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, ¶ms, &v);
|
||||
Some(v)
|
||||
}
|
||||
Err(e) => {
|
||||
if !ask(app, title, id, ¶ms, &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?"));
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -61,7 +61,8 @@ pub fn shortcut(app: &mut SheetApp, key: Key, m: Modifiers) {
|
||||
}
|
||||
},
|
||||
// Insert works on one area at a time, so several areas still go through the dialog.
|
||||
Key::Plus | Key::Equals if shift => match whole_lines(app).filter(|_| single_area(app)) {
|
||||
// The numpad's + arrives as Plus without Shift; Excel takes Ctrl with it as well.
|
||||
Key::Plus | Key::Equals if shift || key == Key::Plus => match whole_lines(app).filter(|_| single_area(app)) {
|
||||
Some(true) => Some(("home.insertRows", json!({}))),
|
||||
Some(false) => Some(("home.insertColumns", json!({}))),
|
||||
None => {
|
||||
|
||||
@@ -340,6 +340,7 @@ pub fn status_bar(app: &mut SheetApp, ui: &mut Ui) {
|
||||
gridcraft_engine::Mode::Point => "Point",
|
||||
};
|
||||
text(ui, egui::RichText::new(app.ui.language.tr(mode)).font(theme::ui_font(12.5)).color(t.text_dim));
|
||||
crate::macro_ui::status(app, ui);
|
||||
if let Some(d) = app.session.active() {
|
||||
if d.wb.vba_project.is_some() {
|
||||
ui.add_space(12.0);
|
||||
@@ -412,8 +413,20 @@ pub fn status_bar(app: &mut SheetApp, ui: &mut Ui) {
|
||||
}
|
||||
ui.add_space(16.0);
|
||||
// The layout runs right to left, so the last statistic is drawn first.
|
||||
// Clicking a statistic copies its value (without the label) to the clipboard.
|
||||
for p in selection_statistics(app).into_iter().rev() {
|
||||
text(ui, egui::RichText::new(p).font(theme::ui_font(12.5)).color(t.text_dim));
|
||||
let value = p.split_once(": ").map_or(p.as_str(), |(_, v)| v).to_owned();
|
||||
let resp = ui.add(
|
||||
egui::Label::new(egui::RichText::new(p).font(theme::ui_font(12.5)).color(t.text_dim))
|
||||
.selectable(false)
|
||||
.sense(Sense::click()),
|
||||
);
|
||||
let resp = resp.on_hover_cursor(egui::CursorIcon::PointingHand).on_hover_text(app.ui.language.tr("Click to copy"));
|
||||
if resp.clicked() {
|
||||
ui.ctx().copy_text(value);
|
||||
}
|
||||
// The label senses clicks, so it also has to open the statistics menu on right-click.
|
||||
resp.context_menu(|ui| status_bar_menu(app, ui));
|
||||
ui.add_space(10.0);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -104,6 +104,16 @@ pub struct Tokens {
|
||||
pub sheet_control_text: Color32,
|
||||
/// A placeholder fill on the sheet (a picture that has not loaded).
|
||||
pub sheet_track: Color32,
|
||||
/// The macro recording dot (Record Macro, the status bar).
|
||||
pub recording: Color32,
|
||||
/// A breakpoint dot in the macro step editor's gutter.
|
||||
pub breakpoint: Color32,
|
||||
/// Macro overlays on the sheet (Check and debugging): the inputs, the cells the macro writes,
|
||||
/// the cells it would write over, and the current step's target. Sheet colours.
|
||||
pub sheet_macro_input: Color32,
|
||||
pub sheet_macro_footprint: Color32,
|
||||
pub sheet_macro_conflict: Color32,
|
||||
pub sheet_macro_target: Color32,
|
||||
}
|
||||
|
||||
/// The eight category colours, in the order of [`Tokens::cat`].
|
||||
@@ -131,6 +141,12 @@ const fn rgb(hex: u32) -> Color32 {
|
||||
/// theme). The light theme's formula bar is white too, so it uses them as well.
|
||||
const SHEET_REF_COLORS: [Color32; 8] =
|
||||
[rgb(0x2E6FD8), rgb(0xD03C3C), rgb(0x7A4FC9), rgb(0x23803D), rgb(0xB5531A), rgb(0x16798A), rgb(0xB83B8E), rgb(0x77681A)];
|
||||
/// Macro overlays on the sheet, the same in both themes (the sheet stays white).
|
||||
const SHEET_MACRO_INPUT: Color32 = rgb(0x2E6FD8);
|
||||
const SHEET_MACRO_FOOTPRINT: Color32 = rgb(0x23803D);
|
||||
const SHEET_MACRO_CONFLICT: Color32 = rgb(0xC42B1C);
|
||||
const SHEET_MACRO_TARGET: Color32 = rgb(0x6B3FC0);
|
||||
|
||||
/// Code colours for one background; plain text uses the surface's own text colour.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Syntax {
|
||||
@@ -222,6 +238,12 @@ impl Tokens {
|
||||
sheet_control_border: SHEET_CONTROL_BORDER,
|
||||
sheet_control_text: SHEET_CONTROL_TEXT,
|
||||
sheet_track: SHEET_TRACK,
|
||||
recording: pick(dark, rgb(0xC42B1C), rgb(0xF1705F)),
|
||||
breakpoint: pick(dark, rgb(0xD13438), rgb(0xF1707A)),
|
||||
sheet_macro_input: SHEET_MACRO_INPUT,
|
||||
sheet_macro_footprint: SHEET_MACRO_FOOTPRINT,
|
||||
sheet_macro_conflict: SHEET_MACRO_CONFLICT,
|
||||
sheet_macro_target: SHEET_MACRO_TARGET,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -363,6 +385,22 @@ mod contrast_tests {
|
||||
add("selection border on sheet", t.sel_border, t.grid_bg, GLYPH);
|
||||
add("sheet control text", t.sheet_control_text, t.sheet_control_bg, TEXT);
|
||||
add("sheet control border on sheet", t.sheet_control_border, t.grid_bg, GLYPH);
|
||||
add("recording dot on ribbon", t.recording, t.ribbon, GLYPH);
|
||||
add("recording dot on status bar", t.recording, t.status_bar, GLYPH);
|
||||
for (name, c) in [
|
||||
("macro input", t.sheet_macro_input),
|
||||
("macro footprint", t.sheet_macro_footprint),
|
||||
("macro conflict", t.sheet_macro_conflict),
|
||||
("macro target", t.sheet_macro_target),
|
||||
] {
|
||||
add(&format!("{name} outline on sheet"), c, t.grid_bg, GLYPH);
|
||||
}
|
||||
// The macro step editor: breakpoint dots in the gutter, error markers, the current step.
|
||||
add("breakpoint on window", t.breakpoint, t.window, GLYPH);
|
||||
add("breakpoint on current step", t.breakpoint, t.accent_soft, GLYPH);
|
||||
add("text on current step", t.text, t.accent_soft, TEXT);
|
||||
add("current step arrow", t.accent, t.accent_soft, GLYPH);
|
||||
add("error marker on window", t.danger, t.window, GLYPH);
|
||||
v
|
||||
}
|
||||
|
||||
@@ -397,6 +435,10 @@ mod contrast_tests {
|
||||
(l.sheet_control_bg, l.sheet_control_border, l.sheet_control_text, l.sheet_track),
|
||||
(d.sheet_control_bg, d.sheet_control_border, d.sheet_control_text, d.sheet_track)
|
||||
);
|
||||
assert_eq!(
|
||||
(l.sheet_macro_input, l.sheet_macro_footprint, l.sheet_macro_conflict, l.sheet_macro_target),
|
||||
(d.sheet_macro_input, d.sheet_macro_footprint, d.sheet_macro_conflict, d.sheet_macro_target)
|
||||
);
|
||||
for c in Cat::ALL {
|
||||
assert_ne!(l.cat(c), d.cat(c), "{c:?} has a dark-mode value of its own");
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
//! offers the new release: Update and Restart (direct self-update), Download or Later. The flow
|
||||
//! itself is `craft_update::updater::Updater`.
|
||||
|
||||
use craft_update::suite::GRIDCRAFT;
|
||||
use craft_update::updater::{Action, Status, Updater};
|
||||
|
||||
use crate::SheetApp;
|
||||
@@ -19,7 +18,7 @@ pub(crate) struct Runtime {
|
||||
|
||||
impl Default for Runtime {
|
||||
fn default() -> Self {
|
||||
Self { updater: Updater::new(&GRIDCRAFT, VERSION), startup: false, manual: false }
|
||||
Self { updater: Updater::new(&crate::UPDATE_APP, VERSION), startup: false, manual: false }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -109,6 +109,9 @@ pub fn big_button(ui: &mut Ui, icon: Icon, label: &str, tip: &str, dropdown: boo
|
||||
}
|
||||
icons::paint(ui.painter(), Rect::from_center_size(pos2(rect.center().x, rect.top() + 21.0), vec2(30.0, 30.0)), icon, t.text);
|
||||
paint_big_label(ui, rect, &lines, dropdown);
|
||||
let name = label.replace('\n', " ");
|
||||
let enabled = ui.is_enabled();
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, &name));
|
||||
resp.on_hover_text(tip_text(ui, tip))
|
||||
}
|
||||
|
||||
@@ -203,6 +206,32 @@ pub fn small_button(ui: &mut Ui, icon: Icon, label: &str, tip: &str, dropdown: b
|
||||
t.text_dim,
|
||||
);
|
||||
}
|
||||
// The accessible name is the label, or the tooltip for an icon-only button.
|
||||
let name = if label.is_empty() { tr(ui, tip) } else { label };
|
||||
let enabled = ui.is_enabled();
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, name));
|
||||
resp.on_hover_text(tip_text(ui, tip))
|
||||
}
|
||||
|
||||
/// Small labelled toggle button (Use Relative References): drawn pressed while `on`. Its
|
||||
/// accessible role is a checkbox, selected while `on`.
|
||||
pub fn small_toggle_button(ui: &mut Ui, icon: Icon, label: &str, tip: &str, on: bool) -> Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let font = theme::ui_font(12.5);
|
||||
let label = tr(ui, label);
|
||||
let tw = ui.painter().layout_no_wrap(label.to_string(), font.clone(), t.text).size().x + 6.0;
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(24.0 + tw, 23.0), Sense::click());
|
||||
if on || resp.is_pointer_button_down_on() {
|
||||
ui.painter().rect_filled(rect, 4.0, t.pressed);
|
||||
ui.painter().rect_stroke(rect, 4.0, Stroke::new(1.0, t.separator), StrokeKind::Inside);
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(rect, 4.0, t.hover);
|
||||
}
|
||||
let color = if ui.is_enabled() { t.text } else { t.text_disabled };
|
||||
icons::paint(ui.painter(), Rect::from_center_size(pos2(rect.left() + 12.0, rect.center().y), vec2(16.0, 16.0)), icon, color);
|
||||
ui.painter().text(pos2(rect.left() + 24.0, rect.center().y), Align2::LEFT_CENTER, label, font, color);
|
||||
let enabled = ui.is_enabled();
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::CheckBox, enabled, on, label));
|
||||
resp.on_hover_text(tip_text(ui, tip))
|
||||
}
|
||||
|
||||
|
||||
@@ -152,7 +152,8 @@ fn right_clicking_a_chart_opens_its_menu_not_the_cells() {
|
||||
let (mut h, id) = fixture();
|
||||
let inside = corner(&h, id) - vec2(100.0, 80.0);
|
||||
right_click(&mut h, inside, vec2(0.0, 0.0));
|
||||
h.get_by_role_and_label(Role::Button, "Delete").click();
|
||||
// Home › Cells › Delete has the same accessible name; the menu is drawn over the ribbon, last.
|
||||
h.get_all_by_role_and_label(Role::Button, "Delete").last().unwrap().click();
|
||||
h.run_steps(3);
|
||||
assert!(h.state().session.doc().unwrap().wb.active().unwrap().charts.is_empty());
|
||||
assert!(h.state().grid.object_menu.is_none());
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
//! #2861: a selected chart's source outline belongs behind floating objects, in cell space.
|
||||
|
||||
use egui::{Color32, Rect, Shape, pos2, vec2};
|
||||
use gridcraft_engine::core::RangeRef;
|
||||
use gridcraft_engine::model::Anchor;
|
||||
use gridcraft_ui_egui::{SheetApp, theme::Tokens};
|
||||
use serde_json::json;
|
||||
|
||||
type Harness = egui_kittest::Harness<'static, SheetApp>;
|
||||
|
||||
const SOURCE: &str = "B2:B12";
|
||||
const SHAPE_FILL: Color32 = Color32::from_rgb(0x12, 0x34, 0x56);
|
||||
const CELL_FILL: Color32 = Color32::from_rgb(0xEF, 0xDC, 0xBA);
|
||||
|
||||
/// The source range crosses a shape and its selected chart, while its top remains uncovered.
|
||||
fn fixture(rtl: bool, frozen: bool) -> (Harness, u32) {
|
||||
let mut session = crate::support::blank_session();
|
||||
let values: Vec<_> = (1..=11).map(|value| json!([value])).collect();
|
||||
session.execute("range.setValues", json!({"range": "B2", "values": values})).unwrap();
|
||||
session.execute("home.fillColor", json!({"range": SOURCE, "color": "#EFDCBA"})).unwrap();
|
||||
session.execute("selection.set", json!({"range": SOURCE})).unwrap();
|
||||
let chart = session.execute("insert.chart", json!({"type": "column", "at": "B7", "width": 260, "height": 130})).unwrap();
|
||||
let id = chart["chart"].as_u64().unwrap() as u32;
|
||||
session.execute("insert.shape", json!({"kind": "rectangle", "at": "B4", "width": 160, "height": 35, "fill": "123456"})).unwrap();
|
||||
session.execute("selection.set", json!({"cell": "A1"})).unwrap();
|
||||
session.execute("view.rightToLeft", json!({"on": rtl})).unwrap();
|
||||
if frozen {
|
||||
session.execute("view.freezePanes", json!({"cell": "A2"})).unwrap();
|
||||
}
|
||||
let mut h = crate::support::harness(session, 4);
|
||||
h.state_mut().selected_chart = Some(id);
|
||||
if frozen {
|
||||
h.state_mut().view_mut().unwrap().scroll.y = 20.0;
|
||||
}
|
||||
h.run_steps(3);
|
||||
(h, id)
|
||||
}
|
||||
|
||||
/// The exact on-screen source rectangle and its pane clips, including RTL and frozen rows.
|
||||
fn source_geometry(h: &Harness) -> (Rect, Vec<Rect>) {
|
||||
let app = h.state();
|
||||
let sheet = app.session.active().unwrap().wb.active().unwrap();
|
||||
let geo = app.grid.geo.as_ref().unwrap();
|
||||
let range = RangeRef::parse(SOURCE).unwrap();
|
||||
let rect = geo.mirror_rect(geo.range_rect(sheet, range));
|
||||
let clips = geo.range_panes(range).into_iter().map(|pane| geo.mirror_rect(pane.intersect(geo.cells))).collect();
|
||||
(rect, clips)
|
||||
}
|
||||
|
||||
/// Allows only floating-point rounding when RTL translation and reflection reach the same bounds.
|
||||
fn same_rect(a: Rect, b: Rect) -> bool {
|
||||
a.min.distance(b.min) < 0.01 && a.max.distance(b.max) < 0.01
|
||||
}
|
||||
|
||||
/// The last frame's actual source-outline paint commands, with their order and visible clip.
|
||||
fn source_strokes(h: &Harness) -> Vec<(usize, Rect)> {
|
||||
let (source, _) = source_geometry(h);
|
||||
let color = Tokens::get(&h.ctx).ref_color(0, true);
|
||||
h.output()
|
||||
.shapes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, shape)| match &shape.shape {
|
||||
Shape::Rect(rect) if same_rect(rect.rect, source) && rect.stroke.color == color && rect.stroke.width == 1.5 => {
|
||||
Some((index, shape.clip_rect))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Floating objects move as a whole on RTL sheets, so their on-screen bounds remain unflipped.
|
||||
fn object_rect(h: &Harness, anchor: &Anchor) -> Rect {
|
||||
let app = h.state();
|
||||
let sheet = app.session.active().unwrap().wb.active().unwrap();
|
||||
let geo = app.grid.geo.as_ref().unwrap();
|
||||
geo.mirror_rect(Rect::from_min_size(
|
||||
pos2(geo.x(sheet, anchor.cell.col) + anchor.dx * geo.z, geo.y(sheet, anchor.cell.row) + anchor.dy * geo.z),
|
||||
vec2(anchor.width * geo.z, anchor.height * geo.z),
|
||||
))
|
||||
}
|
||||
|
||||
/// Finds an opaque object fill in final paint order, rather than relying on implementation calls.
|
||||
fn fill_index(h: &Harness, bounds: Rect, color: Color32) -> usize {
|
||||
h.output()
|
||||
.shapes
|
||||
.iter()
|
||||
.position(|shape| matches!(&shape.shape, Shape::Rect(rect) if same_rect(rect.rect, bounds) && rect.fill == color))
|
||||
.expect("the overlapping floating object's fill was painted")
|
||||
}
|
||||
|
||||
/// Opaque objects must cover the overlapping source stroke while the uncovered stroke stays visible.
|
||||
#[test]
|
||||
fn chart_source_outlines_stay_below_every_overlapping_object() {
|
||||
for rtl in [false, true] {
|
||||
for frozen in [false, true] {
|
||||
let (h, id) = fixture(rtl, frozen);
|
||||
let sheet = h.state().session.active().unwrap().wb.active().unwrap();
|
||||
let chart = sheet.charts.iter().find(|chart| chart.id == id).unwrap();
|
||||
let chart_bounds = object_rect(&h, &chart.anchor);
|
||||
let shape_bounds = object_rect(&h, &sheet.shapes[0].anchor);
|
||||
let chart_fill = fill_index(&h, chart_bounds, Tokens::get(&h.ctx).grid_bg);
|
||||
let shape_fill = fill_index(&h, shape_bounds, SHAPE_FILL);
|
||||
let (source, expected_clips) = source_geometry(&h);
|
||||
let strokes = source_strokes(&h);
|
||||
assert_eq!(strokes.len(), expected_clips.len(), "source remains painted: rtl={rtl}, frozen={frozen}");
|
||||
for ((_, clip), expected) in strokes.iter().zip(expected_clips) {
|
||||
assert_eq!(*clip, expected, "source clips stay in cell space");
|
||||
}
|
||||
let visible_top = source.top().max(strokes[0].1.top());
|
||||
let x = if rtl { source.left() + 0.75 } else { source.right() - 0.75 };
|
||||
let uncovered = pos2(x, visible_top + 4.0);
|
||||
assert!(source.contains(uncovered) && strokes[0].1.contains(uncovered));
|
||||
assert!(!chart_bounds.contains(uncovered) && !shape_bounds.contains(uncovered), "uncovered source outline remains visible");
|
||||
let background = h
|
||||
.output()
|
||||
.shapes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find_map(|(index, shape)| match &shape.shape {
|
||||
Shape::Rect(rect) if rect.fill == CELL_FILL && rect.rect.contains(uncovered) && shape.clip_rect.contains(uncovered) => {
|
||||
Some(index)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.expect("the uncovered source cell has an opaque fill");
|
||||
assert!(strokes[0].0 > background, "source outline remains above cell backgrounds");
|
||||
for (name, bounds, fill) in [("chart", chart_bounds, chart_fill), ("shape", shape_bounds, shape_fill)] {
|
||||
let covered = pos2(x, bounds.center().y);
|
||||
assert!(source.contains(covered) && bounds.contains(covered), "fixture overlaps the {name}'s source stroke");
|
||||
for (stroke, _) in &strokes {
|
||||
assert!(*stroke < fill, "source outline paints over {name}: rtl={rtl}, frozen={frozen}, stroke={stroke}, fill={fill}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Moving the source pass must preserve deselection, hidden-object and offscreen-chart behavior.
|
||||
#[test]
|
||||
fn unselected_hidden_and_offscreen_charts_do_not_highlight_sources() {
|
||||
for state in ["unselected", "hidden", "offscreen"] {
|
||||
let (mut h, id) = fixture(false, false);
|
||||
assert!(!source_strokes(&h).is_empty(), "selected visible chart highlights its source");
|
||||
match state {
|
||||
"unselected" => h.state_mut().selected_chart = None,
|
||||
"hidden" => {
|
||||
h.state_mut().run("view.showObjects", json!({"on": false})).unwrap();
|
||||
}
|
||||
_ => {
|
||||
h.state_mut().run("object.move", json!({"kind": "chart", "id": id, "at": "B100"})).unwrap();
|
||||
}
|
||||
}
|
||||
h.run_steps(3);
|
||||
assert!(source_strokes(&h).is_empty(), "{state} chart must not highlight its source");
|
||||
}
|
||||
}
|
||||
@@ -140,6 +140,15 @@ fn locale_preserves_text_cells_and_existing_entry_conveniences() {
|
||||
assert_eq!(app.editor.as_ref().unwrap().text, "=SUM(1,2)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_cell_autosum_places_caret_inside_parentheses() {
|
||||
let mut app = app();
|
||||
app.run("formulas.autoSum", json!({"enter": false})).unwrap();
|
||||
let ed = app.editor.as_ref().unwrap();
|
||||
assert_eq!(ed.text, "=SUM()");
|
||||
assert_eq!(ed.caret, ed.text.chars().count() - 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Insert Function and AutoSum use `=SUMA(`, while user-supplied formula text is not translated twice.
|
||||
fn canonical_engine_templates_enter_the_selected_formula_language() {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user