diff --git a/Cargo.lock b/Cargo.lock index e94d006..99ec458 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -447,6 +447,15 @@ version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block2" version = "0.5.1" @@ -697,6 +706,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.2" @@ -746,6 +764,15 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "cursor-icon" version = "1.2.0" @@ -776,6 +803,16 @@ dependencies = [ "rayon", ] +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "dispatch" version = "0.2.0" @@ -1530,6 +1567,7 @@ dependencies = [ "gridcraft-formula", "serde", "serde_json", + "sha2", "thiserror 2.0.21", ] @@ -1665,6 +1703,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + [[package]] name = "image" version = "0.25.10" @@ -2482,7 +2529,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3064,6 +3111,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "2.0.1" @@ -3445,6 +3503,12 @@ dependencies = [ "rustc-hash 2.1.3", ] +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "uds_windows" version = "1.2.1" diff --git a/Cargo.toml b/Cargo.toml index fb1d798..eab2853 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,7 @@ rayon = "1" regex = { version = "1", default-features = false, features = ["std", "unicode", "perf"] } quick-xml = "0.41" zip = { version = "3", default-features = false, features = ["deflate"] } +sha2 = { version = "0.11", default-features = false } image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } # ui diff --git a/ROADMAP.md b/ROADMAP.md index 9eb2a27..4dd7f0c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -145,6 +145,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | 2026-10-11 | Freeze panes by dragging the freeze handles on the header edges (#195) | | 2026-10-11 | XLSX: threaded comments (replies, resolved, timestamps) and note visibility survive a save (#300) | | 2026-10-11 | Cell checkboxes: Insert › Checkbox on the ribbon, Excel's Delete and value rules, XLSX round trip as Excel cell controls (#196) | +| 2026-10-11 | Review › Allow Edit Ranges with Excel-compatible password hashes, kept in XLSX (#307) | | 2026-10-10 | Ribbon translated into Japanese, Chinese, Korean, Brazilian Portuguese and Russian with system-language default (#31, #104, #82); CJK/Thai/Hebrew font fallback (#38); clear messages for encrypted and `.xls` files (#17); smooth line charts (#62). Progress docs restructured to the craftrules standard; full re-measure (this page). Initial ribbon key tips (#43), in-place text box editing (#84), copy as HTML table (#36). ODS and XLSB data import (#100, #101); recalc linear in the number of range formulas (#66); RISC-V Linux build; Flatpak repository on Pages; web cell-commit trap fixed | | 2026-10-09 | Community fixes: spill range operator `A1#`, sheet-scoped names, protection refuses locked-cell edits, Insert/Delete Cells adjust references, `DATE(1900,2,29)`, SUMIF sizing, `ROUND` at 15 digits, `_xlpm.` LAMBDA parameters in XLSX, vertical scrollbar, system appearance, border previews; v0.4.0 | | 2026-10-08 | v0.2.0 and v0.3.0 releases; Flatpak, AppImage zsync, branded DMG; desktop log file; DirectX 12 default on Windows | @@ -163,6 +164,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | 2026-10-11 | minor | Selected objects can be deleted from the keyboard (#213) | | 2026-10-11 | minor | Catalog 258 → 260 / 290: View › Formula Bar and Zoom 100% (#289) | | 2026-10-11 | minor | Progress log: XLSX threaded comments round trip (#300) | +| 2026-10-11 | minor | Catalog 258 → 259 / 290: Allow Edit Ranges (#307) | | 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% per audience; full number unchanged (additive weighted sum) | | 2026-10-10 | minor | Added Mainstream practitioner (~50%) and Essentials user (~63%); languages updated for the 10-10 merges (ribbon in ja, zh, ko, pt-BR, ru); localization 5%→10%; full number re-checked (51.6, unchanged) | | 2026-10-10 | minor | Checked against the alpha gate (six core workflows pass): stays alpha | diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 1e47b36..ce34f44 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -199,10 +199,13 @@ fn cell_set(s: &mut Session, p: &Json) -> Result { let input = str_param(p, "input").or_else(|| str_param(p, "value")).map(str::to_string).or_else(|| p.get("value").map(json_to_input)).unwrap_or_default(); let array = bool_param(p, "array").unwrap_or(false); - let protected = - s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.is_protected() && s.doc().is_ok_and(|d| d.wb.styles.get(sh.style_id(at)).protection.locked)); - if protected { - return Err(EngineError::Other("The cell or chart you're trying to change is on a protected sheet.".into())); + let d = s.doc()?; + if let Some(sh) = d.wb.sheet(sheet) + && sh.is_protected() + && d.wb.styles.get(sh.style_id(at)).protection.locked + && let Some(m) = locked_cell_error(sh, at, &d.unlocked_ranges) + { + return Err(EngineError::Other(m)); } edit(s, |cx| { if array { diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index cab5df7..29f6112 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -782,7 +782,7 @@ fn insert_picture(s: &mut Session, p: &Json) -> Result { }; let at = sh.merge_at(at).map(|r| r.start).unwrap_or(at); let style = sh.style_id(at); - if sh.is_protected() && d.wb.styles.get(style).protection.locked { + if sh.is_protected() && d.wb.styles.get(style).protection.locked && locked_cell_error(sh, at, &d.unlocked_ranges).is_some() { return Err(EngineError::Other("The cell you're trying to change is on a protected sheet.".into())); } let picture = CellPicture { data, mime: mime.into(), alt: str_param(p, "alt").unwrap_or("").into() }; diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index a37c1a8..a32aad8 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -272,7 +272,7 @@ pub(crate) fn commit(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result) let r = f(&mut ctx)?; let Ctx { mut wb, changed, structural, mut fit_rows, protection_checked, .. } = ctx; if !protection_checked { - check_protection(&d.wb, &wb)?; + check_protection(&d.wb, &wb, &d.unlocked_ranges)?; } if changed.len() <= 200_000 { fit_rows.extend(changed.iter().map(|(s, c)| (*s, c.row))); @@ -307,7 +307,7 @@ pub(crate) const PROTECTED: &str = "The cell or chart you're trying to change is /// Refuses an edit that changed what a locked cell on a protected sheet holds (its value or /// formula), whichever command made it. Sheets are matched by name, so moving, renaming or /// deleting a sheet isn't an edit of its cells. -fn check_protection(old: &Workbook, new: &Workbook) -> Result<()> { +fn check_protection(old: &Workbook, new: &Workbook, unlocked: &[(String, String)]) -> Result<()> { for sh in &old.sheets { if !sh.is_protected() { continue; @@ -317,14 +317,33 @@ fn check_protection(old: &Workbook, new: &Workbook) -> Result<()> { continue; } for c in sh.cells.diff(&after.cells) { - if !same_content(sh.cell(c), after.cell(c)) && old.styles.get(sh.style_id(c)).protection.locked { - return Err(EngineError::Other(PROTECTED.into())); + if !same_content(sh.cell(c), after.cell(c)) + && old.styles.get(sh.style_id(c)).protection.locked + && let Some(m) = locked_cell_error(sh, c, unlocked) + { + return Err(EngineError::Other(m)); } } } Ok(()) } +/// Whether a locked cell on a protected sheet stays locked: `None` when it's in an Allow Edit +/// Range without a password or one unlocked in this session, else the message to show. +pub(crate) fn locked_cell_error(sh: &gridcraft_model::Sheet, c: CellRef, unlocked: &[(String, String)]) -> Option { + let mut needs_password = None; + for r in sh.edit_ranges.iter().filter(|r| r.contains(c)) { + if r.password.is_none() || unlocked.iter().any(|(s, t)| *s == sh.name && *t == r.title) { + return None; + } + needs_password.get_or_insert(r.title.as_str()); + } + Some(match needs_password { + Some(t) => format!("The range \"{t}\" needs its password before it can be changed (Review › Allow Edit Ranges)."), + None => PROTECTED.into(), + }) +} + /// Two cells hold the same value or formula (formats aside). fn same_content(a: Option<&gridcraft_model::Cell>, b: Option<&gridcraft_model::Cell>) -> bool { let empty = |c: Option<&gridcraft_model::Cell>| c.is_none_or(|c| c.formula.is_none() && c.value.is_empty()); diff --git a/crates/engine/src/cmd/review.rs b/crates/engine/src/cmd/review.rs index 1613e04..12b296b 100644 --- a/crates/engine/src/cmd/review.rs +++ b/crates/engine/src/cmd/review.rs @@ -1,6 +1,6 @@ //! Review tab: protection, spelling (basic), workbook statistics. -use gridcraft_model::{SheetProtection, password_hash}; +use gridcraft_model::{EditRange, PasswordHash, SheetProtection, password_hash}; use serde_json::{Value as Json, json}; use super::*; @@ -19,6 +19,18 @@ pub fn specs() -> Vec { cmd!("review.unprotectSheet", "Unprotect Sheet", ["Review", "Protect"], None, "{password?}", has_doc, unprotect_sheet), cmd!("review.protectWorkbook", "Protect Workbook", ["Review", "Protect"], None, "{on?: bool}", has_doc, protect_workbook), cmd!(query "review.workbookStatistics", "Workbook Statistics", ["Review", "Proofing"], None, "{}", has_doc, stats), + // Not journaled: the params can hold a password. + CommandSpec { + id: "review.allowEditRanges", + label: "Allow Edit Ranges…", + menu: &["Review", "Protect"], + shortcut: None, + params: "{action?: list (default) | add | modify | delete | unlock, title?, range?, password?, newTitle?, sheet?} → ranges", + enabled: has_doc, + run: allow_edit_ranges, + journal: false, + undoable: true, + }, cmd!(query "review.checkAccessibility", "Check Accessibility", ["Review", "Accessibility"], None, "{} → issues", has_doc, accessibility), ] } @@ -60,6 +72,117 @@ fn unprotect_sheet(s: &mut Session, p: &Json) -> Result { }) } +fn range_text(r: &EditRange) -> String { + r.ranges.iter().map(|x| x.a1()).collect::>().join(",") +} + +/// Review › Allow Edit Ranges: list, add, modify or delete the ranges that stay editable on a +/// protected sheet, or unlock a password range for this session. +fn allow_edit_ranges(s: &mut Session, p: &Json) -> Result { + static SALT_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let sheet = target_sheet(s, p)?; + let action = str_param(p, "action").unwrap_or("list"); + let title = str_param(p, "title").map(str::trim).filter(|t| !t.is_empty()); + let d = s.doc()?; + let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?; + let sheet_name = sh.name.clone(); + let find = |t: Option<&str>| -> Result { + let t = t.ok_or_else(|| bad("review.allowEditRanges", "missing `title`"))?; + sh.edit_ranges + .iter() + .position(|r| r.title.eq_ignore_ascii_case(t)) + .ok_or_else(|| bad("review.allowEditRanges", format!("no range titled `{t}`"))) + }; + let hash = |pw: &str, title: &str| { + let n = SALT_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let seed = format!("{sheet_name}\u{0}{title}\u{0}{n}\u{0}{}\u{0}{}", d.uid, gridcraft_calc::now_serial().to_bits()); + PasswordHash::new_sha512(pw, &gridcraft_model::password::salt_from(seed.as_bytes())) + }; + if matches!(action, "add" | "modify" | "delete") && sh.is_protected() { + return Err(EngineError::Other("Unprotect the sheet to change its edit ranges.".into())); + } + match action { + "list" => {} + "unlock" => { + let i = find(title)?; + let Some(r) = sh.edit_ranges.get(i) else { return Err(bad("review.allowEditRanges", "no such range")) }; + if let Some(h) = &r.password { + let pw = str_param(p, "password").unwrap_or(""); + if !h.verify(pw).map_err(EngineError::Other)? { + return Err(EngineError::Other("The password you supplied is not correct.".into())); + } + let key = (sheet_name.clone(), r.title.clone()); + let d = s.doc_mut()?; + if !d.unlocked_ranges.contains(&key) { + d.unlocked_ranges.push(key); + } + } + } + "add" => { + let t = match title { + Some(t) => t.to_string(), + None => { + (1..).map(|n| format!("Range{n}")).find(|t| !sh.edit_ranges.iter().any(|r| r.title.eq_ignore_ascii_case(t))).unwrap_or_default() + } + }; + if sh.edit_ranges.iter().any(|r| r.title.eq_ignore_ascii_case(&t)) { + return Err(bad("review.allowEditRanges", format!("a range titled `{t}` already exists"))); + } + let ranges = target_ranges(s, p)?; + let password = str_param(p, "password").filter(|pw| !pw.is_empty()).map(|pw| hash(pw, &t)); + let r = EditRange { title: t, ranges, password, security_descriptor: None }; + edit(s, |cx| { + cx.sheet_mut(sheet)?.edit_ranges.push(r.clone()); + Ok(()) + })?; + } + "modify" => { + let i = find(title)?; + let mut r = sh.edit_ranges.get(i).cloned().ok_or_else(|| bad("review.allowEditRanges", "no such range"))?; + if let Some(n) = str_param(p, "newTitle").map(str::trim).filter(|n| !n.is_empty()) { + if sh.edit_ranges.iter().enumerate().any(|(j, x)| j != i && x.title.eq_ignore_ascii_case(n)) { + return Err(bad("review.allowEditRanges", format!("a range titled `{n}` already exists"))); + } + r.title = n.to_string(); + } + if let Some(pw) = str_param(p, "password") { + r.password = (!pw.is_empty()).then(|| hash(pw, &r.title)); + } + if p.get("range").is_some() { + r.ranges = target_ranges(s, p)?; + } + edit(s, |cx| { + if let Some(x) = cx.sheet_mut(sheet)?.edit_ranges.get_mut(i) { + *x = r.clone(); + } + Ok(()) + })?; + } + "delete" => { + let i = find(title)?; + edit(s, |cx| { + let list = &mut cx.sheet_mut(sheet)?.edit_ranges; + if i < list.len() { + list.remove(i); + } + Ok(()) + })?; + } + other => return Err(bad("review.allowEditRanges", format!("unknown action `{other}`"))), + } + let d = s.doc()?; + let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?; + Ok(Json::Array( + sh.edit_ranges + .iter() + .map(|r| { + let unlocked = r.password.is_none() || d.unlocked_ranges.iter().any(|(s, t)| *s == sh.name && *t == r.title); + json!({"title": r.title, "range": range_text(r), "password": r.password.is_some(), "unlocked": unlocked}) + }) + .collect(), + )) +} + fn protect_workbook(s: &mut Session, p: &Json) -> Result { let cur = s.doc()?.wb.protected_structure; let on = bool_param(p, "on").unwrap_or(!cur); @@ -97,3 +220,61 @@ fn accessibility(s: &mut Session, _: &Json) -> Result { } Ok(Json::Array(issues)) } + +#[cfg(test)] +mod tests { + use super::*; + + fn protected_book() -> Session { + let mut s = Session::new(); + s.new_workbook(); + s.execute("review.allowEditRanges", json!({"action": "add", "title": "Open", "range": "A1:A3"})).unwrap(); + s.execute("review.allowEditRanges", json!({"action": "add", "title": "Budget", "range": "B1:B3,D1", "password": "pa55"})).unwrap(); + s.execute("review.protectSheet", json!({})).unwrap(); + s + } + + #[test] + fn edit_ranges_open_locked_cells_and_passwords_really_verify() { + let mut s = protected_book(); + s.execute("cell.set", json!({"cell": "A2", "input": "1"})).unwrap(); + assert!(s.execute("cell.set", json!({"cell": "C1", "input": "1"})).is_err()); + let err = s.execute("cell.set", json!({"cell": "D1", "input": "1"})).unwrap_err().to_string(); + assert!(err.contains("Budget"), "{err}"); + // A paste or fill is checked the same way. + assert!(s.execute("range.setValues", json!({"range": "B2", "values": [[5]]})).is_err()); + assert!(s.execute("review.allowEditRanges", json!({"action": "unlock", "title": "Budget", "password": "pass"})).is_err()); + assert!(s.execute("review.allowEditRanges", json!({"action": "unlock", "title": "Budget"})).is_err()); + let list = s.execute("review.allowEditRanges", json!({"action": "unlock", "title": "budget", "password": "pa55"})).unwrap(); + assert_eq!(list[1]["unlocked"], true); + s.execute("range.setValues", json!({"range": "B2", "values": [[5]]})).unwrap(); + s.execute("cell.set", json!({"cell": "D1", "input": "2"})).unwrap(); + // Ranges can't change while the sheet is protected. + assert!(s.execute("review.allowEditRanges", json!({"action": "delete", "title": "Open"})).is_err()); + } + + #[test] + fn edit_ranges_add_modify_delete_list_and_undo() { + let mut s = Session::new(); + s.new_workbook(); + // Empty params list; nothing to list yet. + assert_eq!(s.execute("review.allowEditRanges", json!({})).unwrap(), json!([])); + s.execute("selection.set", json!({"range": "C3:D4"})).unwrap(); + let list = s.execute("review.allowEditRanges", json!({"action": "add"})).unwrap(); + assert_eq!(list, json!([{"title": "Range1", "range": "C3:D4", "password": false, "unlocked": true}])); + assert!(s.execute("review.allowEditRanges", json!({"action": "add", "title": "range1", "range": "A1"})).is_err()); + let list = s + .execute("review.allowEditRanges", json!({"action": "modify", "title": "Range1", "newTitle": "Mine", "range": "E5", "password": "x"})) + .unwrap(); + assert_eq!((list[0]["title"].as_str(), list[0]["range"].as_str(), list[0]["password"].as_bool()), (Some("Mine"), Some("E5"), Some(true))); + // Inserting a row above moves the range with its cells. + s.execute("home.insertRows", json!({"rows": "1:1"})).unwrap(); + assert_eq!(s.execute("review.allowEditRanges", json!({})).unwrap()[0]["range"], "E6"); + s.execute("review.allowEditRanges", json!({"action": "delete", "title": "Mine"})).unwrap(); + assert_eq!(s.execute("review.allowEditRanges", json!({})).unwrap(), json!([])); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.execute("review.allowEditRanges", json!({})).unwrap()[0]["title"], "Mine"); + assert!(s.execute("review.allowEditRanges", json!({"action": "nope"})).is_err()); + assert!(s.execute("review.allowEditRanges", json!({"action": "delete"})).is_err()); + } +} diff --git a/crates/engine/src/cmd/sheet.rs b/crates/engine/src/cmd/sheet.rs index 24e6268..8d1c836 100644 --- a/crates/engine/src/cmd/sheet.rs +++ b/crates/engine/src/cmd/sheet.rs @@ -146,6 +146,10 @@ fn shift_sheet_features(sh: &mut Sheet, axis: Axis, at: u32, count: u32, insert: for cf in sh.cond_formats.iter_mut() { cf.ranges = cf.ranges.iter().filter_map(|r| map_range(*r)).collect(); } + for er in sh.edit_ranges.iter_mut() { + er.ranges = er.ranges.iter().filter_map(|r| map_range(*r)).collect(); + } + sh.edit_ranges.retain(|er| !er.ranges.is_empty()); sh.cond_formats.retain(|cf| !cf.ranges.is_empty()); for dv in sh.validations.iter_mut() { dv.ranges = dv.ranges.iter().filter_map(|r| map_range(*r)).collect(); @@ -693,6 +697,10 @@ fn shift_cell_features(sh: &mut Sheet, e: &Edit) { for cf in sh.cond_formats.iter_mut() { cf.ranges = cf.ranges.iter().filter_map(|r| map_range(*r, e)).collect(); } + for er in sh.edit_ranges.iter_mut() { + er.ranges = er.ranges.iter().filter_map(|r| map_range(*r, e)).collect(); + } + sh.edit_ranges.retain(|er| !er.ranges.is_empty()); sh.cond_formats.retain(|cf| !cf.ranges.is_empty()); for dv in sh.validations.iter_mut() { dv.ranges = dv.ranges.iter().filter_map(|r| map_range(*r, e)).collect(); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 06a321e..b6c0974 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -100,6 +100,8 @@ pub struct DocState { pub title: String, /// Per-sheet selection memory. pub sheet_selections: Vec, + /// Allow Edit Ranges unlocked with their password in this session: (sheet name, title). + pub unlocked_ranges: Vec<(String, String)>, } static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); @@ -123,6 +125,7 @@ impl DocState { uid: NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed), title, sheet_selections: vec![], + unlocked_ranges: vec![], } } pub fn is_dirty(&self) -> bool { diff --git a/crates/model/Cargo.toml b/crates/model/Cargo.toml index ff65691..761cf00 100644 --- a/crates/model/Cargo.toml +++ b/crates/model/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [dependencies] gridcraft-core = { workspace = true } +sha2 = { workspace = true } gridcraft-formula = { workspace = true } serde = { workspace = true } thiserror = { workspace = true } diff --git a/crates/model/src/features.rs b/crates/model/src/features.rs index e0219b4..b4194c6 100644 --- a/crates/model/src/features.rs +++ b/crates/model/src/features.rs @@ -565,6 +565,24 @@ pub struct SheetProtection { pub autofilter: bool, } +/// Review › Allow Edit Ranges: cells that stay editable on a protected sheet, with or without a +/// password (XLSX ``). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct EditRange { + pub title: String, + pub ranges: Vec, + pub password: Option, + /// Windows permissions (`securityDescriptor`), kept as read from the file. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub security_descriptor: Option, +} + +impl EditRange { + pub fn contains(&self, c: CellRef) -> bool { + self.ranges.iter().any(|r| r.contains(c)) + } +} + /// Simple non-cryptographic password hash for sheet protection (like Excel's, protection is a /// convenience, not security). pub fn password_hash(pw: &str) -> u64 { diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index e3b8b63..275d2b2 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -8,6 +8,7 @@ mod b64; pub mod cell; mod cellmap; pub mod features; +pub mod password; pub mod sheet; pub mod store; pub mod style; @@ -15,6 +16,7 @@ pub mod workbook; pub use cell::{Cell, CellPicture, Formula}; pub use features::*; +pub use password::PasswordHash; pub use sheet::{DEFAULT_COL_WIDTH, DEFAULT_ROW_HEIGHT, LineIndex, Sheet, Visibility}; pub use store::CellStore; pub use style::{ diff --git a/crates/model/src/password.rs b/crates/model/src/password.rs new file mode 100644 index 0000000..59b9aaf --- /dev/null +++ b/crates/model/src/password.rs @@ -0,0 +1,133 @@ +//! Excel-compatible password hashes for protection (ECMA-376 Part 1 §18.2.29 and §18.3.1.66 +//! attribute sets): the legacy 16-bit hash (`password="CC1A"`) and the iterated, salted hash +//! (`algorithmName`, `hashValue`, `saltValue`, `spinCount`). Protection is a convenience, not +//! security, but verification really checks the password: an unsupported variant is refused, +//! never accepted. + +use serde::{Deserialize, Serialize}; +use sha2::Digest; + +/// Excel's default spin count for new passwords. +pub const DEFAULT_SPIN_COUNT: u32 = 100_000; +/// Largest spin count accepted when verifying (the documented maximum); a file asking for more +/// would only make verification hang. +pub const MAX_SPIN_COUNT: u32 = 10_000_000; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum PasswordHash { + /// The legacy 16-bit hash. + Legacy(u16), + /// An iterated, salted hash as stored in the file: algorithm name, base64 hash and salt. + Iterated { algorithm: String, hash: String, salt: String, spin_count: u32 }, +} + +impl PasswordHash { + /// A SHA-512 hash with Excel's default spin count, for a new password. + pub fn new_sha512(password: &str, salt: &[u8]) -> PasswordHash { + let hash = iterated::(password, salt, DEFAULT_SPIN_COUNT); + PasswordHash::Iterated { + algorithm: "SHA-512".into(), + hash: crate::b64::encode(&hash), + salt: crate::b64::encode(salt), + spin_count: DEFAULT_SPIN_COUNT, + } + } + + /// Whether `password` matches. `Err` when the hash can't be checked (unsupported algorithm, + /// broken base64, spin count over [`MAX_SPIN_COUNT`]). + pub fn verify(&self, password: &str) -> Result { + match self { + PasswordHash::Legacy(h) => Ok(legacy_hash(password) == *h), + PasswordHash::Iterated { algorithm, hash, salt, spin_count } => { + if algorithm.is_empty() { + return Err("the stored password hash is damaged".into()); + } + if *spin_count > MAX_SPIN_COUNT { + return Err(format!("the password uses a spin count of {spin_count}, more than the {MAX_SPIN_COUNT} allowed")); + } + let (Some(want), Some(salt)) = (crate::b64::decode(hash), crate::b64::decode(salt)) else { + return Err("the stored password hash is damaged".into()); + }; + let got = match algorithm.to_ascii_uppercase().replace('_', "-").as_str() { + "SHA-512" => iterated::(password, &salt, *spin_count), + "SHA-384" => iterated::(password, &salt, *spin_count), + "SHA-256" => iterated::(password, &salt, *spin_count), + _ => return Err(format!("passwords hashed with {algorithm} aren't supported")), + }; + Ok(got == want) + } + } + } +} + +/// A 16-byte salt derived from `seed` (callers mix in what varies: names, a counter, the clock). +pub fn salt_from(seed: &[u8]) -> [u8; 16] { + let h = sha2::Sha512::digest(seed); + let mut out = [0u8; 16]; + for (o, b) in out.iter_mut().zip(h.iter()) { + *o = *b; + } + out +} + +/// The legacy 16-bit password hash: each UTF-16 unit contributes its low byte (its high byte +/// when the low one is zero), folded in from the last character with a 15-bit rotation, then +/// the length and the constant `0xCE4B`. +pub fn legacy_hash(password: &str) -> u16 { + let bytes: Vec = password.encode_utf16().map(|u| if u & 0xFF != 0 { u & 0xFF } else { u >> 8 }).collect(); + let rotate = |v: u16| ((v >> 14) & 1) | ((v << 1) & 0x7FFF); + let mut h: u16 = 0; + for b in bytes.iter().rev() { + h = rotate(h) ^ b; + } + h = rotate(h) ^ (bytes.len() as u16); + h ^ 0xCE4B +} + +/// H0 = H(salt + UTF-16LE password); Hn = H(Hn-1 + n as 32-bit little-endian), `spin` times. +fn iterated(password: &str, salt: &[u8], spin: u32) -> Vec { + let mut d = D::new(); + d.update(salt); + for u in password.encode_utf16() { + d.update(u.to_le_bytes()); + } + let mut h = d.finalize().to_vec(); + for i in 0..spin { + let mut d = D::new(); + d.update(&h); + d.update(i.to_le_bytes()); + h = d.finalize().to_vec(); + } + h +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn legacy_hash_matches_the_published_example() { + // The hash Excel writes for the password "password" (`password="83AF"`). + assert_eq!(legacy_hash("password"), 0x83AF); + assert!(PasswordHash::Legacy(0x83AF).verify("password").unwrap()); + assert!(!PasswordHash::Legacy(0x83AF).verify("Password").unwrap()); + assert_eq!(legacy_hash(""), 0xCE4B); + } + + #[test] + fn iterated_hash_verifies_and_refuses_what_it_cannot_check() { + let h = PasswordHash::new_sha512("s3cret", b"0123456789abcdef"); + assert!(h.verify("s3cret").unwrap()); + assert!(!h.verify("s3cret ").unwrap()); + assert!(matches!(&h, PasswordHash::Iterated { algorithm, spin_count: DEFAULT_SPIN_COUNT, .. } if algorithm == "SHA-512")); + let PasswordHash::Iterated { hash, salt, .. } = h else { return }; + let other = + |algorithm: &str, spin_count| PasswordHash::Iterated { algorithm: algorithm.into(), hash: hash.clone(), salt: salt.clone(), spin_count }; + assert!(other("SHA-1", 10).verify("s3cret").is_err()); + assert!(other("MD5", 10).verify("s3cret").is_err()); + assert!(other("SHA-512", MAX_SPIN_COUNT + 1).verify("s3cret").is_err()); + assert!(!other("SHA-256", 2).verify("s3cret").unwrap()); + let broken = PasswordHash::Iterated { algorithm: "SHA-512".into(), hash: "!!".into(), salt: salt.clone(), spin_count: 1 }; + assert!(broken.verify("s3cret").is_err()); + } +} diff --git a/crates/model/src/sheet.rs b/crates/model/src/sheet.rs index d2d82ed..ec5b70a 100644 --- a/crates/model/src/sheet.rs +++ b/crates/model/src/sheet.rs @@ -64,6 +64,9 @@ pub struct Sheet { #[serde(default)] pub pivots: Vec, pub protection: Option, + /// Allow Edit Ranges (meaningful while the sheet is protected). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub edit_ranges: Vec, pub print: PrintSettings, /// Last selection and scroll position, saved with the file. pub view_active: CellRef, @@ -108,6 +111,7 @@ impl Sheet { sparklines: vec![], pivots: vec![], protection: None, + edit_ranges: vec![], print: PrintSettings::default(), view_active: CellRef::default(), view_top_left: CellRef::default(), diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 8403388..353b63c 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -172,6 +172,44 @@ impl Dialog { ], json!({}), ), + "allowEditRanges" => { + let list = app.session.run("review.allowEditRanges", json!({})).unwrap_or(Json::Null); + let rows: Vec = list + .as_array() + .map(|a| { + a.iter() + .map(|r| { + let lock = match (r["password"].as_bool(), r["unlocked"].as_bool()) { + (Some(true), Some(true)) => " (password, unlocked)", + (Some(true), _) => " (password)", + _ => "", + }; + format!("{}: {}{lock}", r["title"].as_str().unwrap_or(""), r["range"].as_str().unwrap_or("")) + }) + .collect() + }) + .unwrap_or_default(); + let summary = if rows.is_empty() { "No ranges yet.".to_string() } else { rows.join("\n") }; + Dialog::form( + "allowEditRanges", + "Allow Edit Ranges", + "review.allowEditRanges", + vec![ + Note(format!("Ranges that stay editable when the sheet is protected:\n{summary}")), + Choice { + key: "action", + label: "Action", + options: vec![("add", "New"), ("modify", "Modify"), ("delete", "Delete"), ("unlock", "Unlock with password")], + }, + Text { key: "title", label: "Title:" }, + Text { key: "range", label: "Refers to cells:" }, + Text { key: "password", label: "Range password:" }, + Text { key: "newTitle", label: "New title (Modify):" }, + Check { key: "removePassword", label: "Remove the password (Modify)" }, + ], + json!({"action": "add", "range": sel}), + ) + } "unprotectSheet" => Dialog::form( "unprotectSheet", "Unprotect Sheet", @@ -637,6 +675,15 @@ fn build_params(d: &Dialog) -> Json { v["columns"] = Json::Array(t.split(',').map(|c| json!(c.trim().to_ascii_uppercase())).collect()); } } + "allowEditRanges" => { + if let Some(m) = v.as_object_mut() { + // Blank fields mean "unchanged"; the check box clears a password. + m.retain(|_, x| x.as_str().is_none_or(|s| !s.trim().is_empty())); + if m.remove("removePassword").and_then(|x| x.as_bool()) == Some(true) { + m.insert("password".into(), json!("")); + } + } + } "zoom" => { if let Some(p) = d.values.get("preset").and_then(Json::as_str) && d.values.get("percent").is_none() diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index bef81ba..5b8bafe 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -334,6 +334,8 @@ const JAPANESE: &[(&str, &str)] = &[ ("Threaded Comments", "スレッド化されたコメント"), ("Show\nComments", "コメントの表示"), ("Protect\nWorkbook", "ブックの保護"), + ("Allow Edit\nRanges", "範囲の編集を許可"), + ("Allow Edit Ranges", "範囲の編集を許可"), ("Unprotect\nSheet", "シート保護の解除"), ("Protect Sheet…", "シートの保護…"), ("Comments", "コメント"), diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index 39eb097..084cd5b 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -177,6 +177,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Threaded Comments" => "스레드 댓글", "Show\nComments" => "댓글\n표시", "Protect\nWorkbook" => "통합 문서\n보호", + "Allow Edit\nRanges" => "범위 편집\n허용", + "Allow Edit Ranges" => "범위 편집 허용", "Unprotect\nSheet" => "시트 보호\n해제", "Protect Sheet…" => "시트 보호…", "Comments" => "댓글", diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index 7711278..81f3a23 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -178,6 +178,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Threaded Comments" => "Comentários Encadeados", "Show\nComments" => "Mostrar\nComentários", "Protect\nWorkbook" => "Proteger\nPasta de Trabalho", + "Allow Edit\nRanges" => "Permitir Edição\nde Intervalos", + "Allow Edit Ranges" => "Permitir Edição de Intervalos", "Unprotect\nSheet" => "Desproteger\nPlanilha", "Protect Sheet…" => "Proteger Planilha…", "Comments" => "Comentários", diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 4cd1723..e2f784c 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -177,6 +177,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Threaded Comments" => "Комментарии с ветками", "Show\nComments" => "Показать\nкомментарии", "Protect\nWorkbook" => "Защитить\nкнигу", + "Allow Edit\nRanges" => "Разрешить\nизменение диапазонов", + "Allow Edit Ranges" => "Разрешить изменение диапазонов", "Unprotect\nSheet" => "Снять защиту\nлиста", "Protect Sheet…" => "Защитить лист…", "Comments" => "Комментарии", diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs index b583425..55de210 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -177,6 +177,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Threaded Comments" => "线程式批注", "Show\nComments" => "显示\n批注", "Protect\nWorkbook" => "保护\n工作簿", + "Allow Edit\nRanges" => "允许编辑\n区域", + "Allow Edit Ranges" => "允许编辑区域", "Unprotect\nSheet" => "撤销\n工作表保护", "Protect Sheet…" => "保护工作表…", "Comments" => "批注", diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 5df12d5..22ee968 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1682,6 +1682,9 @@ fn review(app: &mut SheetApp, ui: &mut Ui) { if big_button(ui, Icon::Book, "Protect\nWorkbook", "Protect Workbook structure", false).clicked() { act(app, "review.protectWorkbook", json!({})); } + if big_button(ui, Icon::Validation, "Allow Edit\nRanges", "Allow Edit Ranges", false).clicked() { + app.open_dialog("allowEditRanges", json!({})); + } } fn view(app: &mut SheetApp, ui: &mut Ui) { diff --git a/crates/xlsx/src/sheet_read.rs b/crates/xlsx/src/sheet_read.rs index 59796ae..11363bc 100644 --- a/crates/xlsx/src/sheet_read.rs +++ b/crates/xlsx/src/sheet_read.rs @@ -7,8 +7,8 @@ use gridcraft_core::date::{serial_from_ymd, time_fraction}; use gridcraft_core::{CellError, CellRef, DateSystem, MAX_COLS, MAX_ROWS, RangeRef, Value}; use gridcraft_formula::Expr; use gridcraft_model::{ - AutoFilter, Cell, CfOperator, CfRule, CfValueKind, Comment, CondFormat, ErrorStyle, FilterCriterion, Formula, Hyperlink, LineInfo, Orientation, - Sheet, SheetProtection, Sparkline, SparklineKind, Table, TableColumn, TotalsFn, Validation, ValidationKind, + AutoFilter, Cell, CfOperator, CfRule, CfValueKind, Comment, CondFormat, EditRange, ErrorStyle, FilterCriterion, Formula, Hyperlink, LineInfo, + Orientation, PasswordHash, Sheet, SheetProtection, Sparkline, SparklineKind, Table, TableColumn, TotalsFn, Validation, ValidationKind, }; use crate::IoError; @@ -183,6 +183,7 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result read_ext(cx, e, &mut sheet), + "protectedRanges" => read_protected_ranges(e, &mut sheet), "oleObjects" | "controls" => cx.warn(format!("sheet \"{name}\": embedded objects and form controls are not supported")), _ => {} } @@ -210,6 +211,33 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result = p.attr("sqref").unwrap_or("").split_whitespace().filter_map(RangeRef::parse).collect(); + if ranges.is_empty() { + continue; + } + let password = if p.attr("algorithmName").is_some() || p.attr("hashValue").is_some() { + Some(PasswordHash::Iterated { + algorithm: p.attr("algorithmName").unwrap_or("").to_string(), + hash: p.attr("hashValue").unwrap_or("").to_string(), + salt: p.attr("saltValue").unwrap_or("").to_string(), + spin_count: p.attr_u32("spinCount").unwrap_or(0), + }) + } else { + p.attr("password").map(|h| match u16::from_str_radix(h.trim(), 16) { + Ok(h) => PasswordHash::Legacy(h), + // Unreadable: keep it locked. + Err(_) => PasswordHash::Iterated { algorithm: String::new(), hash: String::new(), salt: String::new(), spin_count: 0 }, + }) + }; + let title = p.attr("name").unwrap_or("").to_string(); + sheet.edit_ranges.push(EditRange { title, ranges, password, security_descriptor: p.attr("securityDescriptor").map(str::to_string) }); + } +} + fn read_views(e: &El, sheet: &mut Sheet) { let Some(v) = e.child("sheetView") else { return }; sheet.show_gridlines = v.flag("showGridLines", true); diff --git a/crates/xlsx/src/sheet_write.rs b/crates/xlsx/src/sheet_write.rs index 1a841ac..276b13c 100644 --- a/crates/xlsx/src/sheet_write.rs +++ b/crates/xlsx/src/sheet_write.rs @@ -331,6 +331,7 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> ( } s.push_str("/>"); } + s.push_str(&protected_ranges_xml(sheet)); // autoFilter (a sheet filter overlapping a table would be invalid). if let Some(af) = &sheet.autofilter && !sheet.tables.iter().any(|t| t.range.intersects(&af.range)) @@ -1103,6 +1104,35 @@ fn threaded_xml(sheet: &Sheet, ids: &BTreeMap>, persons: &m s } +/// Allow Edit Ranges: `` (after `sheetProtection`). +fn protected_ranges_xml(sheet: &Sheet) -> String { + let mut s = String::new(); + for r in sheet.edit_ranges.iter().filter(|r| !r.ranges.is_empty()) { + let sqref: Vec = r.ranges.iter().map(|x| x.a1()).collect(); + let _ = write!(s, " { + let _ = write!(s, " password=\"{h:04X}\""); + } + Some(gridcraft_model::PasswordHash::Iterated { algorithm, hash, salt, spin_count }) => { + let _ = write!( + s, + " algorithmName=\"{}\" hashValue=\"{}\" saltValue=\"{}\" spinCount=\"{spin_count}\"", + esc_attr(algorithm), + esc_attr(hash), + esc_attr(salt) + ); + } + None => {} + } + if let Some(sd) = &r.security_descriptor { + let _ = write!(s, " securityDescriptor=\"{}\"", esc_attr(sd)); + } + s.push_str("/>"); + } + if s.is_empty() { s } else { format!("{s}") } +} + /// Legacy VML shapes Excel uses to display notes. fn vml_xml(sheet: &Sheet, n: u32) -> String { let mut s = format!( diff --git a/crates/xlsx/src/tests/malformed.rs b/crates/xlsx/src/tests/malformed.rs index 8591971..b14e53a 100644 --- a/crates/xlsx/src/tests/malformed.rs +++ b/crates/xlsx/src/tests/malformed.rs @@ -178,3 +178,18 @@ fn hostile_threaded_comments_persons_and_note_vml() { let (back, _) = read_xlsx(&super::make_zip(&refs)).unwrap(); assert!(back.sheets[0].comments[&CellRef::new(1, 1)].visible); } + +/// Allow Edit Ranges from a hostile file: bad references are dropped and a password that can't be +/// read keeps the range locked instead of opening it. +#[test] +fn hostile_protected_ranges_stay_locked() { + let body = r#""#; + let (wb, _) = read_xlsx(&minimal(body, &[], "", "")).unwrap(); + let ranges = &wb.sheets[0].edit_ranges; + assert_eq!(ranges.iter().map(|r| r.title.as_str()).collect::>(), ["hex", "spin", "half", "md5"]); + for r in ranges { + let h = r.password.as_ref().unwrap(); + assert!(!matches!(h.verify(""), Ok(true)), "{}", r.title); + assert!(!matches!(h.verify("password"), Ok(true)), "{}", r.title); + } +} diff --git a/crates/xlsx/src/tests/roundtrip.rs b/crates/xlsx/src/tests/roundtrip.rs index a627c5d..76b5ac0 100644 --- a/crates/xlsx/src/tests/roundtrip.rs +++ b/crates/xlsx/src/tests/roundtrip.rs @@ -187,6 +187,21 @@ fn sample() -> Workbook { allow_blank: false, ..Default::default() }); + s.edit_ranges = vec![ + EditRange { title: "Open & ".into(), ranges: vec![rr("A1:B2"), rr("D4")], password: None, security_descriptor: None }, + EditRange { + title: "Old".into(), + ranges: vec![rr("C1:C9")], + password: Some(PasswordHash::Legacy(0x83AF)), + security_descriptor: Some("O:WDG:WDD:(A;;CC;;;WD)".into()), + }, + EditRange { + title: "New".into(), + ranges: vec![rr("E1")], + password: Some(PasswordHash::new_sha512("pw", b"0123456789abcdef")), + security_descriptor: None, + }, + ]; s.hyperlinks.insert(at("A2"), Hyperlink { target: "https://example.com/?a=1&b=2".into(), tooltip: Some("go".into()) }); s.hyperlinks.insert(at("A3"), Hyperlink { target: "'Data & Stuff'!B2".into(), tooltip: None }); s.hyperlinks.insert(at("A4"), Hyperlink { target: "mailto:me@example.com".into(), tooltip: None }); @@ -435,6 +450,7 @@ fn full_roundtrip() { assert_eq!(b.comments, a.comments); assert_eq!(b.autofilter, a.autofilter); assert_eq!(b.protection, a.protection); + assert_eq!(b.edit_ranges, a.edit_ranges); assert_eq!(b.print, a.print); let mut sp = a.sparklines.clone(); sp[0].source = "'Data & Stuff'!B2:B3".into(); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 30596bf..d1c774f 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -16,6 +16,7 @@ support comes from `crates/engine/src/io.rs` (`FileKind`), `crates/xlsx/src` and | Format | Excel | GridCraft read | GridCraft write | Fidelity / notes | Tests | |---|---|---|---|---|---| | `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, styles, themes, merges, CF, validation, tables, notes (with their shown/hidden state) and threaded comments with replies, resolved state and timestamps (`threadedComments` and `persons` parts, #300), hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, calc settings. **Not preserved:** unmodelled parts and extensions (form controls, slicer caches, timelines, external links, OLE objects, custom XML, data model, query tables, chart details). Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` | +| `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, styles, themes, merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, Allow Edit Ranges (`protectedRanges` with their password hashes, #307), calc settings. **Not preserved:** unmodelled parts and extensions (form controls, slicer caches, timelines, external links, OLE objects, custom XML, data model, query tables, chart details). Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` | | `.xlsx` Strict Open XML | R/W | yes | no (writes transitional) | Excel also saves transitional by default | fixtures | | `.xlsm` Macro-Enabled Workbook | R/W | yes | yes, **without macros** | VBA project dropped with a warning; saving as `.xlsm` loses the macros (#6) | — | | `.xltx` / `.xltm` templates | R/W | yes | partial | Saved with the workbook content type, not the template one; "new from template" behaviour missing | — | @@ -75,5 +76,6 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Date | Change | Summary | |---|---|---| | 2026-10-11 | minor | XLSX: threaded comments and persons parts written (they came back as notes before); note visibility read from VML (#300) | +| 2026-10-11 | minor | XLSX: Allow Edit Ranges (`protectedRanges`) read and written with their password hashes (#307) | | 2026-10-10 | minor | Encrypted and `.xls` files are now recognised and refused with a message (#17) | | 2026-10-10 | major | Created: every Excel format with our read/write support; XLSB and ODS values-only imports recorded; `.xls`, encryption and large files flagged as beta gates | diff --git a/docs/gaps.md b/docs/gaps.md index efec0f2..6b42774 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -113,7 +113,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- [function-parity.md](function-parity.md) ### 16. Collaboration and review (F) -- No co-authoring, Show Changes, Allow Edit Ranges, comment navigation, notes ↔ comments +- No co-authoring, Show Changes, comment navigation, notes ↔ comments conversion; cloud storage requested (#86). **Estimate:** 35–55 h (co-authoring scope needs the owner). ### 17. Insert objects (F) @@ -156,6 +156,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- | 2026-10-11 | minor | §15: PERCENTOF and EUROCONVERT added (20 functions missing) | | 2026-10-11 | minor | §15: BAHTTEXT added (21 functions missing) | | 2026-10-11 | minor | §15: TRIMRANGE added (21 functions missing; trim-reference operators still missing) | +| 2026-10-11 | minor | §16: Allow Edit Ranges landed (#307) | | 2026-10-10 | minor | Updated for the 10-10 merges (#17, #31, #38, #62, #82, #104, #116) and the new chart resize bug (#190) | | 2026-10-10 | minor | Noted that no gap blocks the alpha gate | | 2026-10-10 | major | Created: 24 ranked gaps with evidence, from the full re-measure and the 60 open issues | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 613b5c0..815ae8b 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -276,7 +276,7 @@ Missing: - `data.solver` Solver (Analysis) - `data.analysisToolPak` Data Analysis (Analysis) -## Review (9 / 18, 50%) +## Review (10 / 18, 56%) Implemented: @@ -289,6 +289,7 @@ Implemented: - `review.showNote` Show/Hide Note (Notes) - `review.unprotectSheet` Unprotect Sheet (Protect) - `review.protectWorkbook` Protect Workbook (Protect) +- `review.allowEditRanges` Allow Edit Ranges (Protect) Missing: @@ -299,7 +300,6 @@ Missing: - `review.showComments` Show Comments (Comments) - `review.showAllNotes` Show All Notes (Notes) - `review.convertToComments` Convert to Comments (Notes) -- `review.allowEditRanges` Allow Edit Ranges (Protect) - `review.showChanges` Show Changes (Changes) ## View (20 / 20, 100%) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 08dabb6..64c7e7b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -90,7 +90,7 @@ performance 8, stability 8, localization 4, hardware 2, platforms 2, ecosystem 2 | What-if and analysis | 4 | ~50% | ~40% | 22–35 | Goal Seek, Scenario Manager, data tables. Missing: Solver, Analysis ToolPak, Forecast Sheet, Analyze Data | | Get & Transform, external data, data model | 5 | ~15% | ~10% | 60–100 | Import of CSV/TSV, JSON, HTML. No Power Query editor, no From Web, database or folder sources, no connections or refresh, no relationships or data model, no external workbook links (dropped with a warning) | | Insert objects | 3 | ~55% | ~45% | 25–40 | Pictures, 8 shape kinds (Excel has ~170), text boxes, own icon set, ink with Ink to Shape, hyperlinks, checkboxes. Missing: SmartArt, WordArt, equations, signature line, objects, 3-D models, screenshot, pictures in cells (PR #105) | -| Review, comments, protection, collaboration | 4 | ~55% | ~40% | 35–55 | Threaded comments, notes, spelling (system dictionary), thesaurus, sheet and workbook protection. Missing: comment navigation, Show Changes, Allow Edit Ranges, notes-to-comments conversion, accessibility checker, co-authoring, opening password-encrypted files (refused with a message since #17) | +| Review, comments, protection, collaboration | 4 | ~55% | ~40% | 35–55 | Threaded comments, notes, spelling (system dictionary), thesaurus, sheet and workbook protection, Allow Edit Ranges with Excel-compatible password hashes (#307). Missing: comment navigation, Show Changes, notes-to-comments conversion, accessibility checker, co-authoring, opening password-encrypted files (refused with a message since #17) | | Automation | 4 | ~30% | ~20% | 12–20 | Recorded, editable GridCraft scripts (Automate tab). VBA is out of scope by owner decision and is **dropped** when an `.xlsm` is opened (warning shown). No Office Scripts compatibility | | **Weighted** | 100 | **~75%** | **~59%** | **300–485** | | @@ -337,6 +337,7 @@ The inventory carried over from the 2026-10-07 ROADMAP.md, updated for what land | 2026-10-11 | minor | Worksheet functions 502 / 523 (96.0%; 493 working): BAHTTEXT | | 2026-10-11 | minor | Worksheet functions 502 / 523 (96.0%; 493 working): TRIMRANGE | | 2026-10-11 | minor | View › Formula Bar and Zoom 100% are engine commands (CLI/MCP); catalog 258 → 260 / 290 (#289) | +| 2026-10-11 | minor | Review › Allow Edit Ranges; catalog 258 → 259 / 290 (#307) | | 2026-10-10 | minor | Readiness-by-audience table: hours to ~95% for each of the three numbers (full 650–1,050, mainstream 200–310, essentials 60–100). Full number confirmed as the additive weighted sum over the written dimension weights (no change) | | 2026-10-10 | minor | Added Mainstream practitioner (~50%) and Essentials user (~63%) with written weights and discounts, and user-evidence counts; re-checked the full number after the 10-10 merges (charts 38→30, localization 5→10; still 51.6, ~50%) | | 2026-10-10 | minor | Stage checked against the new alpha gate (six core workflows, all pass): stays alpha |