Skip to main content

ravenrs_sheet/engine/cmd/
review.rs

1//! Review tab: protection, spelling (basic), workbook statistics, comment navigation and the
2//! Comments pane, Show All Notes and Convert to Comments.
3
4use crate::base::CellRef;
5use crate::model::{EditRange, PasswordHash, SheetProtection, Visibility, password_hash};
6use serde_json::{Value as Json, json};
7
8use super::{CommandSpec, DocState, EngineError, Result, Session, bad, bool_param, cmd, edit, has_doc, str_param, target_ranges, target_sheet};
9
10pub fn specs() -> Vec<CommandSpec> {
11    vec![
12        cmd!(
13            "review.protectSheet",
14            "Protect Sheet…",
15            ["Review", "Protect"],
16            None,
17            "{password?, formatCells?, formatColumns?, formatRows?, insertRows?, insertColumns?, deleteRows?, deleteColumns?, sort?, autofilter?}",
18            has_doc,
19            protect_sheet
20        ),
21        cmd!("review.unprotectSheet", "Unprotect Sheet", ["Review", "Protect"], None, "{password?}", has_doc, unprotect_sheet),
22        cmd!(
23            "review.protectWorkbook",
24            "Protect Workbook",
25            ["Review", "Protect"],
26            None,
27            "{on?: bool (default: toggle), password?} (structure protection; unprotecting needs the password when one was set)",
28            has_doc,
29            protect_workbook
30        ),
31        cmd!(query "review.workbookStatistics", "Workbook Statistics", ["Review", "Proofing"], None, "{}", has_doc, stats),
32        // Not journaled: the params can hold a password.
33        CommandSpec {
34            id: "review.allowEditRanges",
35            label: "Allow Edit Ranges…",
36            menu: &["Review", "Protect"],
37            shortcut: None,
38            shortcut_mac: None,
39            params: "{action?: list (default) | add | modify | delete | unlock, title?, range?, password?, newTitle?, sheet?} → ranges",
40            enabled: has_doc,
41            run: allow_edit_ranges,
42            journal: false,
43            undoable: true,
44        },
45        cmd!(query "review.checkAccessibility", "Check Accessibility", ["Review", "Accessibility"], None, "{} → issues", has_doc, accessibility),
46        cmd!(noundo "review.previousComment", "Previous Comment", ["Review", "Comments"], None, "{} → {sheet, cell}", has_comments, |s, _| {
47            step_comment(s, false)
48        }),
49        cmd!(noundo "review.nextComment", "Next Comment", ["Review", "Comments"], None, "{} → {sheet, cell}", has_comments, |s, _| step_comment(
50            s, true
51        )),
52        cmd!(query "review.showComments", "Show Comments", ["Review", "Comments"], None, "{on?: bool} (toggles the Comments pane)", has_doc, |s, p| {
53            s.comments_pane = bool_param(p, "on").unwrap_or(!s.comments_pane);
54            Ok(json!({"on": s.comments_pane}))
55        }),
56        cmd!(query "review.showAllNotes", "Show All Notes", ["Review", "Notes"], None, "{on?: bool}", has_doc, |s, p| {
57            s.show_all_notes = bool_param(p, "on").unwrap_or(!s.show_all_notes);
58            Ok(json!({"on": s.show_all_notes}))
59        }),
60        cmd!(
61            "review.convertToComments",
62            "Convert to Comments",
63            ["Review", "Notes"],
64            None,
65            "{} (every note in the workbook) → {converted}",
66            has_doc,
67            convert_notes
68        ),
69    ]
70}
71
72/// Threaded comments in navigation order: visible sheets in tab order, then row by row.
73fn comment_cells(s: &Session) -> Vec<(usize, CellRef)> {
74    let Some(d) = s.active() else { return vec![] };
75    d.wb.sheets
76        .iter()
77        .enumerate()
78        .filter(|(_, sh)| sh.visibility == Visibility::Visible)
79        // `CellRef` orders by row, then column: reading order.
80        .flat_map(|(i, sh)| sh.comments.iter().filter(|(_, c)| c.threaded).map(move |(at, _)| (i, *at)))
81        .collect()
82}
83
84fn has_comments(s: &Session) -> std::result::Result<(), String> {
85    has_doc(s)?;
86    if comment_cells(s).is_empty() { Err("there are no comments in this workbook".into()) } else { Ok(()) }
87}
88
89/// Selects the next (or previous) threaded comment after the active cell, moving on to the next
90/// sheet and wrapping around at the end of the workbook.
91fn step_comment(s: &mut Session, forward: bool) -> Result<Json> {
92    let list = comment_cells(s);
93    let d = s.doc()?;
94    let here = (d.wb.active_sheet(), d.selection.active);
95    let target = if forward {
96        list.iter().find(|c| **c > here).or_else(|| list.first())
97    } else {
98        list.iter().rev().find(|c| **c < here).or_else(|| list.last())
99    };
100    let Some(&(sheet, at)) = target else {
101        return Err(EngineError::Other("There are no comments in this workbook.".into()));
102    };
103    let d = s.doc_mut()?;
104    d.wb_switch_sheet(sheet);
105    d.selection = crate::engine::Selection::at(at);
106    if let Some(sh) = d.wb.active() {
107        d.selection.expand_merges(sh);
108    }
109    let name = d.wb.active().map(|sh| sh.name.clone()).unwrap_or_default();
110    Ok(json!({"sheet": name, "cell": at.a1()}))
111}
112
113/// Review › Notes › Convert to Comments: every note in the workbook becomes a threaded comment
114/// with the same author and text.
115fn convert_notes(s: &mut Session, _: &Json) -> Result<Json> {
116    let sheets: Vec<usize> =
117        s.doc()?.wb.sheets.iter().enumerate().filter(|(_, sh)| sh.comments.values().any(|c| !c.threaded)).map(|(i, _)| i).collect();
118    if sheets.is_empty() {
119        return Ok(json!({"converted": 0}));
120    }
121    edit(s, |cx| {
122        let mut n = 0usize;
123        for i in &sheets {
124            for c in cx.sheet_mut(*i)?.comments.values_mut().filter(|c| !c.threaded) {
125                c.threaded = true;
126                c.visible = false;
127                n += 1;
128            }
129        }
130        Ok(json!({"converted": n}))
131    })
132}
133
134fn protect_sheet(s: &mut Session, p: &Json) -> Result<Json> {
135    let sheet = target_sheet(s, p)?;
136    // Re-protecting a password-protected sheet would replace the password without knowing it:
137    // unprotect it first, as in Excel. One without a password can take new options directly.
138    let has_password = |pr: &SheetProtection| pr.password.is_some() || pr.legacy_password_hash.is_some();
139    if s.doc()?.wb.sheet(sheet).and_then(|sh| sh.protection.as_ref()).is_some_and(has_password) {
140        return Err(EngineError::Other("The sheet is protected with a password. Unprotect it first (Review › Unprotect Sheet).".into()));
141    }
142    let b = |k: &str| bool_param(p, k).unwrap_or(false);
143    // Selection rights default to what the sheet has now (a file may forbid selecting locked cells).
144    let current = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.protection.clone());
145    let select_locked = bool_param(p, "selectLockedCells").unwrap_or_else(|| current.as_ref().is_none_or(|c| c.select_locked));
146    let select_unlocked = bool_param(p, "selectUnlockedCells").unwrap_or_else(|| current.as_ref().is_none_or(|c| c.select_unlocked));
147    let prot = SheetProtection {
148        password: None,
149        legacy_password_hash: None,
150        select_locked,
151        select_unlocked,
152        format_cells: b("formatCells"),
153        format_columns: b("formatColumns"),
154        format_rows: b("formatRows"),
155        insert_columns: b("insertColumns"),
156        insert_rows: b("insertRows"),
157        delete_columns: b("deleteColumns"),
158        delete_rows: b("deleteRows"),
159        sort: b("sort"),
160        autofilter: b("autofilter"),
161    };
162    let d = s.doc()?;
163    let what = d.wb.sheet(sheet).map(|sh| sh.name.clone()).unwrap_or_default();
164    let prot = SheetProtection { password: str_param(p, "password").filter(|x| !x.is_empty()).map(|pw| new_password_hash(d, &what, pw)), ..prot };
165    edit(s, |cx| {
166        cx.sheet_mut(sheet)?.protection = Some(prot.clone());
167        Ok(Json::Null)
168    })
169}
170
171fn unprotect_sheet(s: &mut Session, p: &Json) -> Result<Json> {
172    let sheet = target_sheet(s, p)?;
173    if let Some(pr) = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.protection.as_ref()) {
174        let pw = str_param(p, "password").unwrap_or("");
175        let ok = match (&pr.password, pr.legacy_password_hash) {
176            (Some(h), _) => h.verify(pw).map_err(EngineError::Other)?,
177            (None, Some(h)) => password_hash(pw) == h,
178            (None, None) => true,
179        };
180        if !ok {
181            return Err(EngineError::Other("The password you supplied is not correct.".into()));
182        }
183    }
184    edit(s, |cx| {
185        cx.sheet_mut(sheet)?.protection = None;
186        Ok(Json::Null)
187    })
188}
189
190/// A new Excel-compatible (SHA-512, salted) hash of `pw`. The salt mixes `what` is protected
191/// with the document, a counter and the clock, so equal passwords get different hashes.
192fn new_password_hash(d: &DocState, what: &str, pw: &str) -> PasswordHash {
193    static SALT_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
194    let n = SALT_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
195    let seed = format!("{what}\u{0}{n}\u{0}{}\u{0}{}", d.uid, crate::calc::now_serial().to_bits());
196    PasswordHash::new_sha512(pw, &crate::model::password::salt_from(seed.as_bytes()))
197}
198
199fn range_text(r: &EditRange) -> String {
200    r.ranges.iter().map(|x| x.a1()).collect::<Vec<_>>().join(",")
201}
202
203/// Review › Allow Edit Ranges: list, add, modify or delete the ranges that stay editable on a
204/// protected sheet, or unlock a password range for this session.
205fn allow_edit_ranges(s: &mut Session, p: &Json) -> Result<Json> {
206    let sheet = target_sheet(s, p)?;
207    let action = str_param(p, "action").unwrap_or("list");
208    let title = str_param(p, "title").map(str::trim).filter(|t| !t.is_empty());
209    let d = s.doc()?;
210    let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
211    let sheet_name = sh.name.clone();
212    let find = |t: Option<&str>| -> Result<usize> {
213        let t = t.ok_or_else(|| bad("review.allowEditRanges", "missing `title`"))?;
214        sh.edit_ranges
215            .iter()
216            .position(|r| r.title.eq_ignore_ascii_case(t))
217            .ok_or_else(|| bad("review.allowEditRanges", format!("no range titled `{t}`")))
218    };
219    let hash = |pw: &str, title: &str| new_password_hash(d, &format!("{sheet_name}\u{0}{title}"), pw);
220    if matches!(action, "add" | "modify" | "delete") && sh.is_protected() {
221        return Err(EngineError::Other("Unprotect the sheet to change its edit ranges.".into()));
222    }
223    match action {
224        "list" => {}
225        "unlock" => {
226            let i = find(title)?;
227            let Some(r) = sh.edit_ranges.get(i) else { return Err(bad("review.allowEditRanges", "no such range")) };
228            if let Some(h) = &r.password {
229                let pw = str_param(p, "password").unwrap_or("");
230                if !h.verify(pw).map_err(EngineError::Other)? {
231                    return Err(EngineError::Other("The password you supplied is not correct.".into()));
232                }
233                let key = (sheet_name.clone(), r.title.clone());
234                let d = s.doc_mut()?;
235                if !d.unlocked_ranges.contains(&key) {
236                    d.unlocked_ranges.push(key);
237                }
238            }
239        }
240        "add" => {
241            let t = match title {
242                Some(t) => t.to_string(),
243                None => {
244                    (1..).map(|n| format!("Range{n}")).find(|t| !sh.edit_ranges.iter().any(|r| r.title.eq_ignore_ascii_case(t))).unwrap_or_default()
245                }
246            };
247            if sh.edit_ranges.iter().any(|r| r.title.eq_ignore_ascii_case(&t)) {
248                return Err(bad("review.allowEditRanges", format!("a range titled `{t}` already exists")));
249            }
250            let ranges = target_ranges(s, p)?;
251            let password = str_param(p, "password").filter(|pw| !pw.is_empty()).map(|pw| hash(pw, &t));
252            let r = EditRange { title: t, ranges, password, security_descriptor: None };
253            edit(s, |cx| {
254                cx.sheet_mut(sheet)?.edit_ranges.push(r.clone());
255                Ok(())
256            })?;
257        }
258        "modify" => {
259            let i = find(title)?;
260            let mut r = sh.edit_ranges.get(i).cloned().ok_or_else(|| bad("review.allowEditRanges", "no such range"))?;
261            if let Some(n) = str_param(p, "newTitle").map(str::trim).filter(|n| !n.is_empty()) {
262                if sh.edit_ranges.iter().enumerate().any(|(j, x)| j != i && x.title.eq_ignore_ascii_case(n)) {
263                    return Err(bad("review.allowEditRanges", format!("a range titled `{n}` already exists")));
264                }
265                r.title = n.to_string();
266            }
267            if let Some(pw) = str_param(p, "password") {
268                r.password = (!pw.is_empty()).then(|| hash(pw, &r.title));
269            }
270            if p.get("range").is_some() {
271                r.ranges = target_ranges(s, p)?;
272            }
273            edit(s, |cx| {
274                if let Some(x) = cx.sheet_mut(sheet)?.edit_ranges.get_mut(i) {
275                    *x = r.clone();
276                }
277                Ok(())
278            })?;
279        }
280        "delete" => {
281            let i = find(title)?;
282            edit(s, |cx| {
283                let list = &mut cx.sheet_mut(sheet)?.edit_ranges;
284                if i < list.len() {
285                    list.remove(i);
286                }
287                Ok(())
288            })?;
289        }
290        other => return Err(bad("review.allowEditRanges", format!("unknown action `{other}`"))),
291    }
292    let d = s.doc()?;
293    let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
294    Ok(Json::Array(
295        sh.edit_ranges
296            .iter()
297            .map(|r| {
298                let unlocked = r.password.is_none() || d.unlocked_ranges.iter().any(|(s, t)| *s == sh.name && *t == r.title);
299                json!({"title": r.title, "range": range_text(r), "password": r.password.is_some(), "unlocked": unlocked})
300            })
301            .collect(),
302    ))
303}
304
305/// Review › Protect Workbook: lock the workbook structure (sheets can't be added, moved,
306/// renamed, hidden or deleted), optionally with a password that unprotecting must match.
307fn protect_workbook(s: &mut Session, p: &Json) -> Result<Json> {
308    let d = s.doc()?;
309    let cur = d.wb.protected_structure;
310    let on = bool_param(p, "on").unwrap_or(!cur);
311    if on == cur {
312        return Ok(json!({"on": on}));
313    }
314    let password = if on {
315        str_param(p, "password").filter(|pw| !pw.is_empty()).map(|pw| new_password_hash(d, "workbook", pw))
316    } else {
317        if let Some(h) = &d.wb.structure_password
318            && !h.verify(str_param(p, "password").unwrap_or("")).map_err(EngineError::Other)?
319        {
320            return Err(EngineError::Other("The password you supplied is not correct.".into()));
321        }
322        None
323    };
324    edit(s, |cx| {
325        cx.wb.protected_structure = on;
326        cx.wb.structure_password = password.clone();
327        Ok(json!({"on": on}))
328    })
329}
330
331fn stats(s: &mut Session, _: &Json) -> Result<Json> {
332    let d = s.doc()?;
333    let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
334    let formulas = sh.cells.iter().filter(|(_, c)| c.formula.is_some()).count();
335    Ok(json!({
336        "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()},
337        "workbook": {"sheets": d.wb.sheets.len(), "cellsWithData": d.wb.sheets.iter().map(|s| s.cells.len()).sum::<usize>(), "tables": d.wb.sheets.iter().map(|s| s.tables.len()).sum::<usize>(), "charts": d.wb.sheets.iter().map(|s| s.charts.len()).sum::<usize>(), "names": d.wb.names.len()}
338    }))
339}
340
341fn accessibility(s: &mut Session, _: &Json) -> Result<Json> {
342    let d = s.doc()?;
343    let mut issues = Vec::new();
344    for sh in &d.wb.sheets {
345        for im in &sh.images {
346            if im.alt.is_empty() {
347                issues.push(json!({"sheet": sh.name, "issue": "Missing alternative text", "object": format!("Picture {}", im.id)}));
348            }
349        }
350        if !sh.merges.is_empty() {
351            issues.push(json!({"sheet": sh.name, "issue": "Merged cells", "count": sh.merges.len()}));
352        }
353        if sh.name.starts_with("Sheet") {
354            issues.push(json!({"sheet": sh.name, "issue": "Default sheet name"}));
355        }
356    }
357    Ok(Json::Array(issues))
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363
364    fn book() -> Session {
365        let mut s = Session::new();
366        s.new_workbook();
367        s.execute("home.insertSheet", json!({"name": "Two"})).unwrap();
368        s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
369        for (sheet, cell) in [(0, "C1"), (0, "A3"), (1, "B2")] {
370            s.execute("review.newComment", json!({"sheet": sheet, "cell": cell, "text": "c", "author": "Ann"})).unwrap();
371        }
372        s.execute("review.newNote", json!({"sheet": 0, "cell": "A2", "text": "note", "author": "Bob"})).unwrap();
373        s
374    }
375
376    fn at(s: &Session) -> (usize, String) {
377        let d = s.doc().unwrap();
378        (d.wb.active_sheet(), d.selection.active.a1())
379    }
380
381    #[test]
382    fn next_and_previous_comment_go_in_reading_order_across_sheets_and_wrap() {
383        let mut s = book();
384        s.execute("selection.set", json!({"cell": "B1"})).unwrap();
385        // Notes are skipped: A2 holds a note, not a comment.
386        let order = [(0, "C1"), (0, "A3"), (1, "B2"), (0, "C1")];
387        for (sheet, cell) in order {
388            s.execute("review.nextComment", json!({})).unwrap();
389            assert_eq!(at(&s), (sheet, cell.to_string()));
390        }
391        for (sheet, cell) in [(1, "B2"), (0, "A3"), (0, "C1")] {
392            s.execute("review.previousComment", json!({})).unwrap();
393            assert_eq!(at(&s), (sheet, cell.to_string()));
394        }
395    }
396
397    #[test]
398    fn comment_navigation_is_disabled_without_comments() {
399        let mut s = Session::new();
400        s.new_workbook();
401        s.execute("review.newNote", json!({"cell": "A1", "text": "note"})).unwrap();
402        assert!(s.execute("review.nextComment", json!({})).is_err());
403        assert!(s.execute("review.previousComment", json!({})).is_err());
404        assert_eq!(at(&s), (0, "A1".to_string()));
405    }
406
407    #[test]
408    fn protection_that_forbids_selecting_locked_cells_is_enforced_and_kept() {
409        let mut s = book();
410        s.execute("home.lockCell", json!({"range": "B2", "on": false})).unwrap();
411        s.execute("review.protectSheet", json!({"selectLockedCells": false})).unwrap();
412        // Locked cells can't be selected; unlocked ones can.
413        assert!(s.execute("selection.set", json!({"cell": "A1"})).is_err());
414        s.execute("selection.set", json!({"cell": "B2"})).unwrap();
415        // Arrow keys skip locked cells, and stay put when nothing selectable is left.
416        s.execute("selection.move", json!({"dr": 0, "dc": 1})).unwrap();
417        assert_eq!(s.doc().unwrap().selection.active.a1(), "B2");
418        // Re-protecting without the flag keeps the sheet's selection rights.
419        s.execute("review.unprotectSheet", json!({})).unwrap();
420        s.execute("review.protectSheet", json!({"selectLockedCells": false})).unwrap();
421        s.execute("review.protectSheet", json!({"sort": true})).unwrap();
422        assert!(!s.doc().unwrap().wb.active().unwrap().protection.clone().unwrap().select_locked);
423        s.execute("review.unprotectSheet", json!({})).unwrap();
424        s.execute("selection.set", json!({"cell": "A1"})).unwrap();
425    }
426
427    #[test]
428    fn sheet_passwords_use_excel_hashes_and_old_json_hashes_still_verify() {
429        let mut s = book();
430        s.execute("review.protectSheet", json!({"password": "pa55"})).unwrap();
431        let prot = s.doc().unwrap().wb.active().unwrap().protection.clone().unwrap();
432        assert!(matches!(&prot.password, Some(PasswordHash::Iterated { algorithm, .. }) if algorithm == "SHA-512"));
433        assert!(s.execute("review.unprotectSheet", json!({})).is_err());
434        assert!(s.execute("review.unprotectSheet", json!({"password": "pass"})).is_err());
435        // Protecting again can't swap the password out from under it.
436        assert!(s.execute("review.protectSheet", json!({})).is_err());
437        assert_eq!(s.doc().unwrap().wb.active().unwrap().protection.as_ref(), Some(&prot));
438        s.execute("review.unprotectSheet", json!({"password": "pa55"})).unwrap();
439        assert!(!s.doc().unwrap().wb.active().unwrap().is_protected());
440        // A GridCraft JSON workbook saved before carried the internal hash under `password_hash`.
441        let old = format!(
442            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}}"#,
443            password_hash("old")
444        );
445        let prot: SheetProtection = serde_json::from_str(&old).unwrap();
446        assert_eq!((prot.legacy_password_hash, prot.password.is_none()), (Some(password_hash("old")), true));
447        let i = s.doc().unwrap().wb.active_sheet();
448        let wb = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb);
449        std::sync::Arc::make_mut(&mut wb.sheets[i]).protection = Some(prot);
450        assert!(s.execute("review.unprotectSheet", json!({"password": "new"})).is_err());
451        s.execute("review.unprotectSheet", json!({"password": "old"})).unwrap();
452    }
453
454    #[test]
455    fn protected_structure_blocks_rename_move_copy_hide_and_unhide_sheet() {
456        let mut s = book();
457        let names = |s: &Session| s.doc().unwrap().wb.sheets.iter().map(|x| x.name.clone()).collect::<Vec<_>>();
458        let before = names(&s);
459        s.execute("review.protectWorkbook", json!({})).unwrap();
460        assert!(s.execute("sheet.rename", json!({"sheet": 0, "name": "Renamed"})).is_err());
461        assert!(s.execute("sheet.move", json!({"sheet": 0, "to": 1})).is_err());
462        assert!(s.execute("sheet.move", json!({"sheet": 0, "to": 1, "copy": true})).is_err());
463        assert!(s.execute("sheet.hide", json!({"sheet": 1})).is_err());
464        assert_eq!(names(&s), before);
465        assert!(s.doc().unwrap().wb.sheets.iter().all(|x| x.visibility == Visibility::Visible));
466        s.execute("review.protectWorkbook", json!({})).unwrap();
467        s.execute("sheet.rename", json!({"sheet": 0, "name": "Renamed"})).unwrap();
468        s.execute("sheet.move", json!({"sheet": 0, "to": 1})).unwrap();
469        s.execute("sheet.move", json!({"sheet": 0, "to": 1, "copy": true})).unwrap();
470        assert_eq!(s.doc().unwrap().wb.sheets.len(), 3);
471        s.execute("sheet.hide", json!({"sheet": 1})).unwrap();
472        s.execute("review.protectWorkbook", json!({})).unwrap();
473        assert!(s.execute("sheet.unhide", json!({"sheet": 1})).is_err());
474        assert!(s.execute("sheet.unhide", json!({})).is_err());
475        assert_eq!(s.doc().unwrap().wb.sheets[1].visibility, Visibility::Hidden);
476        s.execute("review.protectWorkbook", json!({})).unwrap();
477        s.execute("sheet.unhide", json!({"sheet": 1})).unwrap();
478    }
479
480    #[test]
481    fn protect_workbook_with_a_password_really_verifies() {
482        let mut s = book();
483        s.execute("review.protectWorkbook", json!({"password": "pa55"})).unwrap();
484        assert!(s.doc().unwrap().wb.protected_structure);
485        assert!(s.execute("home.insertSheet", json!({"name": "Three"})).is_err(), "the structure is locked");
486        // A second Protect keeps it protected; unprotecting needs the right password.
487        assert_eq!(s.execute("review.protectWorkbook", json!({"on": true})).unwrap()["on"], true);
488        assert!(s.execute("review.protectWorkbook", json!({})).is_err());
489        assert!(s.execute("review.protectWorkbook", json!({"on": false, "password": "pass"})).is_err());
490        assert!(s.doc().unwrap().wb.protected_structure);
491        s.execute("review.protectWorkbook", json!({"on": false, "password": "pa55"})).unwrap();
492        let wb = &s.doc().unwrap().wb;
493        assert!(!wb.protected_structure && wb.structure_password.is_none());
494        // Without a password it toggles freely.
495        s.execute("review.protectWorkbook", json!({})).unwrap();
496        s.execute("review.protectWorkbook", json!({})).unwrap();
497        assert!(!s.doc().unwrap().wb.protected_structure);
498    }
499
500    #[test]
501    fn convert_to_comments_turns_every_note_into_a_comment_and_undoes() {
502        let mut s = book();
503        s.execute("review.newNote", json!({"sheet": 1, "cell": "D4", "text": "second", "author": "Cy"})).unwrap();
504        s.execute("review.showNote", json!({"sheet": 1, "cell": "D4"})).unwrap();
505        let r = s.execute("review.convertToComments", json!({})).unwrap();
506        assert_eq!(r["converted"], 2);
507        let wb = &s.doc().unwrap().wb;
508        let a2 = &wb.sheets[0].comments[&CellRef::parse("A2").unwrap()];
509        let d4 = &wb.sheets[1].comments[&CellRef::parse("D4").unwrap()];
510        assert!(a2.threaded && d4.threaded && !d4.visible);
511        assert_eq!((a2.author.as_str(), a2.text.as_str(), d4.author.as_str(), d4.text.as_str()), ("Bob", "note", "Cy", "second"));
512        // Nothing left to convert; one undo restores the notes.
513        assert_eq!(s.execute("review.convertToComments", json!({})).unwrap()["converted"], 0);
514        s.execute("edit.undo", json!({})).unwrap();
515        assert!(!s.doc().unwrap().wb.sheets[0].comments[&CellRef::parse("A2").unwrap()].threaded);
516    }
517
518    #[test]
519    fn show_comments_and_show_all_notes_toggle() {
520        let mut s = book();
521        assert_eq!(s.execute("review.showComments", json!({})).unwrap()["on"], true);
522        assert_eq!(s.execute("review.showComments", json!({})).unwrap()["on"], false);
523        assert_eq!(s.execute("review.showComments", json!({"on": true})).unwrap()["on"], true);
524        assert!(s.comments_pane);
525        assert_eq!(s.execute("review.showAllNotes", json!({})).unwrap()["on"], true);
526        assert_eq!(s.execute("review.showAllNotes", json!({"on": false})).unwrap()["on"], false);
527        assert!(!s.show_all_notes);
528    }
529}
530
531#[cfg(test)]
532mod edit_range_tests {
533    use super::*;
534
535    fn protected_book() -> Session {
536        let mut s = Session::new();
537        s.new_workbook();
538        s.execute("review.allowEditRanges", json!({"action": "add", "title": "Open", "range": "A1:A3"})).unwrap();
539        s.execute("review.allowEditRanges", json!({"action": "add", "title": "Budget", "range": "B1:B3,D1", "password": "pa55"})).unwrap();
540        s.execute("review.protectSheet", json!({})).unwrap();
541        s
542    }
543
544    #[test]
545    fn edit_ranges_open_locked_cells_and_passwords_really_verify() {
546        let mut s = protected_book();
547        s.execute("cell.set", json!({"cell": "A2", "input": "1"})).unwrap();
548        assert!(s.execute("cell.set", json!({"cell": "C1", "input": "1"})).is_err());
549        let err = s.execute("cell.set", json!({"cell": "D1", "input": "1"})).unwrap_err().to_string();
550        assert!(err.contains("Budget"), "{err}");
551        // A paste or fill is checked the same way.
552        assert!(s.execute("range.setValues", json!({"range": "B2", "values": [[5]]})).is_err());
553        assert!(s.execute("review.allowEditRanges", json!({"action": "unlock", "title": "Budget", "password": "pass"})).is_err());
554        assert!(s.execute("review.allowEditRanges", json!({"action": "unlock", "title": "Budget"})).is_err());
555        let list = s.execute("review.allowEditRanges", json!({"action": "unlock", "title": "budget", "password": "pa55"})).unwrap();
556        assert_eq!(list[1]["unlocked"], true);
557        s.execute("range.setValues", json!({"range": "B2", "values": [[5]]})).unwrap();
558        s.execute("cell.set", json!({"cell": "D1", "input": "2"})).unwrap();
559        // Ranges can't change while the sheet is protected.
560        assert!(s.execute("review.allowEditRanges", json!({"action": "delete", "title": "Open"})).is_err());
561    }
562
563    #[test]
564    fn edit_ranges_add_modify_delete_list_and_undo() {
565        let mut s = Session::new();
566        s.new_workbook();
567        // Empty params list; nothing to list yet.
568        assert_eq!(s.execute("review.allowEditRanges", json!({})).unwrap(), json!([]));
569        s.execute("selection.set", json!({"range": "C3:D4"})).unwrap();
570        let list = s.execute("review.allowEditRanges", json!({"action": "add"})).unwrap();
571        assert_eq!(list, json!([{"title": "Range1", "range": "C3:D4", "password": false, "unlocked": true}]));
572        assert!(s.execute("review.allowEditRanges", json!({"action": "add", "title": "range1", "range": "A1"})).is_err());
573        let list = s
574            .execute("review.allowEditRanges", json!({"action": "modify", "title": "Range1", "newTitle": "Mine", "range": "E5", "password": "x"}))
575            .unwrap();
576        assert_eq!((list[0]["title"].as_str(), list[0]["range"].as_str(), list[0]["password"].as_bool()), (Some("Mine"), Some("E5"), Some(true)));
577        // Inserting a row above moves the range with its cells.
578        s.execute("home.insertRows", json!({"rows": "1:1"})).unwrap();
579        assert_eq!(s.execute("review.allowEditRanges", json!({})).unwrap()[0]["range"], "E6");
580        s.execute("review.allowEditRanges", json!({"action": "delete", "title": "Mine"})).unwrap();
581        assert_eq!(s.execute("review.allowEditRanges", json!({})).unwrap(), json!([]));
582        s.execute("edit.undo", json!({})).unwrap();
583        assert_eq!(s.execute("review.allowEditRanges", json!({})).unwrap()[0]["title"], "Mine");
584        assert!(s.execute("review.allowEditRanges", json!({"action": "nope"})).is_err());
585        assert!(s.execute("review.allowEditRanges", json!({"action": "delete"})).is_err());
586    }
587}