1use 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 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
72fn 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 .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
89fn 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
113fn 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 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 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
190fn 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
203fn 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
305fn 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 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 assert!(s.execute("selection.set", json!({"cell": "A1"})).is_err());
414 s.execute("selection.set", json!({"cell": "B2"})).unwrap();
415 s.execute("selection.move", json!({"dr": 0, "dc": 1})).unwrap();
417 assert_eq!(s.doc().unwrap().selection.active.a1(), "B2");
418 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 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 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 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 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 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 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 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 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 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}