Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
88a6e18
XLSX: formulas into linked workbooks calculate from the values saved …
neilcawse Oct 12, 2026
d1dd946
Linked workbooks: references stay references; [1]Jan:Dec!A1 reads and…
neilcawse Oct 12, 2026
9bb5a34
Linked workbooks: OFFSET refuses a linked base; sheet rename keeps [1…
neilcawse Oct 12, 2026
36befbd
Linked workbooks: a typed [n] that isn't a link is refused on entry; …
neilcawse Oct 13, 2026
6746985
Linked workbooks: check both ends of a sheet range and non-index pref…
neilcawse Oct 13, 2026
80a583c
Linked workbooks: paste keeps [n] where the target shares that link (…
neilcawse Oct 13, 2026
40de982
Formulas: [Book.xlsx]Sheet1!A1 and 'C:\path\[Book.xlsx]Sheet1'!A1 par…
marcoarnulfo Oct 12, 2026
35db799
Linked workbooks: keep the link's path, write links added after readi…
marcoarnulfo Oct 12, 2026
fb69323
Linked workbooks: references typed by file name or path go through a …
marcoarnulfo Oct 12, 2026
838088d
Linked workbooks: link targets are percent-decoded on read and encode…
marcoarnulfo Oct 12, 2026
86517fa
Formulas: a cell in another workbook needs a sheet ([Book.xlsx]!A1 is…
marcoarnulfo Oct 12, 2026
44a685f
Linked workbooks: C:/links/Source.xlsx and C:\links\Source.xlsx are t…
marcoarnulfo Oct 12, 2026
d5ca527
Linked workbooks: typed file names go through a link after #464's [n]…
Herr-Enklo Oct 13, 2026
b86ae0d
Workbook Links: update values from the source, change source, break l…
Herr-Enklo Oct 13, 2026
9a0fec9
Workbook Links: ribbon group, task pane, Edit Links dialog, security …
Herr-Enklo Oct 13, 2026
661c58f
Workbook Links: docs, MCP test, fuzz exclusions
Herr-Enklo Oct 13, 2026
07627f1
1739: transfer self-contained worksheets between workbooks
derechopiano Oct 17, 2026
3e7d0dc
1739: preserve workbook references during sheet transfers
derechopiano Oct 17, 2026
b91e250
1739: make workbook-link path assertions portable
derechopiano Oct 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-12 · **Change:** minor (rows auto-fit oversized fonts on open and whole-row/column formatting) · **Target:** Microsoft Excel (Microsoft 365)
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** minor (Workbook Links: linked workbooks update from their files) · **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.
Expand Down Expand Up @@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in

| Date | What landed |
|---|---|
| 2026-10-14 | Workbook Links: Data › Queries & Connections › Workbook Links (Excel 365's pane) and File › Info › Edit Links to Files update linked workbooks' values from their files, check their status, change their source, open them and break the links (formulas become values); the startup prompt is saved with the workbook. Opening a workbook with links shows the security warning with Enable Content instead of reading them. Builds on #464 (formulas into linked workbooks use the saved values) and #829 (references typed by file name go through a link) |
| 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) |
| 2026-10-12 | Number formats: a fraction denominator with 65,536 zeros no longer crashes; format codes are capped at 255 characters like Excel (longer codes display as General, `TEXT` gives `#VALUE!`) (#807) |
Expand Down Expand Up @@ -193,6 +194,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in

| Date | Change | Summary |
|---|---|---|
| 2026-10-14 | minor | Progress log: Workbook Links (update, change source, break links, startup prompt, security warning) |
| 2026-10-12 | minor | Rows auto-fit oversized fonts on open and whole-row/column formatting (#833); progress log row |
| 2026-10-12 | minor | Progress log: multi-area row/column delete (#758) |
| 2026-10-12 | trivial | Progress log: XLSX chart sheets kept on save (#727) |
Expand Down
210 changes: 194 additions & 16 deletions crates/calc/src/eval.rs

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion crates/calc/src/recalc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -765,7 +765,7 @@ impl Host for PassHost<'_> {
}
fn used_range(&mut self, sheet: usize) -> Option<RangeRef> {
let wb = self.wb;
*self.used.entry(sheet).or_insert_with(|| wb.sheet(sheet).and_then(|s| s.used_range()))
*self.used.entry(sheet).or_insert_with(|| crate::eval::used_range_of(wb, sheet))
}
fn now_serial(&self) -> f64 {
self.now
Expand Down
154 changes: 154 additions & 0 deletions crates/calc/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,160 @@ fn chains_out_of_order() {
assert_eq!(t.num("A5"), 5.0);
}

#[test]
fn references_into_linked_workbooks_read_the_saved_values() {
use gridcraft_model::{ExternalBook, ExternalName, ExternalSheet};
let mut t = T::new();
let prices = ExternalSheet {
name: "P&L Prices".into(),
cells: vec![(c("A1"), Value::text("gas")), (c("B1"), Value::Number(4.5)), (c("A2"), Value::text("power")), (c("B2"), Value::Number(61.0))],
refresh_error: false,
};
let rates = ExternalSheet { name: "Rates".into(), cells: vec![(c("C3"), Value::Number(2.0))], refresh_error: false };
let failed = ExternalSheet { name: "Failed".into(), cells: vec![(c("A1"), Value::Number(9.0))], refresh_error: true };
let names = vec![
ExternalName { name: "Gas".into(), sheet: None, refers_to: "'P&L Prices'!$B$1".into() },
ExternalName { name: "Rate".into(), sheet: Some(1), refers_to: "$C$3".into() },
// Names inside a linked name are the linked workbook's too.
ExternalName { name: "GasTwice".into(), sheet: None, refers_to: "Gas*2".into() },
ExternalName { name: "RatePlus".into(), sheet: Some(1), refers_to: "Rate+GasTwice".into() },
// A sheet range or a sheetless reference in a workbook-level name can't be resolved
// from the saved values (and must not read this workbook's cells).
ExternalName { name: "Span".into(), sheet: None, refers_to: "Rates:Failed!$A$1".into() },
ExternalName { name: "Loose".into(), sheet: None, refers_to: "$E$9".into() },
];
t.wb.external_books = vec![ExternalBook::default(), ExternalBook { sheets: vec![prices, rates, failed], names, ..Default::default() }].into();
t.wb.names.push(gridcraft_model::DefinedName {
name: "Price".into(),
scope: None,
formula: "'[2]P&L Prices'!$A$1:$B$65536".into(),
comment: String::new(),
hidden: false,
});
t.set("A1", "='[2]P&L Prices'!B2*2");
t.set("A2", "=VLOOKUP(\"gas\",Price,2,FALSE)");
t.set("A3", "=SUM('[2]P&L Prices'!B1:B9)+[2]Rates!C3");
t.set("A4", "=[2]Rates!A1");
assert_eq!((t.num("A1"), t.num("A2"), t.num("A3"), t.num("A4")), (122.0, 4.5, 67.5, 0.0));
// A sheet the link didn't save, or a book that isn't linked, is #REF! as before.
t.set("A5", "=[2]Other!A1");
t.set("A6", "=[1]Rates!C3");
t.set("A7", "='[3]Rates'!C3");
for at in ["A5", "A6", "A7"] {
assert_eq!(t.get(at), Value::Error(CellError::Ref), "{at}");
}
// A sheet whose last refresh failed keeps its saved cells; anything else in it is #REF!.
t.set("A8", "=[2]Failed!A1+[2]Failed!B1");
t.set("A9", "=[2]Failed!A1");
t.set("A10", "=SUM([2]Failed!A1:B1)");
assert_eq!((t.get("A8"), t.num("A9"), t.get("A10")), (Value::Error(CellError::Ref), 9.0, Value::Error(CellError::Ref)));
// `[0]` is the workbook itself: its names, sheets and sheet ranges (as files may hold them).
t.set("D1", "=VLOOKUP(\"power\",[0]!Price,2,FALSE)");
assert_eq!(t.num("D1"), 61.0);
t.set("D2", "=[0]Sheet1!A1+SUM([0]Sheet1:Sheet1!A1)");
assert_eq!(t.num("D2"), 244.0);
// The linked workbook's own names: workbook-scoped and sheet-scoped.
t.set("B1", "=[2]!Gas*10");
t.set("B2", "=[2]Rates!Rate+1");
assert_eq!((t.num("B1"), t.num("B2")), (45.0, 3.0));
t.set("B3", "=[2]!Missing");
assert_eq!(t.get("B3"), Value::Error(CellError::Ref));
t.set("E9", "100");
t.set("B4", "=[2]!GasTwice");
t.set("B5", "=[2]Rates!RatePlus");
t.set("B6", "=[2]!Span");
t.set("B7", "=[2]!Loose");
assert_eq!((t.num("B4"), t.num("B5")), (9.0, 11.0));
assert_eq!((t.get("B6"), t.get("B7")), (Value::Error(CellError::Ref), Value::Error(CellError::Ref)));
// SUMIF and friends need references; Excel can't read a closed workbook through them.
t.set("C1", "=SUMIF('[2]P&L Prices'!A1:A2,\"gas\",'[2]P&L Prices'!B1:B2)");
t.set("C2", "=COUNTIF(#REF!,\">0\")");
t.set("C3", "=SUMIF(Price,\"gas\")");
assert_eq!(
(t.get("C1"), t.get("C2"), t.get("C3")),
(Value::Error(CellError::Value), Value::Error(CellError::Ref), Value::Error(CellError::Value))
);
// Linked sheets print as Excel writes them: quoted only when the sheet name needs it.
let f = t.wb.sheet(0).unwrap().cells.get(c("A3")).and_then(|x| x.formula.clone()).unwrap();
assert_eq!(gridcraft_formula::print(f.expr.as_ref().unwrap()), "SUM('[2]P&L Prices'!B1:B9)+[2]Rates!C3");
}

#[test]
fn references_into_linked_workbooks_keep_their_position_and_size() {
use gridcraft_model::{ExternalBook, ExternalSheet};
let mut t = T::new();
// Data!A1:B6: a header row, then items a..e priced 10..14.
let mut cells = vec![(c("A1"), Value::text("Item")), (c("B1"), Value::text("Price"))];
for (i, item) in ["a", "b", "c", "d", "e"].into_iter().enumerate() {
cells.push((CellRef::new(i as u32 + 1, 0), Value::text(item)));
cells.push((CellRef::new(i as u32 + 1, 1), Value::Number(10.0 + i as f64)));
}
cells.sort_by_key(|(p, _)| *p);
t.wb.external_books =
vec![ExternalBook { sheets: vec![ExternalSheet { name: "Data".into(), cells, refresh_error: false }], names: vec![], ..Default::default() }]
.into();
// `@` takes the cell on the formula's row (or column), as for this workbook's references.
for at in ["D2", "D3", "D4", "D5", "D6"] {
t.set(at, "=@[1]Data!B:B");
}
assert_eq!([t.num("D2"), t.num("D3"), t.num("D4"), t.num("D5"), t.num("D6")], [10.0, 11.0, 12.0, 13.0, 14.0]);
t.set("F4", "=@[1]Data!B3:B6");
t.set("F9", "=@[1]Data!B3:B6");
t.set("B10", "=@[1]Data!A1:B1");
assert_eq!((t.num("F4"), t.get("F9"), t.get("B10")), (12.0, Value::Error(CellError::Value), Value::text("Price")));
// The reference keeps the size written, past the last saved cell.
t.set("G1", "=ROWS([1]Data!A:A)");
t.set("G2", "=ROWS([1]Data!A1:A100000)");
t.set("G3", "=COLUMNS([1]Data!1:1)");
t.set("G4", "=INDEX([1]Data!B:B,4)");
t.set("G5", "=INDEX([1]Data!A:A,10)");
t.set("G6", "=SUM([1]Data!B:B)+VLOOKUP(\"c\",[1]Data!A:B,2,FALSE)");
t.set("G7", "=ISREF([1]Data!B2)");
assert_eq!(
(t.num("G1"), t.num("G2"), t.num("G3"), t.num("G4"), t.num("G5"), t.num("G6"), t.get("G7")),
(1_048_576.0, 100_000.0, 16_384.0, 12.0, 0.0, 72.0, Value::Bool(true))
);
// INDIRECT reads another workbook only while it is open.
t.set("H1", "=INDIRECT(\"[1]Data!B2\")");
assert_eq!(t.get("H1"), Value::Error(CellError::Ref));
}

#[test]
fn offset_refuses_a_base_in_a_linked_workbook() {
use gridcraft_model::{ExternalBook, ExternalSheet};
let mut t = T::new();
// Curve!D8:D10 = 37 and Backup!D38 = 39: the saved link has every cell OFFSET would read,
// but Excel takes the ISERROR branch while the source workbook is closed (enron
// "West NatGas Prices 1031", PowerPrices!D9).
let curve = (8..=10).map(|r| (CellRef::new(r - 1, 3), Value::Number(37.0))).collect();
let backup = vec![(c("D38"), Value::Number(39.0))];
t.wb.external_books = vec![ExternalBook {
sheets: vec![
ExternalSheet { name: "Curve".into(), cells: curve, refresh_error: false },
ExternalSheet { name: "Backup".into(), cells: backup, refresh_error: false },
],
names: vec![],
..Default::default()
}]
.into();
t.set("A1", "=OFFSET([1]Curve!$D$6,2,0,3,1)");
t.set("A2", "=IF(ISERROR(AVERAGE(OFFSET([1]Curve!$D$6,2,0,3,1))),[1]Backup!$D$38,0)");
t.set("A3", "=SUM(OFFSET(B1,0,0,2,1))");
assert_eq!((t.get("A1"), t.num("A2"), t.num("A3")), (Value::Error(CellError::Value), 39.0, 0.0));
}

#[test]
fn if_family_ranges_inside_lambdas_stay_references() {
// MAP/BYROW hand their LAMBDA ranges of this workbook: COUNTIF and SUMIF take them.
let mut t = T::new();
for (at, v) in [("E1", "1"), ("F1", "-2"), ("G1", "3"), ("E2", "-1"), ("F2", "5"), ("G2", "6")] {
t.set(at, v);
}
t.set("H1", "=BYROW(E1:G2,LAMBDA(r,COUNTIF(r,\">0\")))");
t.set("I1", "=MAP(E1:E2,LAMBDA(x,SUMIF(x,\">0\")))");
assert_eq!((t.num("H1"), t.num("H2"), t.num("I1"), t.num("I2")), (2.0, 2.0, 1.0, 0.0));
}

#[test]
fn xlookup_returns_a_reference() {
let mut t = T::new();
Expand Down
2 changes: 1 addition & 1 deletion crates/calc/src/trimrange.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ impl Evaluator<'_> {
if (h as u64) * (w as u64) > MAX_CELLS {
return Err(CellError::Num);
}
let values = self.host.range_values(a.sheet, part);
let values = self.block_values(a.sheet, part);
let ((r0, r1), (c0, c1)) = bounds(&values, w).ok_or(CellError::Calc)?;
let (h_all, w_all) = (a.range.height() as usize, a.range.width() as usize);
let (kr0, kr1) = keep(h_all, (r0, r1), rows);
Expand Down
2 changes: 2 additions & 0 deletions crates/engine/src/catalog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ pub const CATALOG: &[(&str, &str, &str, &str)] = &[
("file.exportCsv", "File", "Export", "CSV"),
("file.exportHtml", "File", "Export", "Web Page"),
("file.properties", "File", "Info", "Properties"),
("data.editLinks", "File", "Info", "Edit Links to Files"),
("file.options", "File", "Options", "Options"),
// Home
("edit.paste", "Home", "Clipboard", "Paste"),
Expand Down Expand Up @@ -197,6 +198,7 @@ pub const CATALOG: &[(&str, &str, &str, &str)] = &[
("data.fromTableRange", "Data", "Get & Transform Data", "From Table/Range"),
("data.refreshAll", "Data", "Queries & Connections", "Refresh All"),
("data.queriesConnections", "Data", "Queries & Connections", "Queries & Connections"),
("data.workbookLinks", "Data", "Queries & Connections", "Workbook Links"),
("data.stocks", "Data", "Data Types", "Stocks"),
("data.geography", "Data", "Data Types", "Geography"),
("data.sortAscending", "Data", "Sort & Filter", "Sort A to Z"),
Expand Down
3 changes: 3 additions & 0 deletions crates/engine/src/cmd/data.rs
Original file line number Diff line number Diff line change
Expand Up @@ -951,6 +951,9 @@ fn validation(s: &mut Session, p: &Json) -> Result<Json> {
"custom" => ValidationKind::Custom,
_ => ValidationKind::Any,
};
for f in [str_param(p, "formula1"), str_param(p, "formula2")].into_iter().flatten() {
super::edit::refuse_linked_books(f)?;
}
let dv = Validation {
ranges: ranges.clone(),
kind,
Expand Down
Loading