Merge pull request #308 from vangel76/feat/review-allow-edit-ranges

Review: Allow Edit Ranges with Excel-compatible password hashes (#307)

# Conflicts:
#	ROADMAP.md
#	crates/xlsx/src/sheet_write.rs
#	crates/xlsx/src/tests/malformed.rs
#	docs/file-format-parity.md
#	docs/gaps.md
#	docs/parity-checklist.md
#	docs/target-app-parity.md
This commit is contained in:
Brandon Thomas 2026-10-11 12:05:30 -04:00
commit 2af0984af0
29 changed files with 609 additions and 17 deletions

66
Cargo.lock generated
View File

@ -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"

View File

@ -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

View File

@ -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 |

View File

@ -199,10 +199,13 @@ fn cell_set(s: &mut Session, p: &Json) -> Result<Json> {
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 {

View File

@ -782,7 +782,7 @@ fn insert_picture(s: &mut Session, p: &Json) -> Result<Json> {
};
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() };

View File

@ -272,7 +272,7 @@ pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>)
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<String> {
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());

View File

@ -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<CommandSpec> {
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<Json> {
})
}
fn range_text(r: &EditRange) -> String {
r.ranges.iter().map(|x| x.a1()).collect::<Vec<_>>().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<Json> {
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<usize> {
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<Json> {
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<Json> {
}
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());
}
}

View File

@ -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();

View File

@ -100,6 +100,8 @@ pub struct DocState {
pub title: String,
/// Per-sheet selection memory.
pub sheet_selections: Vec<Selection>,
/// 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 {

View File

@ -10,6 +10,7 @@ workspace = true
[dependencies]
gridcraft-core = { workspace = true }
sha2 = { workspace = true }
gridcraft-formula = { workspace = true }
serde = { workspace = true }
thiserror = { workspace = true }

View File

@ -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 `<protectedRange>`).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct EditRange {
pub title: String,
pub ranges: Vec<RangeRef>,
pub password: Option<crate::PasswordHash>,
/// Windows permissions (`securityDescriptor`), kept as read from the file.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub security_descriptor: Option<String>,
}
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 {

View File

@ -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::{

View File

@ -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::<sha2::Sha512>(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<bool, String> {
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::<sha2::Sha512>(password, &salt, *spin_count),
"SHA-384" => iterated::<sha2::Sha384>(password, &salt, *spin_count),
"SHA-256" => iterated::<sha2::Sha256>(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<u16> = 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<D: Digest>(password: &str, salt: &[u8], spin: u32) -> Vec<u8> {
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());
}
}

View File

@ -64,6 +64,9 @@ pub struct Sheet {
#[serde(default)]
pub pivots: Vec<PivotTable>,
pub protection: Option<SheetProtection>,
/// Allow Edit Ranges (meaningful while the sheet is protected).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub edit_ranges: Vec<crate::EditRange>,
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(),

View File

@ -172,6 +172,44 @@ impl Dialog {
],
json!({}),
),
"allowEditRanges" => {
let list = app.session.run("review.allowEditRanges", json!({})).unwrap_or(Json::Null);
let rows: Vec<String> = 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()

View File

@ -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", "コメント"),

View File

@ -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" => "댓글",

View File

@ -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",

View File

@ -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" => "Комментарии",

View File

@ -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" => "批注",

View File

@ -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) {

View File

@ -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<Sheet, IoE
}
}
"extLst" => 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<Sheet, IoE
Ok(sheet)
}
/// Allow Edit Ranges. A range whose password can't be read keeps a hash that never matches
/// (or an unsupported algorithm), so it stays locked rather than open.
fn read_protected_ranges(e: &El, sheet: &mut Sheet) {
for p in e.kids("protectedRange") {
let ranges: Vec<RangeRef> = 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);

View File

@ -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<CellRef, Vec<String>>, persons: &m
s
}
/// Allow Edit Ranges: `<protectedRanges>` (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<String> = r.ranges.iter().map(|x| x.a1()).collect();
let _ = write!(s, "<protectedRange name=\"{}\" sqref=\"{}\"", esc_attr(&r.title), sqref.join(" "));
match &r.password {
Some(gridcraft_model::PasswordHash::Legacy(h)) => {
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!("<protectedRanges>{s}</protectedRanges>") }
}
/// Legacy VML shapes Excel uses to display notes.
fn vml_xml(sheet: &Sheet, n: u32) -> String {
let mut s = format!(

View File

@ -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#"<sheetData/><protectedRanges><protectedRange name="bad ref" sqref="ZZZZZ1 A0"/><protectedRange name="hex" sqref="A1" password="XYZ"/><protectedRange name="spin" sqref="B1" algorithmName="SHA-512" hashValue="AAAA" saltValue="AAAA" spinCount="4294967295"/><protectedRange name="half" sqref="C1" hashValue="AAAA"/><protectedRange name="md5" sqref="D1" algorithmName="MD5" hashValue="AAAA" saltValue="AAAA" spinCount="1"/></protectedRanges>"#;
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::<Vec<_>>(), ["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);
}
}

View File

@ -187,6 +187,21 @@ fn sample() -> Workbook {
allow_blank: false,
..Default::default()
});
s.edit_ranges = vec![
EditRange { title: "Open & <free>".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();

View File

@ -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 |

View File

@ -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 |

View File

@ -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%)

View File

@ -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 |