//! Review tab: protection, spelling (basic), workbook statistics, comment navigation and the //! Comments pane, Show All Notes and Convert to Comments. use gridcraft_core::CellRef; use gridcraft_model::{EditRange, PasswordHash, SheetProtection, Visibility, password_hash}; use serde_json::{Value as Json, json}; use super::*; pub fn specs() -> Vec { vec![ cmd!( "review.protectSheet", "Protect Sheet…", ["Review", "Protect"], None, "{password?, formatCells?, formatColumns?, formatRows?, insertRows?, insertColumns?, deleteRows?, deleteColumns?, sort?, autofilter?}", has_doc, protect_sheet ), cmd!("review.unprotectSheet", "Unprotect Sheet", ["Review", "Protect"], None, "{password?}", has_doc, unprotect_sheet), cmd!( "review.protectWorkbook", "Protect Workbook", ["Review", "Protect"], None, "{on?: bool (default: toggle), password?} (structure protection; unprotecting needs the password when one was set)", 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), cmd!(noundo "review.previousComment", "Previous Comment", ["Review", "Comments"], None, "{} → {sheet, cell}", has_comments, |s, _| { step_comment(s, false) }), cmd!(noundo "review.nextComment", "Next Comment", ["Review", "Comments"], None, "{} → {sheet, cell}", has_comments, |s, _| step_comment( s, true )), cmd!(query "review.showComments", "Show Comments", ["Review", "Comments"], None, "{on?: bool} (toggles the Comments pane)", has_doc, |s, p| { s.comments_pane = bool_param(p, "on").unwrap_or(!s.comments_pane); Ok(json!({"on": s.comments_pane})) }), cmd!(query "review.showAllNotes", "Show All Notes", ["Review", "Notes"], None, "{on?: bool}", has_doc, |s, p| { s.show_all_notes = bool_param(p, "on").unwrap_or(!s.show_all_notes); Ok(json!({"on": s.show_all_notes})) }), cmd!( "review.convertToComments", "Convert to Comments", ["Review", "Notes"], None, "{} (every note in the workbook) → {converted}", has_doc, convert_notes ), ] } /// Threaded comments in navigation order: visible sheets in tab order, then row by row. fn comment_cells(s: &Session) -> Vec<(usize, CellRef)> { let Some(d) = s.active() else { return vec![] }; d.wb.sheets .iter() .enumerate() .filter(|(_, sh)| sh.visibility == Visibility::Visible) // `CellRef` orders by row, then column: reading order. .flat_map(|(i, sh)| sh.comments.iter().filter(|(_, c)| c.threaded).map(move |(at, _)| (i, *at))) .collect() } fn has_comments(s: &Session) -> std::result::Result<(), String> { has_doc(s)?; if comment_cells(s).is_empty() { Err("there are no comments in this workbook".into()) } else { Ok(()) } } /// Selects the next (or previous) threaded comment after the active cell, moving on to the next /// sheet and wrapping around at the end of the workbook. fn step_comment(s: &mut Session, forward: bool) -> Result { let list = comment_cells(s); let d = s.doc()?; let here = (d.wb.active_sheet, d.selection.active); let target = if forward { list.iter().find(|c| **c > here).or_else(|| list.first()) } else { list.iter().rev().find(|c| **c < here).or_else(|| list.last()) }; let Some(&(sheet, at)) = target else { return Err(EngineError::Other("There are no comments in this workbook.".into())); }; let d = s.doc_mut()?; d.wb_switch_sheet(sheet); d.selection = crate::Selection::at(at); if let Some(sh) = d.wb.active() { d.selection.expand_merges(sh); } let name = d.wb.active().map(|sh| sh.name.clone()).unwrap_or_default(); Ok(json!({"sheet": name, "cell": at.a1()})) } /// Review › Notes › Convert to Comments: every note in the workbook becomes a threaded comment /// with the same author and text. fn convert_notes(s: &mut Session, _: &Json) -> Result { let sheets: Vec = s.doc()?.wb.sheets.iter().enumerate().filter(|(_, sh)| sh.comments.values().any(|c| !c.threaded)).map(|(i, _)| i).collect(); if sheets.is_empty() { return Ok(json!({"converted": 0})); } edit(s, |cx| { let mut n = 0usize; for i in &sheets { for c in cx.sheet_mut(*i)?.comments.values_mut().filter(|c| !c.threaded) { c.threaded = true; c.visible = false; n += 1; } } Ok(json!({"converted": n})) }) } fn protect_sheet(s: &mut Session, p: &Json) -> Result { let sheet = target_sheet(s, p)?; // Re-protecting a password-protected sheet would replace the password without knowing it: // unprotect it first, as in Excel. One without a password can take new options directly. let has_password = |pr: &SheetProtection| pr.password.is_some() || pr.legacy_password_hash.is_some(); if s.doc()?.wb.sheet(sheet).and_then(|sh| sh.protection.as_ref()).is_some_and(has_password) { return Err(EngineError::Other("The sheet is protected with a password. Unprotect it first (Review › Unprotect Sheet).".into())); } let b = |k: &str| bool_param(p, k).unwrap_or(false); let prot = SheetProtection { password: None, legacy_password_hash: None, select_locked: true, select_unlocked: true, format_cells: b("formatCells"), format_columns: b("formatColumns"), format_rows: b("formatRows"), insert_columns: b("insertColumns"), insert_rows: b("insertRows"), delete_columns: b("deleteColumns"), delete_rows: b("deleteRows"), sort: b("sort"), autofilter: b("autofilter"), }; let d = s.doc()?; let what = d.wb.sheet(sheet).map(|sh| sh.name.clone()).unwrap_or_default(); let prot = SheetProtection { password: str_param(p, "password").filter(|x| !x.is_empty()).map(|pw| new_password_hash(d, &what, pw)), ..prot }; edit(s, |cx| { cx.sheet_mut(sheet)?.protection = Some(prot.clone()); Ok(Json::Null) }) } fn unprotect_sheet(s: &mut Session, p: &Json) -> Result { let sheet = target_sheet(s, p)?; if let Some(pr) = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.protection.as_ref()) { let pw = str_param(p, "password").unwrap_or(""); let ok = match (&pr.password, pr.legacy_password_hash) { (Some(h), _) => h.verify(pw).map_err(EngineError::Other)?, (None, Some(h)) => password_hash(pw) == h, (None, None) => true, }; if !ok { return Err(EngineError::Other("The password you supplied is not correct.".into())); } } edit(s, |cx| { cx.sheet_mut(sheet)?.protection = None; Ok(Json::Null) }) } /// A new Excel-compatible (SHA-512, salted) hash of `pw`. The salt mixes `what` is protected /// with the document, a counter and the clock, so equal passwords get different hashes. fn new_password_hash(d: &DocState, what: &str, pw: &str) -> PasswordHash { static SALT_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); let n = SALT_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed); let seed = format!("{what}\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())) } 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 { 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| new_password_hash(d, &format!("{sheet_name}\u{0}{title}"), pw); 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(), )) } /// Review › Protect Workbook: lock the workbook structure (sheets can't be added, moved, /// renamed, hidden or deleted), optionally with a password that unprotecting must match. fn protect_workbook(s: &mut Session, p: &Json) -> Result { let d = s.doc()?; let cur = d.wb.protected_structure; let on = bool_param(p, "on").unwrap_or(!cur); if on == cur { return Ok(json!({"on": on})); } let password = if on { str_param(p, "password").filter(|pw| !pw.is_empty()).map(|pw| new_password_hash(d, "workbook", pw)) } else { if let Some(h) = &d.wb.structure_password && !h.verify(str_param(p, "password").unwrap_or("")).map_err(EngineError::Other)? { return Err(EngineError::Other("The password you supplied is not correct.".into())); } None }; edit(s, |cx| { cx.wb.protected_structure = on; cx.wb.structure_password = password.clone(); Ok(json!({"on": on})) }) } fn stats(s: &mut Session, _: &Json) -> Result { let d = s.doc()?; let sh = d.wb.active().ok_or(EngineError::NoDocument)?; let formulas = sh.cells.iter().filter(|(_, c)| c.formula.is_some()).count(); Ok(json!({ "sheet": {"name": sh.name, "endOfSheet": sh.used_range().map(|r| r.end.a1()), "cellsWithData": sh.cells.iter().filter(|(_, c)| !c.value.is_empty()).count(), "tables": sh.tables.len(), "formulas": formulas, "charts": sh.charts.len(), "comments": sh.comments.len()}, "workbook": {"sheets": d.wb.sheets.len(), "cellsWithData": d.wb.sheets.iter().map(|s| s.cells.len()).sum::(), "tables": d.wb.sheets.iter().map(|s| s.tables.len()).sum::(), "charts": d.wb.sheets.iter().map(|s| s.charts.len()).sum::(), "names": d.wb.names.len()} })) } fn accessibility(s: &mut Session, _: &Json) -> Result { let d = s.doc()?; let mut issues = Vec::new(); for sh in &d.wb.sheets { for im in &sh.images { if im.alt.is_empty() { issues.push(json!({"sheet": sh.name, "issue": "Missing alternative text", "object": format!("Picture {}", im.id)})); } } if !sh.merges.is_empty() { issues.push(json!({"sheet": sh.name, "issue": "Merged cells", "count": sh.merges.len()})); } if sh.name.starts_with("Sheet") { issues.push(json!({"sheet": sh.name, "issue": "Default sheet name"})); } } Ok(Json::Array(issues)) } #[cfg(test)] mod tests { use super::*; fn book() -> Session { let mut s = Session::new(); s.new_workbook(); s.execute("home.insertSheet", json!({"name": "Two"})).unwrap(); s.execute("sheet.activate", json!({"sheet": 0})).unwrap(); for (sheet, cell) in [(0, "C1"), (0, "A3"), (1, "B2")] { s.execute("review.newComment", json!({"sheet": sheet, "cell": cell, "text": "c", "author": "Ann"})).unwrap(); } s.execute("review.newNote", json!({"sheet": 0, "cell": "A2", "text": "note", "author": "Bob"})).unwrap(); s } fn at(s: &Session) -> (usize, String) { let d = s.doc().unwrap(); (d.wb.active_sheet, d.selection.active.a1()) } #[test] fn next_and_previous_comment_go_in_reading_order_across_sheets_and_wrap() { let mut s = book(); s.execute("selection.set", json!({"cell": "B1"})).unwrap(); // Notes are skipped: A2 holds a note, not a comment. let order = [(0, "C1"), (0, "A3"), (1, "B2"), (0, "C1")]; for (sheet, cell) in order { s.execute("review.nextComment", json!({})).unwrap(); assert_eq!(at(&s), (sheet, cell.to_string())); } for (sheet, cell) in [(1, "B2"), (0, "A3"), (0, "C1")] { s.execute("review.previousComment", json!({})).unwrap(); assert_eq!(at(&s), (sheet, cell.to_string())); } } #[test] fn comment_navigation_is_disabled_without_comments() { let mut s = Session::new(); s.new_workbook(); s.execute("review.newNote", json!({"cell": "A1", "text": "note"})).unwrap(); assert!(s.execute("review.nextComment", json!({})).is_err()); assert!(s.execute("review.previousComment", json!({})).is_err()); assert_eq!(at(&s), (0, "A1".to_string())); } #[test] fn sheet_passwords_use_excel_hashes_and_old_json_hashes_still_verify() { let mut s = book(); s.execute("review.protectSheet", json!({"password": "pa55"})).unwrap(); let prot = s.doc().unwrap().wb.active().unwrap().protection.clone().unwrap(); assert!(matches!(&prot.password, Some(PasswordHash::Iterated { algorithm, .. }) if algorithm == "SHA-512")); assert!(s.execute("review.unprotectSheet", json!({})).is_err()); assert!(s.execute("review.unprotectSheet", json!({"password": "pass"})).is_err()); // Protecting again can't swap the password out from under it. assert!(s.execute("review.protectSheet", json!({})).is_err()); assert_eq!(s.doc().unwrap().wb.active().unwrap().protection.as_ref(), Some(&prot)); s.execute("review.unprotectSheet", json!({"password": "pa55"})).unwrap(); assert!(!s.doc().unwrap().wb.active().unwrap().is_protected()); // A GridCraft JSON workbook saved before carried the internal hash under `password_hash`. let old = format!( r#"{{"password_hash": {}, "select_locked": true, "select_unlocked": true, "format_cells": false, "format_columns": false, "format_rows": false, "insert_columns": false, "insert_rows": false, "delete_columns": false, "delete_rows": false, "sort": false, "autofilter": false}}"#, password_hash("old") ); let prot: SheetProtection = serde_json::from_str(&old).unwrap(); assert_eq!((prot.legacy_password_hash, prot.password.is_none()), (Some(password_hash("old")), true)); let i = s.doc().unwrap().wb.active_sheet; let wb = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb); std::sync::Arc::make_mut(&mut wb.sheets[i]).protection = Some(prot); assert!(s.execute("review.unprotectSheet", json!({"password": "new"})).is_err()); s.execute("review.unprotectSheet", json!({"password": "old"})).unwrap(); } #[test] fn protect_workbook_with_a_password_really_verifies() { let mut s = book(); s.execute("review.protectWorkbook", json!({"password": "pa55"})).unwrap(); assert!(s.doc().unwrap().wb.protected_structure); assert!(s.execute("home.insertSheet", json!({"name": "Three"})).is_err(), "the structure is locked"); // A second Protect keeps it protected; unprotecting needs the right password. assert_eq!(s.execute("review.protectWorkbook", json!({"on": true})).unwrap()["on"], true); assert!(s.execute("review.protectWorkbook", json!({})).is_err()); assert!(s.execute("review.protectWorkbook", json!({"on": false, "password": "pass"})).is_err()); assert!(s.doc().unwrap().wb.protected_structure); s.execute("review.protectWorkbook", json!({"on": false, "password": "pa55"})).unwrap(); let wb = &s.doc().unwrap().wb; assert!(!wb.protected_structure && wb.structure_password.is_none()); // Without a password it toggles freely. s.execute("review.protectWorkbook", json!({})).unwrap(); s.execute("review.protectWorkbook", json!({})).unwrap(); assert!(!s.doc().unwrap().wb.protected_structure); } #[test] fn convert_to_comments_turns_every_note_into_a_comment_and_undoes() { let mut s = book(); s.execute("review.newNote", json!({"sheet": 1, "cell": "D4", "text": "second", "author": "Cy"})).unwrap(); s.execute("review.showNote", json!({"sheet": 1, "cell": "D4"})).unwrap(); let r = s.execute("review.convertToComments", json!({})).unwrap(); assert_eq!(r["converted"], 2); let wb = &s.doc().unwrap().wb; let a2 = &wb.sheets[0].comments[&CellRef::parse("A2").unwrap()]; let d4 = &wb.sheets[1].comments[&CellRef::parse("D4").unwrap()]; assert!(a2.threaded && d4.threaded && !d4.visible); assert_eq!((a2.author.as_str(), a2.text.as_str(), d4.author.as_str(), d4.text.as_str()), ("Bob", "note", "Cy", "second")); // Nothing left to convert; one undo restores the notes. assert_eq!(s.execute("review.convertToComments", json!({})).unwrap()["converted"], 0); s.execute("edit.undo", json!({})).unwrap(); assert!(!s.doc().unwrap().wb.sheets[0].comments[&CellRef::parse("A2").unwrap()].threaded); } #[test] fn show_comments_and_show_all_notes_toggle() { let mut s = book(); assert_eq!(s.execute("review.showComments", json!({})).unwrap()["on"], true); assert_eq!(s.execute("review.showComments", json!({})).unwrap()["on"], false); assert_eq!(s.execute("review.showComments", json!({"on": true})).unwrap()["on"], true); assert!(s.comments_pane); assert_eq!(s.execute("review.showAllNotes", json!({})).unwrap()["on"], true); assert_eq!(s.execute("review.showAllNotes", json!({"on": false})).unwrap()["on"], false); assert!(!s.show_all_notes); } } #[cfg(test)] mod edit_range_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()); } }