diff --git a/CHANGELOG.md b/CHANGELOG.md
index afae34f..2e7fdfd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,60 @@ All notable changes to this project are documented here. The format follows
- `install.sh`, a one-line installer for macOS and Linux that downloads the latest release binary.
- Agent skill (`skills/sheetgraph/SKILL.md`) and a Claude Code plugin marketplace (`.claude-plugin/`), so agents run sheetgraph on their own when asked about a spreadsheet.
- README: before/after demo image (`assets/demo.png`) and the script that generates its sample workbook.
+- Macros: each VBA procedure's trigger (workbook and sheet events, `Auto_Open`), the sheets and ranges it writes and
+ uses, the procedures it calls, and the formula cells that call it as a function, in REPORT.md and as `macro` nodes
+ with `writes` / `uses` / `calls` edges in graph.json. Apps Script functions are listed with their triggers.
+- `sheetgraph macro
[name]` lists macros or prints one module's or procedure's code; `find` also searches
+ macro code.
+- Sheets' VBA code names are read from xlsx/xlsm (`Sheet.code_name`).
+- File-level settings: document and custom properties, calculation mode and iterative calculation (Excel and
+ Google), and whether the sheet structure is locked, at the top of REPORT.md.
+- Data connections (ODBC, OLE DB, web, text, Power Query) with passwords removed, and Power Query (M) code as
+ `power_query` scripts that `macro` and `find` read.
+- Pictures, shapes, text boxes and form controls over the grid (`Sheet.shapes`), with their text, alt text, the macro
+ a button runs and a control's linked cell; graph.json gets `shape` nodes with `runs` and `writes` edges.
+- Pictures placed in cells and Excel data types (Stocks, Geography) with their fields (`Cell.obj`); Google people and
+ file chips the same way.
+- Text with mixed formatting inside a cell (`Cell.runs`), from Excel and Google (`textFormatRuns`).
+- Locked/unlocked cells and hidden formulas (`Style.unlocked`, `Style.hidden_formula`), listed per sheet as the
+ inputs left editable under protection.
+- Numbers in xlsx/xlsm show as their format displays them (`25%`, `$12.50`, `(1,234)`), not only dates.
+- `tests/fixtures/audit.xlsx` and `tests/audit.rs`: one workbook with every item on the coverage checklist.
+- Very hidden sheets (only macro code can unhide them) are marked as such (`Sheet.very_hidden`).
+- Names local to one sheet keep their scope (`NamedRange.scope`), and a sheet's own definition wins over a
+ workbook-wide name of the same name. Names defined by a formula, LAMBDA functions included, link to what the
+ formula reads, through other names too.
+- Named cell styles ("Input", "Calculation", custom ones) as `Style.cell_style`, and which sides of a cell have a
+ border and its line (`Style.border`: `bottom double`, `box thin`), from Excel and Google.
+- Dropdowns, validation formulas and conditional formats are graph nodes with `choices` / `reads` edges to the ranges
+ they read; REPORT.md lists those that read another sheet, and `trace` shows them both ways.
+- `trace` follows links into other workbooks extracted to the same folder (`'[Other.xlsx]Data'!A1`, IMPORTRANGE),
+ in both directions.
+- Merged cells: `cells` and `trace` say a cell is merged or hidden under a merge and show what the merge displays;
+ REPORT.md folds repeating merges (`12× A:C, 1 row each, rows 8–30`); formulas reading a cell hidden under a merge
+ (always blank) are flagged.
+- Google Sheets slicers (as filters) and Tables.
+- Column widths and row heights in pixels where they differ from the default (`Sheet.col_widths`,
+ `Sheet.row_heights`); a column of width 0 or a row of height 0 counts as hidden. Gridlines turned off
+ (`Sheet.no_gridlines`) show in the sheet's REPORT.md header.
+- Grouped rows and columns with their level and whether they're collapsed (`Sheet.outlines`), from Excel and Google,
+ listed per sheet in REPORT.md.
+- Notes and comment threads carry their authors (`Analyst: Check with finance`, replies after `↳`, resolved threads
+ marked). Google Drive comments, which the Sheets API doesn't return, are read too (`Workbook.comments`) and tied to
+ a cell when exactly one cell shows the text they quote; REPORT.md lists them, `cells` shows them on their cell and
+ `find` searches them.
+
+### Fixed
+
+- A reference across sheets (`=SUM(Jan:Dec!B2)`) linked only the last sheet; it now links every sheet in the span.
+- A reference to another workbook (`'[Other.xlsx]Data'!A1`) also produced a broken `reads` edge in graph.json.
+- An xlsm whose VBA uses compression calamine doesn't expect no longer aborts the extraction; the macros are reported
+ missing and everything else is kept.
+
+### Changed
+
+- `Style.borders` (has any border) is replaced by `Style.border`, which says which sides and their line.
+- REPORT.md counts modules with no code (the empty module Excel gives every sheet) instead of listing each one.
## [0.1.0]
diff --git a/README.md b/README.md
index 4baf46a..b9b08bc 100644
--- a/README.md
+++ b/README.md
@@ -102,8 +102,9 @@ sheetgraph extract ... -o sheetgraph-out --creds service-accou
# drill down (all read workbook.json.gz; no network)
sheetgraph cells sheetgraph-out/ "'Revenue Input'!A1:F20"
-sheetgraph trace sheetgraph-out/ "Monthly!E10" --depth 3 # precedents + dependents
-sheetgraph find sheetgraph-out/ "VAT"
+sheetgraph trace sheetgraph-out/ "Monthly!E10" --depth 3 # precedents + dependents, into other extracted workbooks too
+sheetgraph find sheetgraph-out/ "VAT" # values, formulas, notes, comments, macro code
+sheetgraph macro sheetgraph-out/ [Module1 | RollUp] # macros and what they touch; code with a name
sheetgraph sheet sheetgraph-out/ "Ledger" --full # untruncated section
sheetgraph report sheetgraph-out/ [--full] # re-render without refetching
```
@@ -126,14 +127,18 @@ let graph = sheetgraph::graph::build(&wb, &a); // serde_json::Value
sheetgraph-out/
INDEX.md every workbook, sizes, links between workbooks (IMPORTRANGE, Excel external links)
/REPORT.md the map: read this first
- /graph.json nodes (sheets, blocks, formula groups, charts, pivots, externals) + edges
- /workbook.json.gz every cell (see Known gaps for very large files): value, formula, display text, style, validation, note, link
+ /graph.json nodes (sheets, blocks, formula groups, charts, pivots, dropdowns and rules, names, externals, macros) + edges
+ /workbook.json.gz every cell (see Known gaps for very large files): value, formula, display text, style, validation, note, link; plus column widths, row heights and file comments
/meta.json
```
-REPORT.md contains, per workbook: sheet list with used ranges and sheet→sheet data flow; external links (IMPORTRANGE / IMPORTDATA / … with targets resolved from cells, Excel links to other workbooks, refs to deleted sheets, Connected-Sheets data sources); named ranges; function census incl. **non-builtin functions** (Apps Script custom functions / Named Functions / VBA UDFs), dynamic refs (INDIRECT/OFFSET) and volatile functions; scripts (VBA modules from xlsm; Apps Script on request); developer metadata (left by add-ons/integrations).
+REPORT.md contains, per workbook: sheet list with used ranges and sheet→sheet data flow; external links (IMPORTRANGE / IMPORTDATA / … with targets resolved from cells, Excel links to other workbooks, refs to deleted sheets, Connected-Sheets data sources); dropdowns, validation and conditional formats that read another sheet; named ranges, with the sheet a local name belongs to and which names are LAMBDA functions; function census incl. **non-builtin functions** (Apps Script custom functions / Named Functions / VBA UDFs), dynamic refs (INDIRECT/OFFSET) and volatile functions; scripts (VBA modules from xlsm; Apps Script on request), with each procedure's trigger (`Workbook_Open`, `Worksheet_Change`, `onEdit`), the sheets and ranges it writes and uses, the procedures it calls, and the formula cells that call it as a function; modules with no code (the empty one Excel gives every sheet) are counted, not listed; developer metadata (left by add-ons/integrations).
-Per sheet: blocks (tables with column profiles + samples, or small blocks verbatim), formula copy-groups and families with flags (`[array/spills]`, `[external]`, `[dynamic ref]`, `[#REF!]`, error counts), data validation (dropdowns, checkboxes, range-sourced lists), conditional formats, visual formatting regions, merges, filters/filter views, protected ranges, hidden rows/cols, banding, Excel tables, charts and pivots (with source ranges), notes and comments, hyperlinks.
+REPORT.md also starts with what changes how the whole file behaves: document properties, calculation mode (manual calculation means stored values may be stale; iterative means circular references are intended), a locked sheet structure, and data connections and Power Query code.
+
+graph.json edges: `reads` (formula group → the blocks and groups its references land on), `plots` (chart → source), `summarizes` (pivot → source), `imports` (formula → external workbook or URL), `writes` / `uses` (macro → what it changes or reads; a sheet the macro creates has `"to": null` and `missing_sheet`), `calls` (macro → macro, and formula group → the VBA function it calls), `runs` (button or shape → the macro it runs), `writes` from a form control to its linked cell, `choices` (dropdown → the range its list comes from), and `reads` from validation and conditional-format rules and from names defined by a formula (LAMBDA included) to what they read. A formula reading a name defined by a formula gets `reads` edges to what that formula reads, with `via` naming it; a name local to one sheet is the node `name:Sheet!Name`.
+
+Per sheet: blocks (tables with column profiles + samples, or small blocks verbatim), formula copy-groups and families with flags (`[array/spills]`, `[external]`, `[dynamic ref]`, `[#REF!]`, error counts, reads of a cell hidden under a merge), data validation (dropdowns, checkboxes, range-sourced lists), conditional formats, visual formatting regions (with named cell styles such as "Input" and which sides have a border), merges (repeats folded into patterns), filters/filter views and slicers, protected ranges, hidden rows/cols, banding, Excel tables, charts and pivots (with source ranges), pictures, shapes, text boxes and buttons over the grid (with their text and the macro they run), pictures in cells and data types (Excel Stocks/Geography, Google people and file chips), text with mixed formatting, unlocked input cells and hidden formulas, notes and comments with their authors, hyperlinks, grouped rows and columns (collapsed or not), and whether gridlines are off. Column widths and row heights are kept in workbook.json.gz only; a zero width or height counts as hidden. Google Drive comments (with replies and whether they're resolved) get their own section.
## Giving it to an AI agent
@@ -144,30 +149,37 @@ Point the agent at `INDEX.md`, then the `REPORT.md` it needs, and let it run the
- "typed arithmetic" is hand-entered sums like `=3820+3770`: data entry, not logic.
- `▸ range table …` is a data block, one line per column: type and range, `enum` for small value sets, `fmt` for the number format, `ƒN` when the column is computed.
- "same layout as X" means a sheet is a structural clone of an earlier one with different data; `sheetgraph sheet "" --full` shows it in full.
+- `[reads B8 inside merge A8:C8: blank]` flags a formula pointing at a cell a merge hides: it always reads blank, usually a mistake for the merge's top-left cell.
- `⋯ rows A–B summarized` means a very large sheet's middle rows were summarized per column rather than kept: the column profiles still count every row, but sample rows and `cells` don't show those rows.
- Flags: `[external]` another file, `[dynamic ref]` INDIRECT/OFFSET (graph edges incomplete), `[array/spills]`, `[#REF!]`, `[N errors]`.
+- Under Scripts, `RollUp: writes Summary!A1:B1,B2:B5 · uses Data!E10,… · calls Totals` is one procedure. `,…` means it also reaches cells whose address is built at run time (`Cells(i, 2)`); `(not in file)` is a sheet the macro creates; `ActiveSheet` is whichever sheet is active when it runs. `sheetgraph macro RollUp` prints its code.
## Layout
| module | role |
|---|---|
| `model` | source-agnostic full-fidelity workbook (sparse cells, deduped styles/validations) |
-| `gsheets` | auth (service account JWT / ADC refresh / token), chunked parallel grid fetch with retry, conversion |
+| `gsheets` | auth (service account JWT / ADC refresh / token), chunked parallel grid fetch with retry, conversion, Drive comments |
| `xlsx` | calamine: values, formulas, defined names, merges, VBA; csv |
-| `ooxml` | xlsx/xlsm package parts calamine skips: styles, validation, conditional formats, comments, hyperlinks, frozen/hidden rows and cols, filters, protection, tables, charts, pivots, external workbook links |
+| `ooxml` | xlsx/xlsm package parts calamine skips: styles, validation, conditional formats, comments and their authors, hyperlinks, frozen/hidden rows and cols, column widths, row heights, outline groups, gridlines, filters, protection, tables, charts, pivots, external workbook links, rich text, pictures and data types in cells, shapes and form controls, document properties, calculation settings, connections, Power Query |
+| `numfmt` | Excel number formats applied to numbers (`25%`, `$12.50`) |
| `formula` | reference lexer (quoted and non-English sheet names, open-ended `A2:A`, whole rows/cols, `'[Book.xlsx]Sheet'`), R1C1 keys, function census |
-| `analyze` | copy-groups, families, blocks, cross-sheet edges, externals |
+| `analyze` | copy-groups, families, blocks, cross-sheet edges, externals, names, dropdown and rule refs |
+| `macros` | VBA / Apps Script procedures: triggers, sheets and ranges each writes and uses, calls |
| `report` | REPORT.md |
| `graph` | graph.json |
-| `query` | cells / trace / find |
+| `query` | cells / trace / find / macro |
| `store` | on-disk layout, INDEX.md |
## Known gaps
- **Apps Script / macros / add-ons on Google Sheets**: the Sheets API doesn't expose bound scripts, installed add-ons or triggers, and the Apps Script API doesn't work with service accounts. `--apps-script ` fetches a project with user OAuth (scope `script.projects.readonly`). Custom functions are still detected from formulas.
-- **Local files**: xlsx/xlsm get the same coverage as Google Sheets, plus Excel tables and links to other workbooks (`'[Budget.xlsx]Data'!B2`). Images, shapes, sparklines and slicers aren't read (the report says when a file has them). xls, xlsb and ods get values, formulas, names and merges only.
-- Number-format display text (e.g. `$1,234.50`) is only rendered for dates in local files; the format itself is on every cell.
+- **Local files**: xlsx/xlsm get the same coverage as Google Sheets, plus Excel tables, links to other workbooks (`'[Budget.xlsx]Data'!B2`), shapes and pictures, connections and Power Query. Sparklines, Excel slicers and ActiveX controls aren't read (the report says when a file has them). xls, xlsb and ods get values, formulas, names and merges only.
+- **Comments on Google Sheets**: the Drive API doesn't say which cell a comment is on, only the text that cell showed. A comment is placed on a cell only when exactly one cell shows that text; otherwise REPORT.md lists it with the text it quotes.
+- **Pictures on Google Sheets**: the Sheets API doesn't return pictures placed over the grid or inserted into cells (an `=IMAGE()` formula is kept).
+- Number-format display text covers the common formats (percent, currency, thousands, decimals, scientific, sections); fractions, conditional sections and formats that mix digits with literals (`000-00-0000`) show the plain value. The format itself is on every cell.
- Spill ranges of array formulas are flagged on the formula but the spilled cells look like plain values.
+- **What a macro touches** is read from its code, not by running it: literal addresses, sheets held in variables, `With` blocks, sheets passed to other procedures, code names, string constants and named ranges are followed; an address built at run time counts as the whole sheet, and a sheet picked at run time (from a cell, a loop over all sheets) isn't known. Apps Script code is listed by function without the ranges it touches.
- INDIRECT/OFFSET targets are dynamic, so those edges are missing (flagged in the report).
- **Very large local files**: up to 10 million cells are kept whole (about 0.2–0.4 GB of memory per million). Past that, a big sheet keeps its last 1,000 rows and its formulas, and summarizes the rows in between per column (counts, min and max, most common values; no sums), so memory stays near 2–3 GB however big the file is (a 160 MB xlsx with 44 million cells: 3 GB, 84 s). REPORT.md's column profiles still count every row and say which rows were summarized; `cells` only lists kept rows, and `find` reports text matches in summarized rows by column. The budget is `xlsx::CELL_BUDGET`; `xlsx::read_limited` takes another.
- **Very large Google Sheets** are read whole, without that budget. Google allows up to 20 million cells, so a sheet near the limit can take several GB of memory.
@@ -178,7 +190,7 @@ Point the agent at `INDEX.md`, then the `REPORT.md` it needs, and let it run the
cargo test
```
-`tests/fixtures/features.xlsx` exercises every xlsx feature the reader handles. It is generated by `tests/fixtures/make_features.py` (openpyxl 3.1.5); rerun the script after changing it.
+`tests/fixtures/features.xlsx` exercises every xlsx feature the reader handles. It is generated by `tests/fixtures/make_features.py` (openpyxl 3.1.5); rerun the script after changing it. `tests/fixtures/macros.xlsm` is the VBA fixture, generated the same way by `tests/fixtures/make_macros.py`, which also writes its VBA project from scratch.
See [CONTRIBUTING.md](CONTRIBUTING.md) before opening an issue or pull request, and [SECURITY.md](SECURITY.md) to report a vulnerability.
diff --git a/skills/sheetgraph/SKILL.md b/skills/sheetgraph/SKILL.md
index 20367bd..b94adc0 100644
--- a/skills/sheetgraph/SKILL.md
+++ b/skills/sheetgraph/SKILL.md
@@ -41,14 +41,23 @@ Notation:
- "typed arithmetic" (`=3820+3770`) is hand-entered data, not logic.
- `▸ range table …` is a data block, one line per column: type and range, `enum` for small value sets, `fmt` for the number format, `ƒN` when computed.
- `⋯ rows A–B summarized`: a very large sheet's middle rows are summarized per column, not kept.
-- Flags: `[external]` another file, `[dynamic ref]` INDIRECT/OFFSET (graph edges incomplete), `[array/spills]`, `[#REF!]`, `[N errors]`.
+- Flags: `[external]` another file, `[dynamic ref]` INDIRECT/OFFSET (graph edges incomplete), `[array/spills]`, `[#REF!]`, `[N errors]`, `[reads B8 inside merge A8:C8: blank]` (a formula pointing at a cell a merge hides: it always reads blank, usually a bug).
+- `[very hidden]` sheets can only be unhidden by macro code: someone meant them to stay out of sight. `Name (Calc only)` is a name local to one sheet; `(LAMBDA function)` names are custom functions defined in the workbook.
+- `gridlines off` in a sheet's header means it's laid out as a page or form, not a grid of records. `grouped rows: 5-8 (collapsed)` are detail rows folded under a summary; collapsed ones are hidden on screen.
+- Notes read `Author: text`, with replies after `↳`. `## Comments` lists Google Drive comments; those not tied to a cell name the text the commented cell showed.
+- Formatting lines name cell styles (`style "Input"` marks intended inputs, `"Calculation"` computed cells) and borders (`border bottom double` is usually a totals line).
+- The lines under the title are file settings. `calculation: manual` means stored values may be stale; `iterative` means circular references are intended, not errors.
+- `▣` lines are pictures in cells and data types (stocks, places, people chips); `[image: …]` in a cell is a picture, not text. `✎ mixed formatting` shows runs as `[text]{bold fg#FF0000}`. `unlocked cells` are the inputs a protected sheet leaves editable.
+- Shape lines (`button "Run roll-up" at G9:H10 → runs RollUp`, `text box "…"`) are things drawn over the grid; text boxes often hold instructions.
+- Scripts: one line per macro with its trigger, what it `writes` and `uses`, and what it `calls`. `,…` means more cells whose address is built at run time; `(not in file)` is a sheet the macro creates. Values a macro writes are pasted values in the sheet, not formulas: check here before calling a number hardcoded.
## 4. Drill down for exact values (offline, fast)
```bash
sheetgraph cells sheetgraph-out/ "'Sheet Name'!A1:F20" # exact values, formulas, formats
-sheetgraph trace sheetgraph-out/ "Summary!B4" --depth 3 # precedents and dependents
-sheetgraph find sheetgraph-out/ "VAT" # search text, formulas and names
+sheetgraph trace sheetgraph-out/ "Summary!B4" --depth 3 # precedents and dependents, across extracted workbooks
+sheetgraph find sheetgraph-out/ "VAT" # search text, formulas, names, notes, comments and macro code
+sheetgraph macro sheetgraph-out/ RollUp # a macro's code (no name: every macro and what it touches)
sheetgraph sheet sheetgraph-out/ "Ledger" --full # one sheet's section, untruncated
```
diff --git a/src/analyze.rs b/src/analyze.rs
index 6b59430..8168693 100644
--- a/src/analyze.rs
+++ b/src/analyze.rs
@@ -3,6 +3,7 @@
//! - families: groups whose formulas differ only in refs/numbers
//! - blocks: connected regions of non-empty cells (tables, label clusters)
//! - edges: group → referenced range, resolved to sheets / named ranges / externals
+//! - rules: dropdowns, validation and conditional formats, with the ranges they read
use crate::a1::{parse_ref, Rect};
use crate::formula::{self, call_args, lex, unquote, Lexed};
@@ -21,7 +22,40 @@ pub struct Analysis {
pub func_cells: BTreeMap,
/// References to sheets that do not exist in this workbook.
pub broken_refs: BTreeMap,
- pub named: HashMap, Rect)>,
+ /// Defined names and tables by upper-cased name: workbook-wide and
+ /// sheet-local definitions of the same name side by side.
+ pub names: HashMap>,
+ /// Procedures in the workbook's macro / script modules.
+ pub macros: Vec,
+}
+
+/// What a defined name (or an Excel table) stands for.
+#[derive(Debug, Clone, Default)]
+pub struct NameDef {
+ /// As defined (names match case-insensitively).
+ pub name: String,
+ /// The sheet the name is local to; None when workbook-wide.
+ pub scope: Option,
+ /// The ranges it covers: its reference, or every range its formula reads
+ /// (through other names too). A None sheet is the sheet using the name.
+ pub targets: Vec<(Option, Rect)>,
+ /// Defined by a formula (a constant, an expression or a LAMBDA) rather
+ /// than a reference.
+ pub formula: bool,
+ pub lambda: bool,
+}
+
+impl Analysis {
+ /// The definition `n` has on sheet `si`: the sheet's own, else the workbook's.
+ pub fn name(&self, si: Option, n: &str) -> Option<&NameDef> {
+ let defs = self.names.get(&n.to_ascii_uppercase())?;
+ defs.iter().find(|d| d.scope.is_some() && d.scope == si).or_else(|| defs.iter().find(|d| d.scope.is_none()))
+ }
+
+ /// The single range a plain-reference name covers.
+ pub fn name_range(&self, si: Option, n: &str) -> Option<(Option, Rect)> {
+ self.name(si, n).filter(|d| !d.formula && d.targets.len() == 1).map(|d| d.targets[0])
+ }
}
#[derive(Debug, Clone, Default)]
@@ -39,6 +73,19 @@ pub struct SheetAnalysis {
/// Sheet index this sheet is a structural clone of (same layout, other data).
pub clone_of: Option,
pub clone_similarity: f32,
+ /// Dropdowns, validation and conditional-format rules that read ranges.
+ pub rules: Vec,
+}
+
+/// A dropdown, validation or conditional-format rule and what it reads.
+#[derive(Debug, Clone)]
+pub struct Rule {
+ /// "dropdown" (choices from a range), "validation" (a formula), "cond_format"
+ pub kind: &'static str,
+ pub ranges: Vec,
+ /// The rule as REPORT.md shows it.
+ pub desc: String,
+ pub refs: Vec,
}
#[derive(Debug, Clone)]
@@ -58,6 +105,17 @@ pub struct Group {
pub external: bool,
/// No refs, names or functions: hand-typed arithmetic like `=3000+1577`.
pub constant: bool,
+ /// Cells of the group whose formula reads a cell hidden under a merge
+ /// (blank: a merge keeps only its top-left cell), with the first such read.
+ pub covered: u32,
+ pub covered_eg: Option,
+}
+
+#[derive(Debug, Clone)]
+pub struct CoveredRead {
+ pub sheet: usize,
+ pub cell: (u32, u32),
+ pub merge: Rect,
}
#[derive(Debug, Clone)]
@@ -88,19 +146,6 @@ pub struct External {
}
pub fn analyze(wb: &Workbook) -> Analysis {
- let mut named = HashMap::new();
- for nr in &wb.named_ranges {
- if let Some((sh, rect)) = parse_ref(&nr.range) {
- let si = sh.as_deref().and_then(|s| sheet_index(wb, s));
- named.insert(nr.name.to_ascii_uppercase(), (si, rect));
- }
- }
- // Excel tables resolve like names: `Sales[Amount]` lexes as the name `Sales`.
- for (si, sh) in wb.sheets.iter().enumerate() {
- for t in &sh.tables {
- named.entry(t.name.to_ascii_uppercase()).or_insert((Some(si), t.range));
- }
- }
let mut a = Analysis {
sheets: Vec::new(),
sheet_edges: BTreeMap::new(),
@@ -108,16 +153,82 @@ pub fn analyze(wb: &Workbook) -> Analysis {
custom_funcs: BTreeMap::new(),
func_cells: BTreeMap::new(),
broken_refs: BTreeMap::new(),
- named,
+ names: names(wb),
+ macros: Vec::new(),
};
for (si, sh) in wb.sheets.iter().enumerate() {
let sa = analyze_sheet(wb, si, sh, &mut a);
a.sheets.push(sa);
}
find_clones(wb, &mut a);
+ a.macros = crate::macros::analyze(wb, &a);
a
}
+/// Every defined name and table. A name defined by a formula covers what the
+/// formula reads, other names included (resolved a few levels deep).
+fn names(wb: &Workbook) -> HashMap> {
+ let mut out: HashMap> = HashMap::new();
+ // (key, index in out[key], names its formula uses) for the second pass.
+ let mut pending: Vec<(String, usize, Vec)> = Vec::new();
+ for nr in &wb.named_ranges {
+ let scope = nr.scope.as_deref().and_then(|s| sheet_index(wb, s));
+ let key = nr.name.to_ascii_uppercase();
+ let mut d = NameDef { name: nr.name.clone(), scope, ..Default::default() };
+ let mut uses = Vec::new();
+ match parse_ref(&nr.range) {
+ Some((sh, rect)) => d.targets.push((sh.as_deref().and_then(|s| sheet_index(wb, s)).or(scope), rect)),
+ None => {
+ let lx = lex(&nr.range);
+ d.formula = true;
+ d.lambda = lx.funcs.iter().any(|f| f.name == "LAMBDA");
+ for t in &lx.refs {
+ let Some(r) = t.rect() else { continue };
+ match &t.sheet {
+ None => d.targets.push((scope, r)),
+ Some(n) => d.targets.extend(sheet_span(wb, n).into_iter().map(|s| (Some(s), r))),
+ }
+ }
+ uses = lx.names.iter().chain(lx.funcs.iter().map(|f| &f.name)).map(|n| n.to_ascii_uppercase()).filter(|n| *n != key).collect();
+ }
+ }
+ let defs = out.entry(key.clone()).or_default();
+ if !uses.is_empty() {
+ pending.push((key, defs.len(), uses));
+ }
+ defs.push(d);
+ }
+ // Excel tables resolve like names: `Sales[Amount]` lexes as the name `Sales`.
+ for (si, sh) in wb.sheets.iter().enumerate() {
+ for t in &sh.tables {
+ out.entry(t.name.to_ascii_uppercase()).or_insert_with(|| vec![NameDef { name: t.name.clone(), targets: vec![(Some(si), t.range)], ..Default::default() }]);
+ }
+ }
+ for _ in 0..3 {
+ let mut changed = false;
+ for (key, i, uses) in &pending {
+ let scope = out[key][*i].scope;
+ let mut add = Vec::new();
+ for u in uses {
+ let Some(defs) = out.get(u) else { continue };
+ let d = defs.iter().find(|d| d.scope.is_some() && d.scope == scope).or_else(|| defs.iter().find(|d| d.scope.is_none()));
+ add.extend(d.into_iter().flat_map(|d| d.targets.iter().copied()));
+ }
+ let d = &mut out.get_mut(key).unwrap()[*i];
+ for t in add {
+ if !d.targets.contains(&t) {
+ d.targets.push(t);
+ changed = true;
+ }
+ }
+ }
+ if !changed {
+ break;
+ }
+ }
+ out
+}
+
/// Structural fingerprint: formula families (copied ≥2 cells, ignoring typed
/// arithmetic) plus the text labels in the top rows / first columns.
pub fn fingerprint(sh: &Sheet, sa: &SheetAnalysis) -> std::collections::HashSet {
@@ -169,6 +280,18 @@ pub fn sheet_index(wb: &Workbook, name: &str) -> Option {
})
}
+/// Sheets a reference's sheet part covers: the one sheet, or every sheet from
+/// first to last (in tab order) for a 3D ref like `Jan:Dec`. Empty if unknown.
+pub fn sheet_span(wb: &Workbook, name: &str) -> Vec {
+ if let Some(i) = sheet_index(wb, name) {
+ return vec![i];
+ }
+ match name.split_once(':').map(|(a, b)| (sheet_index(wb, a), sheet_index(wb, b))) {
+ Some((Some(x), Some(y))) => (x.min(y)..=x.max(y)).collect(),
+ _ => Vec::new(),
+ }
+}
+
/// Index of the cell at (r, c) in a sheet's sorted cell list.
pub fn find_cell(sh: &Sheet, r: u32, c: u32) -> Option<&Cell> {
sh.cells.binary_search_by_key(&(r, c), |x| (x.r, x.c)).ok().map(|i| &sh.cells[i])
@@ -283,6 +406,8 @@ fn analyze_sheet(wb: &Workbook, si: usize, sh: &Sheet, a: &mut Analysis) -> Shee
spill: false,
external: false,
constant: lx.refs.is_empty() && lx.names.is_empty() && lx.funcs.is_empty(),
+ covered: 0,
+ covered_eg: None,
};
for fu in &g.funcs {
g.dynamic |= formula::DYNAMIC_FUNCS.contains(&fu.as_str());
@@ -293,6 +418,7 @@ fn analyze_sheet(wb: &Workbook, si: usize, sh: &Sheet, a: &mut Analysis) -> Shee
g.external |= !lx.books.is_empty();
g.errors = cells_in(sh, &rect).filter(|c| matches!(c.v, Value::Err { .. })).count() as u32;
g.refs = resolve_refs(wb, si, &lx, &rect, a);
+ covered_reads(wb, si, &lx, &mut g);
let mut targets: Vec = g.refs.iter().filter_map(|r| r.sheet).filter(|t| *t != si).collect();
targets.sort_unstable();
targets.dedup();
@@ -317,9 +443,87 @@ fn analyze_sheet(wb: &Workbook, si: usize, sh: &Sheet, a: &mut Analysis) -> Shee
}
sa.blocks = find_blocks(sh);
+ sa.rules = rules(wb, si, sh, sa.used, a);
sa
}
+/// A merge shows its top-left cell over the whole range; every other cell in
+/// it is blank. Count the group's cells whose single-cell refs land on one.
+fn covered_reads(wb: &Workbook, si: usize, lx: &Lexed, g: &mut Group) {
+ let rect = g.rect;
+ let (dr, dc) = (rect.r1 as i64 - rect.r0 as i64, rect.c1 as i64 - rect.c0 as i64);
+ let mut hit = vec![false; (rect.area() as usize).min(1 << 16)];
+ for t in &lx.refs {
+ if t.rect_at(0, 0).is_none_or(|r| r.area() != 1) {
+ continue;
+ }
+ let sheets = match &t.sheet {
+ None => vec![si],
+ Some(n) => sheet_span(wb, n),
+ };
+ for ts in sheets {
+ let merges = &wb.sheets[ts].merges;
+ let (Some(a0), Some(a1)) = (t.rect_at(0, 0), t.rect_at(dr, dc)) else { continue };
+ let span = a0.union(&a1);
+ let near: Vec<&Rect> = merges.iter().filter(|m| m.area() > 1 && m.intersects(&span)).collect();
+ if near.is_empty() {
+ continue;
+ }
+ let cols = rect.cols() as usize;
+ for (i, h) in hit.iter_mut().enumerate() {
+ let Some(x) = t.rect_at((i / cols) as i64, (i % cols) as i64) else { continue };
+ if let Some(m) = near.iter().find(|m| m.contains(x.r0, x.c0) && (x.r0, x.c0) != (m.r0, m.c0)) {
+ *h = true;
+ g.covered_eg.get_or_insert(CoveredRead { sheet: ts, cell: (x.r0, x.c0), merge: **m });
+ }
+ }
+ }
+ }
+ g.covered = hit.iter().filter(|h| **h).count() as u32;
+}
+
+/// Dropdowns, validation formulas and conditional formats that read ranges.
+fn rules(wb: &Workbook, si: usize, sh: &Sheet, used: Option, a: &Analysis) -> Vec {
+ let mut out = Vec::new();
+ // Whole-column rules: only as far as the sheet has cells.
+ let clamp = |r: Rect| match used {
+ Some(u) => Rect { r1: r.r1.min(u.r1.max(r.r0)), c1: r.c1.min(u.c1.max(r.c0)), ..r },
+ None => r,
+ };
+ let dv_pts: Vec<(u32, u32, u32)> = sh.cells.iter().filter_map(|c| c.dv.map(|d| (d, c.r, c.c))).collect();
+ let mut by: BTreeMap> = BTreeMap::new();
+ for (d, r) in cover(dv_pts) {
+ by.entry(d).or_default().push(r);
+ }
+ for (d, ranges) in by {
+ let v = &wb.validations[d as usize];
+ let (kind, f) = match (v.kind.as_str(), v.values.first()) {
+ ("ONE_OF_RANGE", Some(f)) => ("dropdown", f),
+ (_, Some(f)) if f.starts_with('=') => ("validation", f),
+ _ => continue,
+ };
+ let f = if f.starts_with('=') { f.clone() } else { format!("={f}") };
+ let lx = lex(&f);
+ let bbox = ranges.iter().skip(1).fold(ranges[0], |b, r| b.union(r));
+ // A dropdown's list is one fixed range for every cell; a formula shifts.
+ let span = if kind == "dropdown" { Rect::cell(bbox.r0, bbox.c0) } else { bbox };
+ let refs = resolve_refs(wb, si, &lx, &span, a);
+ if !refs.is_empty() {
+ out.push(Rule { kind, desc: f, ranges, refs });
+ }
+ }
+ for cf in &sh.cond_formats {
+ let Some(first) = cf.ranges.first() else { continue };
+ let bbox = cf.ranges.iter().skip(1).fold(clamp(*first), |b, r| b.union(&clamp(*r)));
+ let span = Rect { r0: first.r0, c0: first.c0, ..bbox };
+ let refs: Vec = cf.formulas.iter().flat_map(|f| resolve_refs(wb, si, &lex(f), &span, a)).collect();
+ if !refs.is_empty() {
+ out.push(Rule { kind: "cond_format", ranges: cf.ranges.clone(), desc: cf.rule.clone(), refs });
+ }
+ }
+ out
+}
+
fn dedup(mut v: Vec) -> Vec {
let mut seen = std::collections::HashSet::new();
v.retain(|x| seen.insert(x.clone()));
@@ -331,15 +535,25 @@ fn resolve_refs(wb: &Workbook, si: usize, lx: &Lexed, rect: &Rect, a: &Analysis)
let mut out = Vec::new();
for t in &lx.refs {
let (Some(a0), Some(a1)) = (t.rect_at(0, 0), t.rect_at(dr, dc)) else { continue };
- let (sheet, sheet_name) = match &t.sheet {
- None => (Some(si), None),
- Some(n) => (sheet_index(wb, n), Some(n.clone())),
+ let rect = a0.union(&a1);
+ let Some(n) = &t.sheet else {
+ out.push(GRef { sheet: Some(si), sheet_name: None, rect, via_name: None });
+ continue;
};
- out.push(GRef { sheet, sheet_name, rect: a0.union(&a1), via_name: None });
+ let span = sheet_span(wb, n);
+ if span.is_empty() {
+ out.push(GRef { sheet: None, sheet_name: Some(n.clone()), rect, via_name: None });
+ }
+ for s in span {
+ out.push(GRef { sheet: Some(s), sheet_name: Some(n.clone()), rect, via_name: None });
+ }
}
- for n in &lx.names {
- if let Some((sh, r)) = a.named.get(&n.to_ascii_uppercase()) {
- out.push(GRef { sheet: sh.or(Some(si)), sheet_name: None, rect: *r, via_name: Some(n.clone()) });
+ // Names, and functions defined as LAMBDA names (`=AddTax(10)`).
+ let lambdas = lx.funcs.iter().filter(|f| !formula::is_builtin(&f.name)).map(|f| &f.name);
+ for n in lx.names.iter().chain(lambdas) {
+ let Some(d) = a.name(Some(si), n) else { continue };
+ for (sh, r) in &d.targets {
+ out.push(GRef { sheet: sh.or(Some(si)), sheet_name: None, rect: *r, via_name: Some(d.name.clone()) });
}
}
out
diff --git a/src/formula.rs b/src/formula.rs
index 87e4fd5..b53e43d 100644
--- a/src/formula.rs
+++ b/src/formula.rs
@@ -127,6 +127,17 @@ pub fn lex(f: &str) -> Lexed {
i = lex_ref_after_sheet(f, start, i + 1, Some(word.to_string()), &mut out);
continue;
}
+ // 3D ref across a run of sheets: `Jan:Dec!B2` (sheet "Jan:Dec").
+ if i < b.len() && b[i] == b':' {
+ let mut k = i + 1;
+ while k < b.len() && is_ident(b[k]) {
+ k += 1;
+ }
+ if k > i + 1 && k < b.len() && b[k] == b'!' {
+ i = lex_ref_after_sheet(f, start, k + 1, Some(f[start..k].to_string()), &mut out);
+ continue;
+ }
+ }
if i < b.len() && b[i] == b'[' {
// Table1[Col]
out.names.push(word.to_string());
@@ -435,6 +446,16 @@ mod tests {
assert_eq!(args, vec!["\"abc\"", "\"S!A1:B2\""]);
}
+ #[test]
+ fn three_d_refs() {
+ for f in ["=SUM(Jan:Dec!B2)", "=SUM('Jan:Dec'!B2)"] {
+ let lx = lex(f);
+ assert_eq!(lx.refs.len(), 1, "{f}");
+ assert_eq!(lx.refs[0].sheet.as_deref(), Some("Jan:Dec"), "{f}");
+ assert!(lx.names.is_empty(), "{f}");
+ }
+ }
+
#[test]
fn external_workbook_refs() {
let lx = lex("='[Budget 2026.xlsx]Data'!B2+'[Rates.xlsx]'!Margin*C1");
diff --git a/src/graph.rs b/src/graph.rs
index 3a18aef..a5b89b4 100644
--- a/src/graph.rs
+++ b/src/graph.rs
@@ -2,6 +2,7 @@
use crate::a1::{quote_sheet, Rect};
use crate::analyze::Analysis;
+use crate::macros::At;
use crate::model::Workbook;
use serde_json::{json, Value as J};
@@ -15,6 +16,9 @@ pub fn group_id(si: usize, gi: usize) -> String {
pub fn block_id(si: usize, bi: usize) -> String {
format!("s{}.b{}", si + 1, bi + 1)
}
+pub fn macro_id(wb: &Workbook, p: &crate::macros::Proc) -> String {
+ format!("macro:{}.{}", wb.scripts[p.script].name, p.name)
+}
pub fn build(wb: &Workbook, a: &Analysis) -> J {
let mut nodes = Vec::new();
@@ -22,7 +26,7 @@ pub fn build(wb: &Workbook, a: &Analysis) -> J {
for (si, sh) in wb.sheets.iter().enumerate() {
let sa = &a.sheets[si];
nodes.push(json!({
- "id": format!("s{}", si + 1), "type": "sheet", "name": sh.name, "hidden": sh.hidden,
+ "id": format!("s{}", si + 1), "type": "sheet", "name": sh.name, "hidden": sh.hidden, "very_hidden": sh.very_hidden,
"used": sa.used.map(|u| u.a1()), "cells": sa.n_cells, "formulas": sa.n_formulas,
}));
for (bi, b) in sa.blocks.iter().enumerate() {
@@ -33,7 +37,7 @@ pub fn build(wb: &Workbook, a: &Analysis) -> J {
}
for (gi, g) in sa.groups.iter().enumerate() {
let mut flags = Vec::new();
- for (on, name) in [(g.external, "external"), (g.dynamic, "dynamic"), (g.volatile, "volatile"), (g.spill, "spill")] {
+ for (on, name) in [(g.external, "external"), (g.dynamic, "dynamic"), (g.volatile, "volatile"), (g.spill, "spill"), (g.covered > 0, "reads_merged_blank")] {
if on {
flags.push(name);
}
@@ -50,6 +54,10 @@ pub fn build(wb: &Workbook, a: &Analysis) -> J {
_ => r.rect.a1(),
};
let Some(t) = r.sheet else {
+ // `[Book.xlsx]Sheet` refs are `imports` edges to the external node.
+ if r.sheet_name.as_deref().is_some_and(|n| n.starts_with('[')) {
+ continue;
+ }
edges.push(json!({"from": group_id(si, gi), "to": null, "kind": "reads", "range": range_txt, "broken": true}));
continue;
};
@@ -70,6 +78,50 @@ pub fn build(wb: &Workbook, a: &Analysis) -> J {
link_a1(wb, a, &id, src, "plots", &mut edges);
}
}
+ for (xi, x) in sh.shapes.iter().enumerate().filter(|(_, x)| x.on_click.is_some() || x.linked_cell.is_some()) {
+ let id = format!("s{}.shape{}", si + 1, xi + 1);
+ nodes.push(json!({
+ "id": id, "type": "shape", "sheet": format!("s{}", si + 1), "kind": x.kind, "text": x.text,
+ "range": x.anchor.map(|r| r.a1()),
+ }));
+ if let Some(m) = &x.on_click {
+ for p in a.macros.iter().filter(|p| crate::macros::clicks(m, wb, p)) {
+ edges.push(json!({"from": id, "to": macro_id(wb, p), "kind": "runs"}));
+ }
+ }
+ if let Some(cell) = &x.linked_cell {
+ let a1 = if cell.contains('!') { cell.clone() } else { format!("{}!{cell}", quote_sheet(&sh.name)) };
+ link_a1(wb, a, &id, &a1, "writes", &mut edges);
+ }
+ }
+ // Dropdowns pick from a range; validation and conditional formats read their formula's refs.
+ let (mut ndv, mut ncf) = (0, 0);
+ for r in &sa.rules {
+ let (id, ty) = if r.kind == "cond_format" {
+ ncf += 1;
+ (format!("s{}.cf{ncf}", si + 1), "cond_format")
+ } else {
+ ndv += 1;
+ (format!("s{}.dv{ndv}", si + 1), "validation")
+ };
+ nodes.push(json!({
+ "id": id, "type": ty, "kind": r.kind, "sheet": format!("s{}", si + 1),
+ "ranges": r.ranges.iter().map(|x| x.a1()).collect::>(), "rule": r.desc,
+ }));
+ let kind = if r.kind == "dropdown" { "choices" } else { "reads" };
+ for x in &r.refs {
+ let Some(t) = x.sheet else { continue };
+ let range = format!("{}!{}", quote_sheet(&wb.sheets[t].name), x.rect);
+ let ts = targets(a, t, &x.rect, None);
+ if ts.is_empty() || ts.len() > MAX_TARGETS {
+ edges.push(json!({"from": id, "to": format!("s{}", t + 1), "kind": kind, "range": range, "via": x.via_name}));
+ } else {
+ for to in ts {
+ edges.push(json!({"from": id, "to": to, "kind": kind, "range": range, "via": x.via_name}));
+ }
+ }
+ }
+ }
for p in &sh.pivots {
let id = format!("s{}.pivot{}", si + 1, crate::a1::cell_name(p.anchor.0, p.anchor.1));
nodes.push(json!({"id": id, "type": "pivot", "sheet": format!("s{}", si + 1), "rows": p.rows, "cols": p.cols, "values": p.values}));
@@ -84,7 +136,67 @@ pub fn build(wb: &Workbook, a: &Analysis) -> J {
edges.push(json!({"from": group_id(e.sheet, e.group), "to": id, "kind": "imports", "range": e.range}));
}
for n in &wb.named_ranges {
- nodes.push(json!({"id": format!("name:{}", n.name), "type": "named_range", "range": n.range}));
+ // A sheet-local name is `name:Sheet!Name`: other sheets can hold their own.
+ let id = match &n.scope {
+ Some(sc) => format!("name:{sc}!{}", n.name),
+ None => format!("name:{}", n.name),
+ };
+ let scope = n.scope.as_deref().and_then(|x| crate::analyze::sheet_index(wb, x));
+ let def = a.name(scope, &n.name).filter(|d| d.scope == scope);
+ nodes.push(json!({
+ "id": id, "type": "named_range", "range": n.range, "scope": n.scope,
+ "formula": def.is_some_and(|d| d.formula), "lambda": def.is_some_and(|d| d.lambda),
+ }));
+ // What a formula name (or LAMBDA) reads.
+ for (t, r) in def.filter(|d| d.formula).map(|d| d.targets.as_slice()).unwrap_or_default() {
+ let Some(t) = t.or(scope) else { continue };
+ let range = format!("{}!{r}", quote_sheet(&wb.sheets[t].name));
+ for to in targets(a, t, r, None).into_iter().take(MAX_TARGETS) {
+ edges.push(json!({"from": id, "to": to, "kind": "reads", "range": range}));
+ }
+ }
+ }
+ for p in &a.macros {
+ let id = macro_id(wb, p);
+ let sc = &wb.scripts[p.script];
+ nodes.push(json!({
+ "id": id, "type": "macro", "lang": sc.kind, "module": sc.name, "name": p.name, "kind": p.kind,
+ "lines": format!("{}-{}", p.lines.0, p.lines.1),
+ "trigger": p.trigger.as_ref().map(|t| t.event.clone()),
+ "trigger_sheet": p.trigger.as_ref().and_then(|t| t.sheet).map(|s| format!("s{}", s + 1)),
+ }));
+ for t in &p.touches {
+ let kind = if t.write { "writes" } else { "uses" };
+ let range = crate::macros::place(wb, t);
+ match &t.at {
+ At::Sheet(s) => {
+ let ts = t.rect.map(|r| targets(a, *s, &r, None)).unwrap_or_default();
+ if ts.is_empty() || ts.len() > MAX_TARGETS {
+ edges.push(json!({"from": id, "to": format!("s{}", s + 1), "kind": kind, "range": range}));
+ } else {
+ for to in ts {
+ edges.push(json!({"from": id, "to": to, "kind": kind, "range": range}));
+ }
+ }
+ }
+ At::Missing(name) => edges.push(json!({"from": id, "to": null, "kind": kind, "range": range, "missing_sheet": name})),
+ // Which sheet is active when it runs isn't known.
+ At::Active => {}
+ }
+ }
+ for &c in &p.calls {
+ edges.push(json!({"from": id, "to": macro_id(wb, &a.macros[c]), "kind": "calls"}));
+ }
+ }
+ // Worksheet formulas calling VBA functions (UDFs).
+ for (si, sa) in a.sheets.iter().enumerate() {
+ for (gi, g) in sa.groups.iter().enumerate() {
+ for f in &g.funcs {
+ if let Some(p) = a.macros.iter().find(|p| p.kind == "function" && p.name.eq_ignore_ascii_case(f)) {
+ edges.push(json!({"from": group_id(si, gi), "to": macro_id(wb, p), "kind": "calls"}));
+ }
+ }
+ }
}
json!({
"schema": SCHEMA,
diff --git a/src/gsheets.rs b/src/gsheets.rs
index 3d4cff8..f279d8d 100644
--- a/src/gsheets.rs
+++ b/src/gsheets.rs
@@ -5,7 +5,7 @@ use crate::a1::{col_name, quote_sheet, Rect, OPEN};
use crate::model::*;
use anyhow::{anyhow, bail, Context, Result};
use serde::Deserialize;
-use serde_json::Value as J;
+use serde_json::{json, Value as J};
use std::collections::HashMap;
use std::sync::{mpsc, Mutex};
use std::time::{Duration, Instant};
@@ -164,6 +164,7 @@ struct RowData {
#[serde(rename_all = "camelCase", default)]
struct DimMeta {
hidden_by_user: bool,
+ pixel_size: Option,
}
#[derive(Deserialize, Default)]
#[serde(rename_all = "camelCase", default)]
@@ -172,6 +173,8 @@ struct CellData {
effective_value: Option,
formatted_value: Option,
user_entered_format: Option,
+ text_format_runs: Vec,
+ chip_runs: Vec,
data_validation: Option,
note: Option,
hyperlink: Option,
@@ -226,8 +229,8 @@ impl ExtValue {
pub fn fetch(auth: &Auth, id_or_url: &str, verbose: bool) -> Result {
let id = crate::formula::spreadsheet_id(id_or_url);
- let meta_fields = "spreadsheetId,spreadsheetUrl,properties(title,locale,timeZone),namedRanges,dataSources,developerMetadata,\
- sheets(properties,merges,conditionalFormats,protectedRanges,basicFilter,filterViews,charts,bandedRanges,developerMetadata)";
+ let meta_fields = "spreadsheetId,spreadsheetUrl,properties(title,locale,timeZone,autoRecalc,iterativeCalculationSettings),namedRanges,dataSources,developerMetadata,\
+ sheets(properties,merges,conditionalFormats,protectedRanges,basicFilter,filterViews,charts,bandedRanges,developerMetadata,slicers,tables,rowGroups,columnGroups)";
let meta: J = serde_json::from_str(&auth.get(&format!("{SHEETS}/{id}"), &[("fields", meta_fields)])?)?;
let mut wb = Workbook {
@@ -242,7 +245,9 @@ pub fn fetch(auth: &Auth, id_or_url: &str, verbose: bool) -> Result {
},
..Default::default()
};
- // Drive metadata is best-effort: needs the drive scope and visibility.
+ wb.calc = calc(&meta["properties"]);
+ // Drive metadata and comments are best-effort: they need the drive scope and visibility.
+ let mut drive_ok = false;
match auth.get(
&format!("https://www.googleapis.com/drive/v3/files/{id}"),
&[("fields", "modifiedTime,owners(emailAddress),lastModifyingUser(emailAddress)"), ("supportsAllDrives", "true")],
@@ -251,8 +256,9 @@ pub fn fetch(auth: &Auth, id_or_url: &str, verbose: bool) -> Result {
let d: J = serde_json::from_str(&t).unwrap_or_default();
wb.source.modified = d["modifiedTime"].as_str().map(Into::into);
wb.source.owner = d["owners"][0]["emailAddress"].as_str().map(Into::into);
+ drive_ok = true;
}
- Err(e) => wb.warnings.push(format!("drive metadata unavailable: {}", short(&e.to_string()))),
+ Err(e) => wb.warnings.push(format!("drive metadata and comments unavailable: {}", short(&e.to_string()))),
}
let sheets_j = meta["sheets"].as_array().cloned().unwrap_or_default();
@@ -278,13 +284,18 @@ pub fn fetch(auth: &Auth, id_or_url: &str, verbose: bool) -> Result {
frozen_rows: gp["frozenRowCount"].as_u64().unwrap_or(0) as u32,
frozen_cols: gp["frozenColumnCount"].as_u64().unwrap_or(0) as u32,
tab_color: color_hex(&p["tabColorStyle"]["rgbColor"]).or_else(|| color_hex(&p["tabColor"])),
+ no_gridlines: gp["hideGridlines"].as_bool().unwrap_or(false),
..Default::default()
};
+ sh.outlines = arr(&s["rowGroups"]).iter().chain(arr(&s["columnGroups"])).filter_map(outline).collect();
+ sh.outlines.sort_by_key(|o| (o.cols, o.span.0, o.depth));
for m in arr(&s["merges"]) {
sh.merges.push(gr(m));
}
for cf in arr(&s["conditionalFormats"]) {
- sh.cond_formats.push(CondFormat { ranges: arr(&cf["ranges"]).iter().map(gr).collect(), rule: cond_rule(cf) });
+ // Formula values (`=$C2>100`, `=Inputs!B2`) are what the rule reads.
+ let formulas = cond_values(&cf["booleanRule"]["condition"]).into_iter().filter(|v| v.starts_with('=')).collect();
+ sh.cond_formats.push(CondFormat { ranges: arr(&cf["ranges"]).iter().map(gr).collect(), rule: cond_rule(cf), formulas });
}
if s["basicFilter"].is_object() {
sh.filters.push(filter(&s["basicFilter"], None));
@@ -292,6 +303,12 @@ pub fn fetch(auth: &Auth, id_or_url: &str, verbose: bool) -> Result {
for fv in arr(&s["filterViews"]) {
sh.filters.push(filter(fv, fv["title"].as_str().map(Into::into)));
}
+ for sl in arr(&s["slicers"]) {
+ sh.filters.push(slicer(&sl["spec"]));
+ }
+ for t in arr(&s["tables"]) {
+ sh.tables.push(table(t));
+ }
for pr in arr(&s["protectedRanges"]) {
sh.protected.push(Protected {
range: pr.get("range").map(gr),
@@ -316,6 +333,7 @@ pub fn fetch(auth: &Auth, id_or_url: &str, verbose: bool) -> Result {
wb.named_ranges.push(NamedRange {
name: nr["name"].as_str().unwrap_or_default().into(),
range: format!("{}!{}", quote_sheet(&sheet), grid_range(r)),
+ scope: None,
});
}
wb.data_sources = arr(&meta["dataSources"]).to_vec();
@@ -336,8 +354,9 @@ pub fn fetch(auth: &Auth, id_or_url: &str, verbose: bool) -> Result {
}
}
let total = jobs.len();
- let fields = "sheets(data(startRow,startColumn,rowMetadata(hiddenByUser),columnMetadata(hiddenByUser),\
- rowData(values(userEnteredValue,effectiveValue,formattedValue,userEnteredFormat,dataValidation,note,hyperlink,pivotTable))))";
+ let fields = "sheets(data(startRow,startColumn,rowMetadata(hiddenByUser,pixelSize),columnMetadata(hiddenByUser,pixelSize),\
+ rowData(values(userEnteredValue,effectiveValue,formattedValue,userEnteredFormat,dataValidation,note,hyperlink,pivotTable,\
+ textFormatRuns,chipRuns))))";
let names: Vec<(String, u32)> = wb.sheets.iter().map(|s| (s.name.clone(), s.grid_cols)).collect();
let queue = Mutex::new(jobs.into_iter());
let (tx, rx) = mpsc::channel::>();
@@ -388,10 +407,17 @@ pub fn fetch(auth: &Auth, id_or_url: &str, verbose: bool) -> Result {
for sh in &mut wb.sheets {
sh.cells.sort_by_key(|c| (c.r, c.c));
sh.hidden_rows = merge_spans(std::mem::take(&mut sh.hidden_rows));
+ sh.row_heights = merge_sized(std::mem::take(&mut sh.row_heights));
}
wb.styles = styles.items;
wb.validations = dvs.items;
canonicalize(&mut wb);
+ if drive_ok {
+ match drive_comments(auth, &id) {
+ Ok(c) => wb.comments = place_comments(&wb, c),
+ Err(e) => wb.warnings.push(format!("comments unavailable: {}", short(&e.to_string()))),
+ }
+ }
wb.warnings.push(
"Apps Script (bound scripts, macros, add-ons, triggers) is not exposed by the Sheets API; \
pass --apps-script with user credentials to include a project"
@@ -400,6 +426,79 @@ pub fn fetch(auth: &Auth, id_or_url: &str, verbose: bool) -> Result {
Ok(wb)
}
+/// Comment threads on the file, from the Drive API. Their anchors are opaque
+/// ids, so [`place_comments`] finds the cell from the text it quotes.
+fn drive_comments(auth: &Auth, id: &str) -> Result> {
+ let fields = "nextPageToken,comments(author(displayName),content,resolved,quotedFileContent(value),replies(author(displayName),content))";
+ let mut out = Vec::new();
+ let mut page = String::new();
+ for _ in 0..20 {
+ let mut q = vec![("fields", fields), ("pageSize", "100")];
+ if !page.is_empty() {
+ q.push(("pageToken", page.as_str()));
+ }
+ let j: J = serde_json::from_str(&auth.get(&format!("https://www.googleapis.com/drive/v3/files/{id}/comments"), &q)?)?;
+ out.extend(arr(&j["comments"]).iter().map(drive_comment));
+ match j["nextPageToken"].as_str() {
+ Some(t) => page = t.to_string(),
+ None => break,
+ }
+ }
+ Ok(out)
+}
+
+fn drive_comment(c: &J) -> Comment {
+ let who = |m: &J| m["author"]["displayName"].as_str().unwrap_or_default().to_string();
+ Comment {
+ author: who(c),
+ text: c["content"].as_str().unwrap_or_default().trim().into(),
+ quoted: c["quotedFileContent"]["value"].as_str().map(unescape_html).filter(|q| !q.is_empty()),
+ cell: None,
+ resolved: c["resolved"].as_bool().unwrap_or(false),
+ // Resolving or reopening a thread adds a reply with no text.
+ replies: arr(&c["replies"])
+ .iter()
+ .filter_map(|r| Some((who(r), r["content"].as_str().map(str::trim).filter(|t| !t.is_empty())?.to_string())))
+ .collect(),
+ }
+}
+
+/// A row or column group (DimensionGroup).
+fn outline(g: &J) -> Option {
+ let r = &g["range"];
+ let (a, b) = (r["startIndex"].as_u64().unwrap_or(0) as u32, r["endIndex"].as_u64()? as u32);
+ (b > a).then(|| Outline {
+ cols: r["dimension"] == "COLUMNS",
+ span: (a, b - 1),
+ depth: g["depth"].as_u64().unwrap_or(1) as u32,
+ collapsed: g["collapsed"].as_bool().unwrap_or(false),
+ })
+}
+
+/// Tie each comment to the one cell showing the text it quotes, if exactly one does.
+fn place_comments(wb: &Workbook, mut comments: Vec) -> Vec {
+ let mut seen: HashMap<&str, (u32, Option)> = comments.iter().filter_map(|c| Some((c.quoted.as_deref()?, (0, None)))).collect();
+ if !seen.is_empty() {
+ for sh in &wb.sheets {
+ for c in &sh.cells {
+ if let Some(hit) = seen.get_mut(c.display().as_str()) {
+ hit.0 += 1;
+ hit.1 = Some(format!("{}!{}", quote_sheet(&sh.name), crate::a1::cell_name(c.r, c.c)));
+ }
+ }
+ }
+ }
+ let cells: HashMap> = seen.into_iter().map(|(q, (n, at))| (q.to_string(), at.filter(|_| n == 1))).collect();
+ for c in &mut comments {
+ c.cell = c.quoted.as_ref().and_then(|q| cells.get(q).cloned().flatten());
+ }
+ comments
+}
+
+fn unescape_html(s: &str) -> String {
+ s.replace("<", "<").replace(">", ">").replace(""", "\"").replace("'", "'").replace("&", "&")
+}
+
/// Fetch an Apps Script project's source (needs user OAuth with script.projects.readonly).
pub fn fetch_apps_script(auth: &Auth, script_id: &str) -> Result> {
let body = auth.get(&format!("https://script.googleapis.com/v1/projects/{script_id}/content"), &[])?;
@@ -427,12 +526,19 @@ fn absorb(
if m.hidden_by_user {
push_span(&mut sh.hidden_rows, sr + i as u32);
}
+ if let Some(px) = m.pixel_size.filter(|p| *p != DEFAULT_ROW_PX) {
+ push_sized(&mut sh.row_heights, sr + i as u32, px);
+ }
}
- if sh.hidden_cols.is_empty() && sr == 0 {
+ // Every chunk spans all columns; the first one reports them.
+ if sr == 0 {
for (i, m) in gd.column_metadata.iter().enumerate() {
if m.hidden_by_user {
push_span(&mut sh.hidden_cols, sc + i as u32);
}
+ if let Some(px) = m.pixel_size.filter(|p| *p != DEFAULT_COL_PX) {
+ push_sized(&mut sh.col_widths, sc + i as u32, px);
+ }
}
}
for (ri, row) in gd.row_data.into_iter().enumerate() {
@@ -454,7 +560,13 @@ fn absorb(
};
let s = cd.user_entered_format.as_ref().map(style_of).filter(|s| *s != Style::default()).map(|s| styles.intern(s));
let dv = cd.data_validation.as_ref().map(|d| dvs.intern(validation(d)));
- let cell = Cell { r, c, v, f, t, s, dv, note: cd.note, link: cd.hyperlink };
+ let text = cd.formatted_value.as_deref().or(match &v {
+ Value::Str(s) => Some(s.as_str()),
+ _ => None,
+ });
+ let runs = text.map(|t| runs(t, &cd.text_format_runs)).unwrap_or_default();
+ let obj = chip(text, &cd.chip_runs);
+ let cell = Cell { r, c, v, f, t, s, dv, note: cd.note, link: cd.hyperlink, runs, obj };
if cell.is_blank() && cell.s.is_none() && cell.dv.is_none() && cell.note.is_none() {
continue;
}
@@ -463,6 +575,23 @@ fn absorb(
}
}
+/// Google's default row height and column width.
+const DEFAULT_ROW_PX: u32 = 21;
+const DEFAULT_COL_PX: u32 = 100;
+
+/// Sizes from chunks fetched in any order, sorted and joined again.
+fn merge_sized(mut v: Vec<(u32, u32, u32)>) -> Vec<(u32, u32, u32)> {
+ v.sort();
+ let mut out: Vec<(u32, u32, u32)> = Vec::new();
+ for (a, b, px) in v {
+ match out.last_mut() {
+ Some(l) if l.1 + 1 == a && l.2 == px => l.1 = b,
+ _ => out.push((a, b, px)),
+ }
+ }
+ out
+}
+
fn merge_spans(mut v: Vec<(u32, u32)>) -> Vec<(u32, u32)> {
v.sort();
let mut out: Vec<(u32, u32)> = Vec::new();
@@ -530,7 +659,20 @@ fn style_of(f: &J) -> Style {
let fg = color_of(&tf["foregroundColorStyle"], &tf["foregroundColor"]).filter(|c| c != "#000000");
let font = tf["fontFamily"].as_str().filter(|f| *f != "Arial").map(Into::into);
let size = tf["fontSize"].as_u64().map(|s| s as u32).filter(|s| *s != 10);
- let borders = f["borders"].as_object().map(|b| b.values().any(|v| v["style"].as_str().is_some_and(|s| s != "NONE"))).unwrap_or(false);
+ // Line names as Excel's, so both sources read alike.
+ let sides: Vec<(&str, String)> = ["top", "bottom", "left", "right"]
+ .into_iter()
+ .filter_map(|k| {
+ let line = match f["borders"][k]["style"].as_str()? {
+ "NONE" => return None,
+ "SOLID" => "thin",
+ "SOLID_MEDIUM" => "medium",
+ "SOLID_THICK" => "thick",
+ other => return Some((k, other.to_ascii_lowercase())),
+ };
+ Some((k, line.to_string()))
+ })
+ .collect();
Style {
num_fmt: f["numberFormat"]["pattern"].as_str().map(Into::into),
num_type: f["numberFormat"]["type"].as_str().map(Into::into),
@@ -540,12 +682,68 @@ fn style_of(f: &J) -> Style {
italic: tf["italic"].as_bool().unwrap_or(false),
strike: tf["strikethrough"].as_bool().unwrap_or(false),
underline: tf["underline"].as_bool().unwrap_or(false),
- borders,
+ border: crate::model::border_desc(&sides),
h_align: f["horizontalAlignment"].as_str().filter(|a| *a != "LEFT").map(Into::into),
font,
size,
wrap: f["wrapStrategy"].as_str() == Some("WRAP"),
+ ..Default::default()
+ }
+}
+
+/// Recalculation settings, when they differ from Google's default (on change, no iteration).
+fn calc(p: &J) -> Option {
+ let mode = match p["autoRecalc"].as_str() {
+ Some("MINUTE") => "on change and every minute",
+ Some("HOUR") => "on change and every hour",
+ _ => "on change",
+ };
+ let it = &p["iterativeCalculationSettings"];
+ let iterative = it.is_object();
+ if !iterative && mode == "on change" {
+ return None;
}
+ Some(Calc {
+ mode: mode.into(),
+ iterative,
+ max_iterations: it["maxIterations"].as_u64().map(|n| n as u32),
+ max_change: it["convergenceThreshold"].as_f64(),
+ })
+}
+
+/// A cell's text split where its formatting changes (`textFormatRuns`).
+/// Indexes count UTF-16 code units.
+fn runs(text: &str, fmt_runs: &[J]) -> Vec {
+ let units: Vec = text.encode_utf16().collect();
+ let mut out: Vec = Vec::new();
+ for (i, r) in fmt_runs.iter().enumerate() {
+ let start = r["startIndex"].as_u64().unwrap_or(0) as usize;
+ let end = fmt_runs.get(i + 1).and_then(|n| n["startIndex"].as_u64()).map_or(units.len(), |e| e as usize);
+ let Some(slice) = units.get(start.min(units.len())..end.min(units.len())).filter(|s| !s.is_empty()) else { continue };
+ let f = style_of(&json!({ "textFormat": r["format"] }));
+ out.push(Run { text: String::from_utf16_lossy(slice), f });
+ }
+ if !runs_differ(&out) {
+ return Vec::new();
+ }
+ out
+}
+
+/// Smart chips: people, Drive files and other links.
+fn chip(text: Option<&str>, chips: &[J]) -> Option {
+ let mut objs = chips.iter().filter_map(|c| {
+ let c = &c["chip"];
+ let (kind, target) = if let Some(email) = c["personProperties"]["email"].as_str() {
+ ("person", email)
+ } else {
+ let uri = c["richLinkProperties"]["uri"].as_str()?;
+ (if uri.contains("docs.google.com") || uri.contains("drive.google.com") { "file" } else { "link" }, uri)
+ };
+ Some((kind, target.to_string()))
+ });
+ let (kind, target) = objs.next()?;
+ let props = objs.map(|(k, t)| (k.to_string(), t)).collect();
+ Some(CellObject { kind: kind.into(), text: text.map(Into::into), target: Some(target), props })
}
fn cond_values(cond: &J) -> Vec {
@@ -611,24 +809,41 @@ fn cond_rule(cf: &J) -> String {
}
fn filter(f: &J, title: Option) -> Filter {
- let mut criteria = Vec::new();
- for spec in arr(&f["filterSpecs"]) {
- let col = spec["columnIndex"].as_u64().unwrap_or(0) as u32;
- let c = &spec["filterCriteria"];
- let mut bits = Vec::new();
- if let Some(h) = c["hiddenValues"].as_array() {
- bits.push(format!("hide {} values", h.len()));
- }
- if c["condition"].is_object() {
- bits.push(format!("{} {}", c["condition"]["type"].as_str().unwrap_or("?"), cond_values(&c["condition"]).join(",")));
- }
- if !bits.is_empty() {
- criteria.push(format!("{}: {}", col_name(col), bits.join("; ")));
- }
- }
+ let criteria = arr(&f["filterSpecs"]).iter().filter_map(|spec| criterion(spec["columnIndex"].as_u64().unwrap_or(0) as u32, &spec["filterCriteria"])).collect();
Filter { range: grid_range(&f["range"]), title, criteria }
}
+/// One column's filter criteria, e.g. `C: hide 3 values`.
+fn criterion(col: u32, c: &J) -> Option {
+ let mut bits = Vec::new();
+ if let Some(h) = c["hiddenValues"].as_array() {
+ bits.push(format!("hide {} values", h.len()));
+ }
+ if c["condition"].is_object() {
+ bits.push(format!("{} {}", c["condition"]["type"].as_str().unwrap_or("?"), cond_values(&c["condition"]).join(",")));
+ }
+ (!bits.is_empty()).then(|| format!("{}: {}", col_name(col), bits.join("; ")))
+}
+
+/// A slicer is a filter control on one column of a range.
+fn slicer(spec: &J) -> Filter {
+ let range = grid_range(&spec["dataRange"]);
+ let col = range.c0 + spec["columnIndex"].as_u64().unwrap_or(0) as u32;
+ let title = spec["title"].as_str().filter(|t| !t.is_empty()).map_or_else(|| format!("slicer on {}", col_name(col)), |t| format!("slicer {t}"));
+ Filter { range, title: Some(title), criteria: criterion(col, &spec["filterCriteria"]).into_iter().collect() }
+}
+
+/// A Google Sheets table (Format → Convert to table).
+fn table(t: &J) -> Table {
+ Table {
+ name: t["name"].as_str().unwrap_or_default().into(),
+ range: grid_range(&t["range"]),
+ columns: arr(&t["columnProperties"]).iter().map(|c| c["columnName"].as_str().unwrap_or_default().to_string()).collect(),
+ // The API doesn't say whether the last row is a totals row.
+ totals: false,
+ }
+}
+
fn chart(c: &J, id_to_name: &HashMap) -> Chart {
let spec = &c["spec"];
let kind = spec
@@ -687,3 +902,89 @@ fn pivot(p: &J, anchor: (u32, u32), id_to_name: &HashMap) -> Pivot
filters: arr(&p["filterSpecs"]).iter().map(col).collect(),
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn text_runs_and_chips() {
+ // Shapes as the Sheets API returns them; indexes are UTF-16 units.
+ let fmt = json!([
+ {"startIndex": 0, "format": {}},
+ {"startIndex": 6, "format": {"bold": true, "foregroundColorStyle": {"rgbColor": {"red": 1}}}},
+ ]);
+ let r = runs("Café: due", fmt.as_array().unwrap());
+ assert_eq!(r.iter().map(|r| r.text.as_str()).collect::>(), ["Café: ", "due"]);
+ assert!(r[1].f.bold && r[1].f.fg.as_deref() == Some("#FF0000"));
+ let same = json!([{"startIndex": 0, "format": {"italic": true}}, {"startIndex": 2, "format": {"italic": true}}]);
+ assert!(runs("abcd", same.as_array().unwrap()).is_empty());
+
+ let chips = json!([
+ {"startIndex": 0, "chip": {"personProperties": {"email": "ana@example.com", "displayFormat": "DEFAULT"}}},
+ {"startIndex": 4, "chip": {"richLinkProperties": {"uri": "https://docs.google.com/document/d/abc", "mimeType": "application/vnd.google-apps.document"}}},
+ ]);
+ let o = chip(Some("Ana and the plan"), chips.as_array().unwrap()).unwrap();
+ assert_eq!((o.kind.as_str(), o.target.as_deref()), ("person", Some("ana@example.com")));
+ assert_eq!(o.props, [("file".to_string(), "https://docs.google.com/document/d/abc".to_string())]);
+
+ assert_eq!(calc(&json!({"autoRecalc": "ON_CHANGE"})), None);
+ let c = calc(&json!({"autoRecalc": "HOUR", "iterativeCalculationSettings": {"maxIterations": 50, "convergenceThreshold": 0.01}})).unwrap();
+ assert_eq!((c.mode.as_str(), c.iterative, c.max_iterations, c.max_change), ("on change and every hour", true, Some(50), Some(0.01)));
+ }
+
+ #[test]
+ fn borders_slicers_and_tables() {
+ let st = style_of(&json!({"borders": {"top": {"style": "SOLID"}, "bottom": {"style": "DOUBLE"}, "left": {"style": "NONE"}}}));
+ assert_eq!(st.border.as_deref(), Some("top thin, bottom double"));
+ let all = json!({"style": "SOLID_MEDIUM"});
+ assert_eq!(style_of(&json!({"borders": {"top": all, "bottom": all, "left": all, "right": all}})).border.as_deref(), Some("box medium"));
+
+ let sl = slicer(&json!({
+ "dataRange": {"sheetId": 0, "startRowIndex": 0, "endRowIndex": 50, "startColumnIndex": 1, "endColumnIndex": 5},
+ "columnIndex": 2, "title": "Region",
+ "filterCriteria": {"hiddenValues": ["East", "West"]},
+ }));
+ assert_eq!((sl.range.a1(), sl.title.as_deref(), sl.criteria.as_slice()), ("B1:E50".to_string(), Some("slicer Region"), ["D: hide 2 values".to_string()].as_slice()));
+
+ let t = table(&json!({
+ "tableId": "t1", "name": "Orders",
+ "range": {"sheetId": 0, "startRowIndex": 0, "endRowIndex": 10, "startColumnIndex": 0, "endColumnIndex": 2},
+ "columnProperties": [{"columnIndex": 0, "columnName": "Order"}, {"columnIndex": 1, "columnName": "Amount", "columnType": "CURRENCY"}],
+ }));
+ assert_eq!((t.name.as_str(), t.range.a1(), t.columns.as_slice()), ("Orders", "A1:B10".to_string(), ["Order".to_string(), "Amount".to_string()].as_slice()));
+
+ let cond = json!({"type": "CUSTOM_FORMULA", "values": [{"userEnteredValue": "=$C2>Inputs!B1"}]});
+ assert_eq!(cond_values(&cond), ["=$C2>Inputs!B1"]);
+ }
+
+ #[test]
+ fn layout_groups_and_comments() {
+ // Sizes and hidden rows from a chunk's dimension metadata.
+ let mut sh = Sheet { name: "Plan".into(), ..Default::default() };
+ let gd: GridData = serde_json::from_value(json!({
+ "rowMetadata": [{"pixelSize": 21}, {"pixelSize": 40}, {"pixelSize": 40}, {"pixelSize": 21, "hiddenByUser": true}],
+ "columnMetadata": [{"pixelSize": 100}, {"pixelSize": 12}, {"pixelSize": 250}],
+ "rowData": [{"values": [{"formattedValue": "45", "effectiveValue": {"numberValue": 45}}, {"formattedValue": "Total", "effectiveValue": {"stringValue": "Total"}}]},
+ {"values": [{"formattedValue": "45", "effectiveValue": {"numberValue": 45}}]}],
+ }))
+ .unwrap();
+ absorb(&mut sh, 0, gd, &mut Interner::default(), &mut Interner::default(), &HashMap::new());
+ assert_eq!((sh.row_heights.as_slice(), sh.col_widths.as_slice(), sh.hidden_rows.as_slice()), ([(1, 2, 40)].as_slice(), [(1, 1, 12), (2, 2, 250)].as_slice(), [(3, 3)].as_slice()));
+ assert_eq!(merge_sized(vec![(5, 6, 40), (0, 1, 40), (2, 4, 40), (7, 7, 30)]), [(0, 6, 40), (7, 7, 30)]);
+
+ let g = outline(&json!({"range": {"dimension": "COLUMNS", "startIndex": 3, "endIndex": 6}, "depth": 2, "collapsed": true})).unwrap();
+ assert_eq!(g, Outline { cols: true, span: (3, 5), depth: 2, collapsed: true });
+
+ let wb = Workbook { sheets: vec![sh], ..Default::default() };
+ let c = |q: &str| drive_comment(&json!({
+ "author": {"displayName": "Ana"}, "content": "why? ", "resolved": true,
+ "quotedFileContent": {"mimeType": "text/html", "value": q},
+ "replies": [{"author": {"displayName": "Bo"}, "content": "fixed"}, {"author": {"displayName": "Bo"}, "content": ""}],
+ }));
+ let placed = place_comments(&wb, vec![c("Total"), c("45"), c("gone")]);
+ let at: Vec