mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-30 21:56:53 -06:00
Merge branch 'land' into fix-288
# Conflicts: # ROADMAP.md # crates/engine/src/cmd/edit.rs # docs/target-app-parity.md
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -29,7 +29,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"data.filter",
|
||||
"Filter",
|
||||
["Data", "Sort & Filter"],
|
||||
Some("Cmd+Shift+F"),
|
||||
Some("Ctrl+Shift+L"),
|
||||
"{range?} toggles AutoFilter on the current region",
|
||||
has_doc,
|
||||
toggle_filter
|
||||
@@ -248,7 +248,7 @@ fn do_sort(s: &mut Session, r: RangeRef, header: bool, keys: Vec<SortKey>, by_co
|
||||
let pos = |v: &Value| v.as_text().and_then(|t| list.iter().position(|x| x.eq_ignore_ascii_case(t))).unwrap_or(usize::MAX);
|
||||
pos(va).cmp(&pos(vb))
|
||||
} else if case && va.is_text() && vb.is_text() {
|
||||
va.as_text().unwrap_or("").cmp(vb.as_text().unwrap_or(""))
|
||||
sort_compare(va, vb).then_with(|| case_order(va.as_text().unwrap_or(""), vb.as_text().unwrap_or("")))
|
||||
} else {
|
||||
sort_compare(va, vb)
|
||||
};
|
||||
@@ -431,7 +431,16 @@ fn toggle_filter(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
Ok(json!({"filter": false}))
|
||||
});
|
||||
}
|
||||
let r = data_range(s, p)?;
|
||||
let mut r = data_range(s, p)?;
|
||||
// A selected header row filters the data below it (#201).
|
||||
if p.get("range").is_none() && r.height() == 1 {
|
||||
let d = s.doc()?;
|
||||
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
|
||||
let below = current_region(sh, r.start).end.row;
|
||||
if below > r.end.row {
|
||||
r = RangeRef::new(r.start, CellRef::new(below, r.end.col));
|
||||
}
|
||||
}
|
||||
edit(s, |cx| {
|
||||
cx.sheet_mut(sheet)?.autofilter = Some(AutoFilter { range: r, criteria: vec![] });
|
||||
Ok(json!({"filter": true, "range": r.a1()}))
|
||||
@@ -653,6 +662,12 @@ pub fn filter_values(wb: &Workbook, sheet: usize, col: u32) -> Vec<(String, bool
|
||||
seen.into_iter().map(|((_, t), on)| (t, on)).collect()
|
||||
}
|
||||
|
||||
/// Case-sensitive tie-break for text that is equal ignoring case: as in Excel, lowercase sorts
|
||||
/// before uppercase at the first letter that differs in case.
|
||||
fn case_order(a: &str, b: &str) -> Ordering {
|
||||
a.chars().zip(b.chars()).find(|(x, y)| x != y).map_or(Ordering::Equal, |(x, y)| y.is_lowercase().cmp(&x.is_lowercase()))
|
||||
}
|
||||
|
||||
fn sort_key(v: &Value) -> (u8, i64, String) {
|
||||
match v {
|
||||
Value::Number(n) => (0, (n * 1e6) as i64, String::new()),
|
||||
@@ -685,8 +700,11 @@ fn remove_duplicates(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let Some(sh) = cx.wb.sheet(sheet) else { return Ok(()) };
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut keep: Vec<u32> = Vec::new();
|
||||
// Cells are compared by the text they show, as in Excel: the same date in two formats
|
||||
// isn't a duplicate, while 1.01 and 1.02 shown as 1.0 are.
|
||||
let shown = |c: CellRef| crate::display::format(&sh.value(c), cx.wb.styles.get(sh.style_id(c)).num_fmt.as_str(), &cx.wb).text;
|
||||
for row in start..=r.end.row {
|
||||
let key: Vec<String> = cols.iter().map(|c| sh.value(CellRef::new(row, *c)).display().to_lowercase()).collect();
|
||||
let key: Vec<String> = cols.iter().map(|c| shown(CellRef::new(row, *c)).to_lowercase()).collect();
|
||||
if seen.insert(key) {
|
||||
keep.push(row);
|
||||
} else {
|
||||
|
||||
+175
-27
@@ -1,6 +1,6 @@
|
||||
//! Cell entry, selection, undo/redo, clipboard, clear, fill, find & replace.
|
||||
|
||||
use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value};
|
||||
use gridcraft_core::{CellRef, MAX_ROWS, RangeRef, Value};
|
||||
use gridcraft_model::{Cell, Formula, StyleId};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
@@ -47,7 +47,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Paste",
|
||||
["Home", "Clipboard"],
|
||||
Some("Cmd+V"),
|
||||
"{at?: \"C3\", text?: \"tab-separated text from the system clipboard\"}",
|
||||
"{at?: \"C3\", text?: \"tab-separated text from the system clipboard\", html?: \"the clipboard's HTML flavour; cells the source recorded as numbers (x:num, sdval) stay numbers\"}",
|
||||
has_doc,
|
||||
paste
|
||||
),
|
||||
@@ -79,7 +79,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"AutoFill",
|
||||
[],
|
||||
None,
|
||||
"{source: \"A1:A2\", target: \"A1:A10\", mode?: series|copy|formats|values}: the fill-handle drag",
|
||||
"{source: \"A1:A2\", target: \"A1:A10\", mode?: series|alternate|copy|formats|values}: the fill-handle drag (alternate = Ctrl+drag: a lone number counts up, any other series copies)",
|
||||
has_doc,
|
||||
auto_fill
|
||||
),
|
||||
@@ -92,7 +92,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
fill_series
|
||||
),
|
||||
cmd!("edit.flashFill", "Flash Fill", ["Home", "Editing", "Fill"], Some("Cmd+E"), "{range?}", has_doc, flash_fill),
|
||||
cmd!("edit.flashFill", "Flash Fill", ["Home", "Editing", "Fill"], Some("Ctrl+E"), "{range?}", has_doc, flash_fill),
|
||||
cmd!(noundo "edit.find", "Find…", ["Home", "Editing", "Find & Select"], Some("Cmd+F"), "{what, matchCase?, wholeCell?, lookIn?: formulas|values, byColumns?, all?: bool, within?: sheet|workbook}", has_doc, find),
|
||||
cmd!(
|
||||
"edit.replace",
|
||||
@@ -196,13 +196,19 @@ fn cell_set(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
Some(c) => c,
|
||||
None => s.doc()?.selection.active,
|
||||
};
|
||||
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 input = str_param(p, "input")
|
||||
.or_else(|| str_param(p, "value"))
|
||||
.map(str::to_string)
|
||||
.or_else(|| p.get("value").map(json_to_input))
|
||||
.ok_or_else(|| bad("cell.set", "missing `input` (or `value`)"))?;
|
||||
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 {
|
||||
@@ -408,9 +414,9 @@ fn selection_move(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if !extend && let Some(m) = sheet.merge_at(from) {
|
||||
// Leaving a merged cell moves from its edge.
|
||||
if dr > 0 {
|
||||
t = CellRef::new((m.end.row + dr as u32).min(MAX_ROWS - 1), from.col);
|
||||
t = CellRef::new(m.end.offset_clamped(dr, 0).row, from.col);
|
||||
} else if dc > 0 {
|
||||
t = CellRef::new(from.row, (m.end.col + dc as u32).min(MAX_COLS - 1));
|
||||
t = CellRef::new(from.row, m.end.offset_clamped(0, dc).col);
|
||||
}
|
||||
}
|
||||
t
|
||||
@@ -604,26 +610,36 @@ fn redo(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
// ---------------------------------------------------------------- clipboard
|
||||
|
||||
fn copy_impl(s: &mut Session, p: &Json, cut: bool) -> Result<Json> {
|
||||
let multiple = || EngineError::Other("This action won't work on multiple selections.".into());
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let ranges = target_ranges(s, p)?;
|
||||
if ranges.len() > 1 {
|
||||
return Err(EngineError::Other("This action won't work on multiple selections.".into()));
|
||||
if ranges.len() > 1 && cut {
|
||||
return Err(multiple());
|
||||
}
|
||||
let range = ranges.first().copied().unwrap_or_default();
|
||||
let selected = range;
|
||||
// The selection as made (untrimmed), for Insert Copied Cells.
|
||||
let selected = ranges.first().copied().unwrap_or_default();
|
||||
let d = s.doc()?;
|
||||
let Some(sh) = d.wb.sheet(sheet) else { return Err(EngineError::NoDocument) };
|
||||
let range = if range.is_full_cols() || range.is_full_rows() {
|
||||
let used = sh.used_range().unwrap_or(RangeRef::cell(range.start));
|
||||
range.intersection(&used.union(&RangeRef::cell(range.start))).unwrap_or(range)
|
||||
} else {
|
||||
range
|
||||
let trim = |range: RangeRef| {
|
||||
if range.is_full_cols() || range.is_full_rows() {
|
||||
let used = sh.used_range().unwrap_or(RangeRef::cell(range.start));
|
||||
range.intersection(&used.union(&RangeRef::cell(range.start))).unwrap_or(range)
|
||||
} else {
|
||||
range
|
||||
}
|
||||
};
|
||||
let text = crate::display::range_text(&d.wb, sheet, range);
|
||||
let (wb, range, areas) = if ranges.len() > 1 {
|
||||
let areas: Vec<RangeRef> = ranges.iter().map(|r| trim(*r)).collect();
|
||||
let (wb, block) = slide_together(&d.wb, sheet, &areas).ok_or_else(multiple)?;
|
||||
(std::sync::Arc::new(wb), block, areas)
|
||||
} else {
|
||||
(d.wb.clone(), trim(ranges.first().copied().unwrap_or_default()), Vec::new())
|
||||
};
|
||||
let text = crate::display::range_text(&wb, sheet, range);
|
||||
// The HTML rendering is for the host clipboard (UI); it can reach 4 MB, so agents and
|
||||
// scripts get it only when they ask for it.
|
||||
let html = if p.get("html").and_then(Json::as_bool) == Some(true) { crate::io::range_html(&d.wb, sheet, range, &text) } else { None };
|
||||
let clip = Clipboard { wb: d.wb.clone(), sheet, range, selected, cut, doc_uid: d.uid, text: text.clone() };
|
||||
let html = if p.get("html").and_then(Json::as_bool) == Some(true) { crate::io::range_html(&wb, sheet, range, &text) } else { None };
|
||||
let clip = Clipboard { wb, sheet, range, cut, doc_uid: d.uid, text: text.clone(), areas, selected };
|
||||
s.clipboard = Some(clip);
|
||||
let mut result = json!({"range": range.a1(), "text": text});
|
||||
if let Some(html) = html {
|
||||
@@ -632,6 +648,83 @@ fn copy_impl(s: &mut Session, p: &Json, cut: bool) -> Result<Json> {
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Excel copies a multi-area selection only when the areas line up: all cover the same rows (they
|
||||
/// slide together side by side, left to right) or all cover the same columns (stacked, top to
|
||||
/// bottom), without overlapping. Returns a copy of the workbook whose sheet holds the areas packed
|
||||
/// into one block at the first area's place, and that block. Formulas travel as their values, as
|
||||
/// in Excel. `None` when the areas don't line up.
|
||||
fn slide_together(wb: &gridcraft_model::Workbook, sheet: usize, areas: &[RangeRef]) -> Option<(gridcraft_model::Workbook, RangeRef)> {
|
||||
let first = *areas.first()?;
|
||||
let across = areas.iter().all(|a| a.start.row == first.start.row && a.end.row == first.end.row);
|
||||
let down = areas.iter().all(|a| a.start.col == first.start.col && a.end.col == first.end.col);
|
||||
let mut sorted = areas.to_vec();
|
||||
if across {
|
||||
sorted.sort_by_key(|a| a.start.col);
|
||||
} else if down {
|
||||
sorted.sort_by_key(|a| a.start.row);
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
if sorted.windows(2).any(|w| matches!(w, [a, b] if a.intersects(b))) {
|
||||
return None;
|
||||
}
|
||||
let anchor = sorted.first()?.start;
|
||||
let (h, w) = if across {
|
||||
(first.height(), sorted.iter().map(RangeRef::width).fold(0u32, u32::saturating_add))
|
||||
} else {
|
||||
(sorted.iter().map(RangeRef::height).fold(0u32, u32::saturating_add), first.width())
|
||||
};
|
||||
let block = RangeRef::new(anchor, anchor.offset(i64::from(h) - 1, i64::from(w) - 1)?);
|
||||
let src = wb.sheet(sheet)?;
|
||||
let mut out = wb.clone();
|
||||
let dst = out.sheet_mut(sheet)?;
|
||||
dst.take_cells(block);
|
||||
dst.spill.retain(|c, _| !block.contains(*c));
|
||||
dst.spill_ranges.retain(|c, _| !block.contains(*c));
|
||||
dst.comments.retain(|c, _| !block.contains(*c));
|
||||
dst.merges.retain(|m| !m.intersects(&block));
|
||||
if across {
|
||||
dst.cols.retain(|c, _| !(block.start.col..=block.end.col).contains(c));
|
||||
} else {
|
||||
dst.rows.retain(|r, _| !(block.start.row..=block.end.row).contains(r));
|
||||
}
|
||||
let mut next = if across { anchor.col } else { anchor.row };
|
||||
for a in &sorted {
|
||||
let (dr, dc) = if across { (0, i64::from(next) - i64::from(a.start.col)) } else { (i64::from(next) - i64::from(a.start.row), 0) };
|
||||
let cells = src.cells.iter_range(*a).map(|(c, _)| c).chain(src.spill.keys().copied().filter(|c| a.contains(*c)));
|
||||
for c in cells {
|
||||
let Some(to) = c.offset(dr, dc) else { continue };
|
||||
let style = src.cell(c).map(|x| x.style).unwrap_or_default();
|
||||
dst.set_cell(to, Cell { value: src.value(c), formula: None, style });
|
||||
if let Some(picture) = src.cell_pictures.get(&c) {
|
||||
dst.cell_pictures.insert(to, picture.clone());
|
||||
}
|
||||
}
|
||||
for (c, comment) in src.comments.iter().filter(|(c, _)| a.contains(**c)) {
|
||||
if let Some(to) = c.offset(dr, dc) {
|
||||
dst.comments.insert(to, comment.clone());
|
||||
}
|
||||
}
|
||||
for m in src.merges.iter().filter(|m| a.contains_range(m)) {
|
||||
if let (Some(s), Some(e)) = (m.start.offset(dr, dc), m.end.offset(dr, dc)) {
|
||||
dst.merges.push(RangeRef::new(s, e));
|
||||
}
|
||||
}
|
||||
// Column widths (side by side) or row heights and hidden rows (stacked) follow their cells.
|
||||
if across {
|
||||
for (c, info) in src.cols.range(a.start.col..=a.end.col) {
|
||||
dst.cols.insert(next.saturating_add(c.saturating_sub(a.start.col)), *info);
|
||||
}
|
||||
} else {
|
||||
for (r, info) in src.rows.range(a.start.row..=a.end.row) {
|
||||
dst.rows.insert(next.saturating_add(r.saturating_sub(a.start.row)), *info);
|
||||
}
|
||||
}
|
||||
next = next.saturating_add(if across { a.width() } else { a.height() });
|
||||
}
|
||||
Some((out, block))
|
||||
}
|
||||
|
||||
fn copy(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
copy_impl(s, p, false)
|
||||
}
|
||||
@@ -675,13 +768,18 @@ fn paste_text(s: &mut Session, p: &Json, text: &str) -> Result<Json> {
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let at = cell_param(p, "at").unwrap_or(s.doc()?.selection.current().start);
|
||||
let rows: Vec<Vec<String>> = parse_tsv(text);
|
||||
// The HTML flavour, when the host passed it: the number behind each cell's shown text.
|
||||
let html = str_param(p, "html").map(crate::clip_html::table_cells).unwrap_or_default();
|
||||
let h = rows.len();
|
||||
let w = rows.iter().map(Vec::len).max().unwrap_or(0);
|
||||
edit(s, |cx| {
|
||||
for (ri, row) in rows.iter().enumerate() {
|
||||
for (ci, v) in row.iter().enumerate() {
|
||||
let Some(c) = at.offset(ri as i64, ci as i64) else { continue };
|
||||
let cell = input_to_cell(v, sheet, c, &mut cx.wb).unwrap_or_else(|_| Some(Cell::value(Value::text(v.as_str()))));
|
||||
let mut cell = input_to_cell(v, sheet, c, &mut cx.wb).unwrap_or_else(|_| Some(Cell::value(Value::text(v.as_str()))));
|
||||
if let Some(cl) = cell.as_mut() {
|
||||
numeric_from_html(cl, v, html.get(ri).and_then(|r| r.get(ci)), &mut cx.wb);
|
||||
}
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
match cell {
|
||||
Some(cell) => sh.set_cell(c, cell),
|
||||
@@ -701,6 +799,27 @@ fn paste_text(s: &mut Session, p: &Json, text: &str) -> Result<Json> {
|
||||
Ok(json!({"rows": h, "cols": w}))
|
||||
}
|
||||
|
||||
/// A pasted cell that came out as text ("Rp10,000.00" isn't en-US input) becomes the number the
|
||||
/// source recorded for it in its HTML, with a format that shows the same text when one can be
|
||||
/// inferred and the cell has none. The HTML cell must show the same text as the tab-separated
|
||||
/// field, so HTML that belongs to another copy (or a table laid out differently) changes nothing.
|
||||
fn numeric_from_html(cell: &mut Cell, field: &str, html: Option<&crate::clip_html::HtmlCell>, wb: &mut gridcraft_model::Workbook) {
|
||||
let Some(hc) = html else { return };
|
||||
let Some(n) = hc.num else { return };
|
||||
if cell.formula.is_some() || !matches!(cell.value, Value::Text(_)) || wb.styles.get(cell.style).num_fmt.as_str() == "@" {
|
||||
return;
|
||||
}
|
||||
if crate::clip_html::normalized(&hc.text) != crate::clip_html::normalized(field) {
|
||||
return;
|
||||
}
|
||||
cell.value = Value::number(n);
|
||||
if wb.styles.get(cell.style).num_fmt.as_str() == "General"
|
||||
&& let Some(code) = crate::clip_html::format_for(field, n)
|
||||
{
|
||||
cell.style = wb.styles.derive(cell.style, |s| s.num_fmt = gridcraft_model::NumFmt::new(&code));
|
||||
}
|
||||
}
|
||||
|
||||
/// Tab-separated text with Excel-style quoting.
|
||||
pub(crate) fn parse_tsv(text: &str) -> Vec<Vec<String>> {
|
||||
let mut rows = Vec::new();
|
||||
@@ -770,6 +889,9 @@ fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let skip_blanks = bool_param(p, "skipBlanks").unwrap_or(false);
|
||||
let transpose = bool_param(p, "transpose").unwrap_or(false);
|
||||
let link = bool_param(p, "link").unwrap_or(false);
|
||||
if link && !clip.areas.is_empty() {
|
||||
return Err(EngineError::Other("This action won't work on multiple selections.".into()));
|
||||
}
|
||||
let dest_sheet = target_sheet(s, p)?;
|
||||
let sel = s.doc()?.selection.current();
|
||||
let at = cell_param(p, "at").unwrap_or(sel.start);
|
||||
@@ -1051,6 +1173,18 @@ pub(crate) fn rewrite_all_formulas(wb: &mut gridcraft_model::Workbook, target: &
|
||||
fn clear(s: &mut Session, p: &Json, what: &str) -> Result<Json> {
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let ranges = target_ranges(s, p)?;
|
||||
// Delete on checkboxes works in two passes (as in Excel): checked boxes are unchecked first,
|
||||
// and only when every box in the selection is unchecked are the boxes removed.
|
||||
let uncheck_only = what == "contents" && {
|
||||
let d = s.doc()?;
|
||||
d.wb.sheet(sheet).is_some_and(|sh| {
|
||||
ranges.iter().any(|r| {
|
||||
r.intersection(&sh.used_range().unwrap_or(RangeRef::cell(r.start))).is_some_and(|r| {
|
||||
sh.cells.iter_range(r).any(|(c, x)| x.value == Value::Bool(true) && super::extra::is_checkbox_cell(&d.wb, sh, c))
|
||||
})
|
||||
})
|
||||
})
|
||||
};
|
||||
edit(s, |cx| {
|
||||
for r in &ranges {
|
||||
let r = match cx.wb.sheet(sheet).and_then(|sh| sh.used_range()) {
|
||||
@@ -1062,6 +1196,21 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result<Json> {
|
||||
};
|
||||
let cells: Vec<(CellRef, Cell)> =
|
||||
cx.wb.sheet(sheet).map(|sh| sh.cells.iter_range(r).map(|(c, x)| (c, x.clone())).collect()).unwrap_or_default();
|
||||
let mut cells = cells;
|
||||
if what == "contents" {
|
||||
for (_, cell) in cells.iter_mut() {
|
||||
let checkbox = cx.wb.styles.get(cell.style).num_fmt.as_str() == "checkbox";
|
||||
cell.formula = None;
|
||||
if checkbox && uncheck_only {
|
||||
cell.value = Value::Bool(false);
|
||||
} else {
|
||||
cell.value = Value::Empty;
|
||||
if checkbox {
|
||||
cell.style = cx.wb.styles.derive(cell.style, |st| st.num_fmt = gridcraft_model::NumFmt::new("General"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
for (c, mut cell) in cells {
|
||||
match what {
|
||||
@@ -1069,8 +1218,6 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result<Json> {
|
||||
sh.remove_cell(c);
|
||||
}
|
||||
"contents" => {
|
||||
cell.value = Value::Empty;
|
||||
cell.formula = None;
|
||||
sh.set_cell(c, cell);
|
||||
}
|
||||
"formats" => {
|
||||
@@ -1140,6 +1287,7 @@ fn auto_fill(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let Some(target) = str_param(p, "target").and_then(RangeRef::parse) else { return Err(bad("edit.autoFill", "missing `target`")) };
|
||||
let mode = match str_param(p, "mode").unwrap_or("series") {
|
||||
"copy" => crate::fill::FillMode::Copy,
|
||||
"alternate" => crate::fill::FillMode::Alternate,
|
||||
"formats" => crate::fill::FillMode::Formats,
|
||||
"values" => crate::fill::FillMode::ValuesOnly,
|
||||
_ => crate::fill::FillMode::Series,
|
||||
|
||||
@@ -34,7 +34,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Select Data…",
|
||||
["Chart Design", "Data"],
|
||||
None,
|
||||
"{chart?, range: \"Sheet1!A1:D7\", byRows?: bool}",
|
||||
"{chart?, range?: \"Sheet1!A1:D7\", byRows?: bool, categories?: \"Sheet1!A2:A7\" (Horizontal (Category) Axis Labels for every series; \"\" clears), removeSeries?: [index…]}",
|
||||
has_doc,
|
||||
select_data
|
||||
),
|
||||
@@ -181,7 +181,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Checkbox",
|
||||
["Insert", "Cell Controls"],
|
||||
None,
|
||||
"{range?} (cells hold TRUE/FALSE and draw as checkboxes; Space toggles)",
|
||||
"{range?} (TRUE/FALSE and empty cells draw as checkboxes, empty ones become FALSE, other values show as they are; click or Space toggles; Delete unchecks, then removes; Clear Formats removes and keeps the values)",
|
||||
has_doc,
|
||||
checkbox
|
||||
),
|
||||
@@ -350,22 +350,70 @@ fn change_colors(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
|
||||
fn select_data(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let range = str_param(p, "range").ok_or_else(|| bad("chart.selectData", "missing `range`"))?.to_string();
|
||||
let (sheet_name, body) = split_sheet(&range);
|
||||
let d = s.doc()?;
|
||||
let src_si = match sheet_name {
|
||||
Some(n) => d.wb.sheet_index(&n).ok_or_else(|| bad("chart.selectData", "no such sheet"))?,
|
||||
None => d.wb.active_sheet,
|
||||
// A new data range rebuilds the series, as in Excel's Select Data › Chart data range.
|
||||
let rebuilt = match str_param(p, "range") {
|
||||
Some(range) => {
|
||||
let range = range.trim_start_matches('=');
|
||||
let (sheet_name, body) = split_sheet(range);
|
||||
let src_si = match sheet_name {
|
||||
Some(n) => d.wb.sheet_index(&n).ok_or_else(|| bad("chart.selectData", "no such sheet"))?,
|
||||
None => d.wb.active_sheet,
|
||||
};
|
||||
let r = RangeRef::parse(body).ok_or_else(|| bad("chart.selectData", "not a range"))?;
|
||||
if r.count() > 1_000_000 {
|
||||
return Err(bad("chart.selectData", "range too large for a chart"));
|
||||
}
|
||||
let src = d.wb.sheet(src_si).ok_or(EngineError::NoDocument)?;
|
||||
let by_rows = bool_param(p, "byRows").unwrap_or(r.width() > r.height() + 1);
|
||||
let series = super::insert::series_from_range(src, r, by_rows);
|
||||
Some((series, format!("{}!{}", gridcraft_formula::quote_sheet(&src.name), r.a1()), by_rows))
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let r = RangeRef::parse(body).ok_or_else(|| bad("chart.selectData", "not a range"))?;
|
||||
let src = d.wb.sheet(src_si).ok_or(EngineError::NoDocument)?;
|
||||
let by_rows = bool_param(p, "byRows").unwrap_or(r.width() > r.height() + 1);
|
||||
let series = super::insert::series_from_range(src, r, by_rows);
|
||||
let source = format!("{}!{}", gridcraft_formula::quote_sheet(&src.name), r.a1());
|
||||
// Horizontal (Category) Axis Labels › Edit: one labels range for every series ("" clears).
|
||||
let categories = match str_param(p, "categories") {
|
||||
None => None,
|
||||
Some(t) if t.trim().trim_start_matches('=').is_empty() => Some(None),
|
||||
Some(t) => {
|
||||
let t = t.trim().trim_start_matches('=');
|
||||
let (sheet_name, body) = split_sheet(t);
|
||||
let si = match sheet_name {
|
||||
Some(n) => d.wb.sheet_index(&n).ok_or_else(|| bad("chart.selectData", "no such sheet"))?,
|
||||
None => d.wb.active_sheet,
|
||||
};
|
||||
let r = RangeRef::parse(body).ok_or_else(|| bad("chart.selectData", "`categories` is not a range"))?;
|
||||
let name = d.wb.sheet(si).map(|sh| sh.name.clone()).ok_or(EngineError::NoDocument)?;
|
||||
let abs = |c: CellRef| format!("${}${}", gridcraft_core::col_to_letters(c.col), c.row + 1);
|
||||
Some(Some(format!("{}!{}:{}", gridcraft_formula::quote_sheet(&name), abs(r.start), abs(r.end))))
|
||||
}
|
||||
};
|
||||
let mut remove: Vec<usize> = p
|
||||
.get("removeSeries")
|
||||
.and_then(Json::as_array)
|
||||
.map(|a| a.iter().filter_map(Json::as_u64).filter_map(|i| usize::try_from(i).ok()).collect())
|
||||
.unwrap_or_default();
|
||||
remove.sort_unstable();
|
||||
remove.dedup();
|
||||
if rebuilt.is_none() && categories.is_none() && remove.is_empty() {
|
||||
return Err(bad("chart.selectData", "missing `range`, `categories` or `removeSeries`"));
|
||||
}
|
||||
with_chart(s, p, move |c| {
|
||||
c.series = series;
|
||||
c.source = Some(source);
|
||||
c.by_rows = by_rows;
|
||||
if let Some((series, source, by_rows)) = rebuilt {
|
||||
c.series = series;
|
||||
c.source = Some(source);
|
||||
c.by_rows = by_rows;
|
||||
}
|
||||
for i in remove.iter().rev() {
|
||||
if *i < c.series.len() && c.series.len() > 1 {
|
||||
c.series.remove(*i);
|
||||
}
|
||||
}
|
||||
if let Some(cats) = categories {
|
||||
for se in &mut c.series {
|
||||
se.categories = cats.clone();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
@@ -828,6 +876,11 @@ fn delete_script(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
/// Number format that marks checkbox cells (TRUE/FALSE values drawn as boxes).
|
||||
pub const CHECKBOX_FORMAT: &str = "\"☐\";\"☐\";\"☐\";[=1]\"☑\"";
|
||||
|
||||
/// Whether `c` has checkbox formatting.
|
||||
pub(crate) fn is_checkbox_cell(wb: &gridcraft_model::Workbook, sh: &gridcraft_model::Sheet, c: CellRef) -> bool {
|
||||
wb.styles.get(sh.style_id(c)).num_fmt.as_str() == "checkbox"
|
||||
}
|
||||
|
||||
fn checkbox(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let ranges = target_ranges(s, p)?;
|
||||
let si = target_sheet(s, p)?;
|
||||
@@ -843,9 +896,14 @@ fn checkbox(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
s.align.h = HAlign::Center;
|
||||
});
|
||||
let sh = cx.sheet_mut(si)?;
|
||||
let v = sh.value(c);
|
||||
let value = if matches!(v, Value::Bool(_)) { v } else { Value::Bool(false) };
|
||||
sh.set_cell(c, Cell { value, formula: None, style: new });
|
||||
// Values and formulas already there stay (only TRUE/FALSE draw as a box); an empty
|
||||
// cell starts unchecked.
|
||||
let mut cell = sh.cell(c).cloned().unwrap_or_default();
|
||||
if cell.value.is_empty() && cell.formula.is_none() {
|
||||
cell.value = Value::Bool(false);
|
||||
}
|
||||
cell.style = new;
|
||||
sh.set_cell(c, cell);
|
||||
cx.touch(si, c);
|
||||
}
|
||||
}
|
||||
@@ -862,12 +920,18 @@ fn toggle_checkbox(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
};
|
||||
edit(s, |cx| {
|
||||
for c in cells {
|
||||
let checkbox = cx.wb.sheet(si).is_some_and(|sh| is_checkbox_cell(&cx.wb, sh, c));
|
||||
let sh = cx.sheet_mut(si)?;
|
||||
if let Some(cell) = sh.cells.get_mut(c)
|
||||
&& let Value::Bool(b) = cell.value
|
||||
&& cell.formula.is_none()
|
||||
{
|
||||
cell.value = Value::Bool(!b);
|
||||
// An empty checkbox cell counts as FALSE.
|
||||
let next = match cell.value {
|
||||
Value::Bool(b) if checkbox => Value::Bool(!b),
|
||||
Value::Empty if checkbox => Value::Bool(true),
|
||||
_ => continue,
|
||||
};
|
||||
cell.value = next;
|
||||
cx.changed.push((si, c));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! File menu: new, open, save, close, export, properties.
|
||||
//! File menu: new, open, save, close, export, properties, options.
|
||||
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
@@ -16,6 +16,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!(noundo "file.exportCsv", "Export as CSV", ["File", "Export"], None, "{path, sheet?}", has_doc, |s, p| export(s, p, "csv")),
|
||||
cmd!(noundo "file.exportHtml", "Save as Web Page", ["File", "Export"], None, "{path}", has_doc, |s, p| export(s, p, "html")),
|
||||
cmd!("file.properties", "Properties", ["File"], None, "{title?, subject?, author?, company?, keywords?, description?}", has_doc, properties),
|
||||
cmd!(
|
||||
query "file.options",
|
||||
"Options",
|
||||
["File"],
|
||||
None,
|
||||
"{} → {calculation: {mode, iterative, maxIterations, maxChange}} for the active workbook (the interface opens File › Options; change settings with their own commands, e.g. formulas.calculationOptions, view.theme, app.language.set)",
|
||||
always,
|
||||
options
|
||||
),
|
||||
cmd!(noundo "window.activate", "Switch Window", ["Window"], None, "{index}", has_doc, activate_window),
|
||||
]
|
||||
}
|
||||
@@ -156,6 +165,22 @@ fn properties(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
})
|
||||
}
|
||||
|
||||
/// File › Options: reports the workbook-level settings and asks the interface to show its Options
|
||||
/// page. Nothing changes here; every setting has its own command.
|
||||
fn options(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
s.ui_requests.push(crate::UiRequest::Dialog("options".into(), json!({})));
|
||||
let calculation = s.active().map(|d| {
|
||||
let c = &d.wb.calc;
|
||||
let mode = match c.mode {
|
||||
gridcraft_model::CalcMode::Automatic => "automatic",
|
||||
gridcraft_model::CalcMode::AutomaticExceptTables => "automaticExceptTables",
|
||||
gridcraft_model::CalcMode::Manual => "manual",
|
||||
};
|
||||
json!({"mode": mode, "iterative": c.iterative, "maxIterations": c.max_iterations, "maxChange": c.max_change})
|
||||
});
|
||||
Ok(json!({"calculation": calculation}))
|
||||
}
|
||||
|
||||
fn activate_window(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let i = u32_param(p, "index").ok_or_else(|| bad("window.activate", "missing `index`"))? as usize;
|
||||
if i >= s.documents().len() {
|
||||
|
||||
@@ -89,7 +89,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
// Alignment
|
||||
cmd!("home.alignLeft", "Align Left", ["Home", "Alignment"], Some("Cmd+L"), "{range?}", has_doc, |s, p| halign(s, p, HAlign::Left)),
|
||||
cmd!("home.alignCenter", "Center", ["Home", "Alignment"], Some("Cmd+E"), "{range?}", has_doc, |s, p| halign(s, p, HAlign::Center)),
|
||||
cmd!("home.alignRight", "Align Right", ["Home", "Alignment"], Some("Cmd+R"), "{range?}", has_doc, |s, p| halign(s, p, HAlign::Right)),
|
||||
cmd!("home.alignRight", "Align Right", ["Home", "Alignment"], None, "{range?}", has_doc, |s, p| halign(s, p, HAlign::Right)),
|
||||
cmd!("home.justify", "Justify", ["Home", "Alignment"], None, "{range?}", has_doc, |s, p| halign(s, p, HAlign::Justify)),
|
||||
cmd!("home.alignTop", "Top Align", ["Home", "Alignment"], None, "{range?}", has_doc, |s, p| valign(s, p, VAlign::Top)),
|
||||
cmd!("home.alignMiddle", "Middle Align", ["Home", "Alignment"], None, "{range?}", has_doc, |s, p| valign(s, p, VAlign::Center)),
|
||||
@@ -156,7 +156,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Row Height…",
|
||||
["Home", "Cells", "Format"],
|
||||
None,
|
||||
"{rows?: \"2:5\", height: 20 (points on screen)}",
|
||||
"{rows?: \"2:5\" | \"2:2,4:5\", height: 20 (points on screen)}",
|
||||
has_doc,
|
||||
row_height
|
||||
),
|
||||
@@ -165,7 +165,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Column Width…",
|
||||
["Home", "Cells", "Format"],
|
||||
None,
|
||||
"{cols?: \"B:D\", width: 64 (points) | chars: 8.43}",
|
||||
"{cols?: \"B:D\" | \"B:B,D:F\", width: 64 (points) | chars: 8.43}",
|
||||
has_doc,
|
||||
col_width
|
||||
),
|
||||
@@ -910,8 +910,14 @@ fn cell_style(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
fn line_targets(s: &Session, p: &Json, rows: bool) -> Result<Vec<(u32, u32)>> {
|
||||
let key = if rows { "rows" } else { "cols" };
|
||||
if let Some(t) = str_param(p, key) {
|
||||
let r = RangeRef::parse(t).ok_or_else(|| bad(key, format!("not a range: {t}")))?;
|
||||
return Ok(vec![if rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) }]);
|
||||
// A comma-separated list ("B:B,D:F") targets several areas, like a multi-area selection.
|
||||
return t
|
||||
.split(',')
|
||||
.map(|part| {
|
||||
let r = RangeRef::parse(part).ok_or_else(|| bad(key, format!("not a range: {part}")))?;
|
||||
Ok(if rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) })
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
Ok(s.doc()?.selection.ranges.iter().map(|r| if rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) }).collect())
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Formulas tab: AutoSum, Insert Function, names, auditing, calculation.
|
||||
|
||||
use gridcraft_core::{CellRef, RangeRef};
|
||||
use gridcraft_model::{CalcMode, DefinedName};
|
||||
use gridcraft_model::{CalcMode, DefinedName, Formula};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
use super::*;
|
||||
@@ -32,7 +32,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Define Name…",
|
||||
["Formulas", "Defined Names"],
|
||||
None,
|
||||
"{name, refersTo?: \"=Sheet1!$A$1:$A$10\" (default: the selection), scope?: \"Workbook\"|sheet name, comment?}",
|
||||
"{name, refersTo?: \"=Sheet1!$A$1:$A$10\" (default: the selection), scope?: \"Workbook\"|sheet name, comment?, oldName?, oldScope?} (oldName: edit that name (Name Manager › Edit); a new name also renames it in formulas)",
|
||||
has_doc,
|
||||
define_name
|
||||
),
|
||||
@@ -185,6 +185,7 @@ pub fn function_list() -> Vec<Json> {
|
||||
("SHEET", "Information", "SHEET([value])", "Sheet number."),
|
||||
("SHEETS", "Information", "SHEETS([reference])", "Number of sheets."),
|
||||
("AREAS", "Lookup", "AREAS(reference)", "Number of areas in a reference."),
|
||||
("TRIMRANGE", "Lookup", "TRIMRANGE(range, [trim_rows], [trim_cols])", "A range or array without its blank outer rows and columns."),
|
||||
("ISOMITTED", "Information", "ISOMITTED(argument)", "TRUE when a LAMBDA argument was left out."),
|
||||
];
|
||||
for (n, c, sig, d) in specials {
|
||||
@@ -265,7 +266,32 @@ fn define_name(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
return Err(EngineError::Other("There's a problem with this formula.".into()));
|
||||
}
|
||||
let comment = str_param(p, "comment").unwrap_or("").to_string();
|
||||
// Editing an existing name (Name Manager › Edit…): it must exist, and its new name must not
|
||||
// collide with another name in the target scope.
|
||||
let old = match str_param(p, "oldName") {
|
||||
Some(old_name) => {
|
||||
let old_scope = match str_param(p, "oldScope") {
|
||||
None | Some("Workbook") => None,
|
||||
Some(sh) => Some(d.wb.sheet_index(sh).ok_or_else(|| bad("formulas.defineName", "no such sheet"))?),
|
||||
};
|
||||
if !d.wb.names.iter().any(|n| n.name.eq_ignore_ascii_case(old_name) && n.scope == old_scope) {
|
||||
return Err(bad("formulas.defineName", "no such name to edit"));
|
||||
}
|
||||
let same = old_name.eq_ignore_ascii_case(&name) && old_scope == scope;
|
||||
if !same && d.wb.names.iter().any(|n| n.name.eq_ignore_ascii_case(&name) && n.scope == scope) {
|
||||
return Err(EngineError::Other("The name entered already exists. Enter a unique name.".into()));
|
||||
}
|
||||
Some((old_name.to_string(), old_scope))
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
edit(s, |cx| {
|
||||
if let Some((old_name, old_scope)) = &old {
|
||||
cx.wb.names.retain(|n| !(n.name.eq_ignore_ascii_case(old_name) && n.scope == *old_scope));
|
||||
if !old_name.eq_ignore_ascii_case(&name) {
|
||||
rename_name_in_formulas(&mut cx.wb, old_name, *old_scope, &name);
|
||||
}
|
||||
}
|
||||
cx.wb.names.retain(|n| !(n.name.eq_ignore_ascii_case(&name) && n.scope == scope));
|
||||
cx.wb.names.push(DefinedName { name: name.clone(), scope, formula: refers.clone(), comment: comment.clone(), hidden: false });
|
||||
cx.structural = true;
|
||||
@@ -273,6 +299,51 @@ fn define_name(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Formulas that use a renamed name follow it: cell formulas and other names' definitions, on
|
||||
/// the sheets where `old` resolves to the renamed name (its own sheet for a sheet-scoped name;
|
||||
/// every sheet without a same-named sheet-level name for a workbook-level one).
|
||||
fn rename_name_in_formulas(wb: &mut gridcraft_model::Workbook, old: &str, old_scope: Option<usize>, new: &str) {
|
||||
let shadowed: Vec<usize> = wb.names.iter().filter(|n| n.name.eq_ignore_ascii_case(old)).filter_map(|n| n.scope).collect();
|
||||
let applies = |sheet: usize| match old_scope {
|
||||
Some(s) => s == sheet,
|
||||
None => !shadowed.contains(&sheet),
|
||||
};
|
||||
// The renamed expression, or `None` when the name doesn't occur in it.
|
||||
let rename = |e: gridcraft_formula::Expr| {
|
||||
let mut hit = false;
|
||||
let out = e.map(&mut |x| match x {
|
||||
gridcraft_formula::Expr::Name(n) if n.eq_ignore_ascii_case(old) => {
|
||||
hit = true;
|
||||
gridcraft_formula::Expr::Name(new.to_string())
|
||||
}
|
||||
o => o,
|
||||
});
|
||||
hit.then_some(out)
|
||||
};
|
||||
for i in 0..wb.sheets.len() {
|
||||
if !applies(i) {
|
||||
continue;
|
||||
}
|
||||
let Some(sh) = wb.sheet_mut(i) else { continue };
|
||||
let formulas: Vec<(CellRef, std::sync::Arc<Formula>)> = sh.cells.iter().filter_map(|(c, x)| x.formula.clone().map(|f| (c, f))).collect();
|
||||
for (c, f) in formulas {
|
||||
let Some(e) = f.expr().and_then(rename) else { continue };
|
||||
let mut nf = Formula::from_expr(e);
|
||||
nf.array = f.array;
|
||||
if let Some(cell) = sh.cells.get_mut(c) {
|
||||
cell.formula = Some(std::sync::Arc::new(nf));
|
||||
}
|
||||
}
|
||||
}
|
||||
for n in wb.names.iter_mut() {
|
||||
if n.scope.is_none_or(applies)
|
||||
&& let Some(e) = gridcraft_formula::parse(&n.formula).ok().and_then(rename)
|
||||
{
|
||||
n.formula = gridcraft_formula::print(&e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn delete_name(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("formulas.deleteName", "missing `name`"))?.to_string();
|
||||
let scope = match str_param(p, "scope") {
|
||||
|
||||
+119
-20
@@ -67,10 +67,24 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
insert_chart
|
||||
),
|
||||
cmd!("insert.recommendedCharts", "Recommended Charts", ["Insert", "Charts"], None, "{range?}", has_doc, |s, p| insert_chart(
|
||||
s,
|
||||
&with(p, "type", json!("auto"))
|
||||
)),
|
||||
cmd!(
|
||||
"insert.recommendedCharts",
|
||||
"Recommended Charts",
|
||||
["Insert", "Charts"],
|
||||
None,
|
||||
"{range?, type?, subtype?} (no type: the first of insert.chartRecommendations; the ribbon button opens the dialog)",
|
||||
has_doc,
|
||||
|s, p| insert_chart(s, &if p.get("type").is_some() { p.clone() } else { with(p, "type", json!("auto")) })
|
||||
),
|
||||
cmd!(
|
||||
query "insert.chartRecommendations",
|
||||
"Chart Recommendations",
|
||||
[],
|
||||
None,
|
||||
"{range?} → [{type, subtype, label}] chart types that suit the data's shape, best first",
|
||||
has_doc,
|
||||
|s, p| Ok(Json::Array(recommend_charts(s, p)?.into_iter().map(|(t, sub, label)| json!({"type": t, "subtype": sub, "label": label})).collect()))
|
||||
),
|
||||
cmd!(
|
||||
"chart.set",
|
||||
"Chart Properties",
|
||||
@@ -143,8 +157,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
insert_link
|
||||
),
|
||||
cmd!("review.newNote", "New Note", ["Review", "Notes"], None, "{cell?, text, author?}", has_doc, |s, p| comment(s, p, false)),
|
||||
cmd!("review.newComment", "New Comment", ["Review", "Comments"], None, "{cell?, text, author?}", has_doc, |s, p| comment(s, p, true)),
|
||||
cmd!("review.newNote", "New Note", ["Review", "Notes"], Some("Shift+F2"), "{cell?, text, author?}", has_doc, |s, p| comment(s, p, false)),
|
||||
cmd!("review.newComment", "New Comment", ["Review", "Comments"], Some("Cmd+Shift+F2"), "{cell?, text, author?}", has_doc, |s, p| comment(
|
||||
s, p, true
|
||||
)),
|
||||
cmd!("review.replyComment", "Reply", [], None, "{cell?, text, author?}", has_doc, reply_comment),
|
||||
cmd!("review.resolveComment", "Resolve Thread", [], None, "{cell?, resolved?: true}", has_doc, resolve_comment),
|
||||
cmd!("review.deleteComment", "Delete Comment", ["Review", "Comments"], None, "{cell?}", has_doc, delete_comment),
|
||||
@@ -518,7 +534,12 @@ pub fn series_from_range(sh: &Sheet, r: RangeRef, by_rows: bool) -> Vec<Series>
|
||||
let header_row = r.height() > 1
|
||||
&& (r.start.col..=r.end.col).any(|c| sh.value(CellRef::new(r.start.row, c)).is_text())
|
||||
&& (r.start.col..=r.end.col).filter(|c| *c != r.start.col).all(|c| is_text(CellRef::new(r.start.row, c)));
|
||||
let label_col = r.width() > 1 && (r.start.row + header_row as u32..=r.end.row).all(|row| is_text(CellRef::new(row, r.start.col)));
|
||||
// A blank top-left cell above a header row marks the first column as the category labels even
|
||||
// when it holds numbers (years, X values), the usual spreadsheet convention, so it isn't
|
||||
// plotted as a series of its own (#287).
|
||||
let blank_corner = header_row && sh.value(r.start).is_empty();
|
||||
let label_col =
|
||||
r.width() > 1 && (blank_corner || (r.start.row + header_row as u32..=r.end.row).all(|row| is_text(CellRef::new(row, r.start.col))));
|
||||
let data = RangeRef::new(CellRef::new(r.start.row + header_row as u32, r.start.col + label_col as u32), r.end);
|
||||
let mut out = Vec::new();
|
||||
if !by_rows {
|
||||
@@ -555,7 +576,12 @@ pub fn series_from_range(sh: &Sheet, r: RangeRef, by_rows: bool) -> Vec<Series>
|
||||
/// Most cells a chart's source block may span (bounds the series it builds).
|
||||
const MAX_CHART_CELLS: u64 = 1_000_000;
|
||||
|
||||
fn insert_chart(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
/// Chart types that suit the shape of the data, best first, as `(type, subtype, label)` for
|
||||
/// `insert.chart`. Our own heuristic from the shape of the block (series count, points per
|
||||
/// series, text categories, negative values), not a copy of Excel's ranking: two numeric columns
|
||||
/// without labels → scatter; one short series → column, pie, bar; long series → line; several
|
||||
/// series → clustered and stacked columns and bars, line.
|
||||
pub fn recommend_charts(s: &Session, p: &Json) -> Result<Vec<(&'static str, &'static str, &'static str)>> {
|
||||
let r = block(s, p)?;
|
||||
if r.count() > MAX_CHART_CELLS {
|
||||
return Err(bad("insert.chart", "range too large for a chart"));
|
||||
@@ -563,15 +589,78 @@ fn insert_chart(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let d = s.doc()?;
|
||||
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
|
||||
let by_rows = r.width() > r.height() + 1;
|
||||
let series = series_from_range(sh, r, by_rows);
|
||||
let ranges: Vec<RangeRef> = series
|
||||
.iter()
|
||||
.filter_map(|x| match gridcraft_formula::parse(&x.values) {
|
||||
Ok(gridcraft_formula::Expr::Ref(rf)) => Some(rf.range()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let points = ranges.first().map_or(0, RangeRef::count);
|
||||
let (mut any_number, mut non_negative) = (false, true);
|
||||
for n in ranges.iter().flat_map(|rr| rr.iter()).filter_map(|c| sh.value(c).as_f64()) {
|
||||
any_number = true;
|
||||
non_negative &= n >= 0.0;
|
||||
}
|
||||
if series.is_empty() || !any_number {
|
||||
return Err(EngineError::Other("Select data for the chart first.".into()));
|
||||
}
|
||||
let labelled = series.first().is_some_and(|x| x.categories.is_some());
|
||||
const COLUMN: (&str, &str, &str) = ("column", "", "Clustered Column");
|
||||
const BAR: (&str, &str, &str) = ("bar", "", "Clustered Bar");
|
||||
const LINE: (&str, &str, &str) = ("line", "", "Line");
|
||||
const LINE_MARKERS: (&str, &str, &str) = ("line", "markers", "Line with Markers");
|
||||
const PIE: (&str, &str, &str) = ("pie", "", "Pie");
|
||||
const DOUGHNUT: (&str, &str, &str) = ("doughnut", "", "Doughnut");
|
||||
const AREA: (&str, &str, &str) = ("area", "", "Area");
|
||||
const SCATTER: (&str, &str, &str) = ("scatter", "", "Scatter");
|
||||
const STACKED_COLUMN: (&str, &str, &str) = ("column", "stacked", "Stacked Column");
|
||||
const STACKED_BAR: (&str, &str, &str) = ("bar", "stacked", "Stacked Bar");
|
||||
let mut out = Vec::new();
|
||||
if series.len() == 2 && !labelled && !by_rows {
|
||||
out.extend([SCATTER, LINE_MARKERS, COLUMN]);
|
||||
} else if series.len() == 1 {
|
||||
if points > 12 {
|
||||
out.extend([LINE, COLUMN, AREA]);
|
||||
} else {
|
||||
out.extend([COLUMN, BAR]);
|
||||
if non_negative && points >= 2 {
|
||||
out.extend([PIE, DOUGHNUT]);
|
||||
}
|
||||
out.push(LINE_MARKERS);
|
||||
}
|
||||
} else if points > 12 {
|
||||
out.extend([LINE, COLUMN, AREA, STACKED_COLUMN]);
|
||||
} else {
|
||||
out.extend([COLUMN, BAR, STACKED_COLUMN, LINE_MARKERS, STACKED_BAR]);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The chart `insert.chart` would insert for these params, without inserting it: the sheet it
|
||||
/// goes on and the chart (previews in the Recommended Charts dialog).
|
||||
pub fn build_chart(s: &Session, p: &Json) -> Result<(usize, Chart)> {
|
||||
let r = block(s, p)?;
|
||||
if r.count() > MAX_CHART_CELLS {
|
||||
return Err(bad("insert.chart", "range too large for a chart"));
|
||||
}
|
||||
let mut t = str_param(p, "type").unwrap_or("column").to_string();
|
||||
let mut sub = str_param(p, "subtype").unwrap_or("").to_string();
|
||||
if t == "auto" {
|
||||
let first = recommend_charts(s, p)?.into_iter().next().unwrap_or(("column", "", ""));
|
||||
t = first.0.into();
|
||||
sub = first.1.into();
|
||||
}
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let d = s.doc()?;
|
||||
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
|
||||
// Excel plots series by the longer dimension (more rows → series in columns).
|
||||
let data_h = r.height();
|
||||
let data_w = r.width();
|
||||
let by_rows = data_w > data_h + 1;
|
||||
if t == "auto" {
|
||||
t = if data_h > 12 && data_w <= 3 { "line".into() } else { "column".into() };
|
||||
}
|
||||
let kind = chart_kind(&t, str_param(p, "subtype").unwrap_or(""));
|
||||
let kind = chart_kind(&t, &sub);
|
||||
let series = series_from_range(sh, r, by_rows);
|
||||
if series.is_empty() {
|
||||
return Err(EngineError::Other("Select data for the chart first.".into()));
|
||||
@@ -606,7 +695,14 @@ fn insert_chart(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
y_title: None,
|
||||
source: Some(format!("{}!{}", gridcraft_formula::quote_sheet(&sh.name), r.a1())),
|
||||
by_rows,
|
||||
pivot: None,
|
||||
};
|
||||
Ok((sheet, chart))
|
||||
}
|
||||
|
||||
fn insert_chart(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let (sheet, chart) = build_chart(s, p)?;
|
||||
let id = chart.id;
|
||||
edit(s, |cx| {
|
||||
cx.sheet_mut(sheet)?.charts.push(chart);
|
||||
Ok(json!({"chart": id}))
|
||||
@@ -782,7 +878,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() };
|
||||
@@ -1036,9 +1132,10 @@ fn comment(s: &mut Session, p: &Json, threaded: bool) -> Result<Json> {
|
||||
let text = str_param(p, "text").unwrap_or("").to_string();
|
||||
let author = str_param(p, "author").map(str::to_string).unwrap_or_else(|| s.prefs.user_name.clone());
|
||||
edit(s, |cx| {
|
||||
cx.sheet_mut(sheet)?
|
||||
.comments
|
||||
.insert(at, Comment { author: author.clone(), text: text.clone(), replies: vec![], threaded, resolved: false, visible: false });
|
||||
cx.sheet_mut(sheet)?.comments.insert(
|
||||
at,
|
||||
Comment { author: author.clone(), text: text.clone(), replies: vec![], threaded, resolved: false, visible: false, dates: vec![] },
|
||||
);
|
||||
Ok(json!({"cell": at.a1()}))
|
||||
})
|
||||
}
|
||||
@@ -1157,15 +1254,17 @@ fn add_cf(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
};
|
||||
let rule = match kind.as_str() {
|
||||
"cellIs" => {
|
||||
// Excel's file and VBA names (`lessThan`, …) are accepted too.
|
||||
let op = match g("operator").as_deref().unwrap_or("greater") {
|
||||
"between" => CfOperator::Between,
|
||||
"notBetween" => CfOperator::NotBetween,
|
||||
"equal" => CfOperator::Equal,
|
||||
"notEqual" => CfOperator::NotEqual,
|
||||
"less" => CfOperator::Less,
|
||||
"greaterOrEqual" => CfOperator::GreaterOrEqual,
|
||||
"lessOrEqual" => CfOperator::LessOrEqual,
|
||||
_ => CfOperator::Greater,
|
||||
"less" | "lessThan" => CfOperator::Less,
|
||||
"greaterOrEqual" | "greaterThanOrEqual" => CfOperator::GreaterOrEqual,
|
||||
"lessOrEqual" | "lessThanOrEqual" => CfOperator::LessOrEqual,
|
||||
"greater" | "greaterThan" => CfOperator::Greater,
|
||||
other => return Err(bad("home.conditionalFormat", format!("unknown operator `{other}`"))),
|
||||
};
|
||||
let a = rule.get("value").map(super::edit::json_to_input).unwrap_or_default();
|
||||
let b = rule.get("value2").map(super::edit::json_to_input);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! The command registry. Ids follow Excel's ribbon (`home.bold`, `insert.chart`,
|
||||
//! `data.sortAscending`) plus primitive editing commands (`cell.set`, `selection.set`).
|
||||
|
||||
pub mod analysis;
|
||||
pub mod data;
|
||||
pub mod draw;
|
||||
pub mod edit;
|
||||
@@ -14,6 +15,7 @@ pub mod pivot;
|
||||
pub mod print;
|
||||
pub mod review;
|
||||
pub mod sheet;
|
||||
pub mod solver;
|
||||
pub mod spelling;
|
||||
pub mod view;
|
||||
pub mod whatif;
|
||||
@@ -69,6 +71,21 @@ impl CommandSpec {
|
||||
}
|
||||
}
|
||||
|
||||
/// Params that hold a secret: `password`, or a name ending in `Password`.
|
||||
pub fn is_secret_param(key: &str) -> bool {
|
||||
key == "password" || key.ends_with("Password")
|
||||
}
|
||||
|
||||
/// The params as the journal records them: secret params are left out, so a password never
|
||||
/// reaches the journal dialog, `engine.journal` or a recorded script (saved in the workbook).
|
||||
/// Replaying such an entry runs the command without that param.
|
||||
pub fn journal_params(params: &Json) -> Json {
|
||||
match params {
|
||||
Json::Object(o) => Json::Object(o.iter().filter(|(k, _)| !is_secret_param(k)).map(|(k, v)| (k.clone(), v.clone())).collect()),
|
||||
other => other.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn always(_: &Session) -> std::result::Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
@@ -118,8 +135,10 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(extra::specs());
|
||||
v.extend(draw::specs());
|
||||
v.extend(spelling::specs());
|
||||
v.extend(solver::specs());
|
||||
v.extend(pivot::specs());
|
||||
v.extend(whatif::specs());
|
||||
v.extend(analysis::specs());
|
||||
v.extend(print::specs());
|
||||
v
|
||||
})
|
||||
@@ -257,7 +276,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)));
|
||||
@@ -292,7 +311,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;
|
||||
@@ -302,14 +321,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());
|
||||
|
||||
@@ -43,6 +43,24 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
summarize_table
|
||||
),
|
||||
cmd!(
|
||||
"pivot.calculatedField",
|
||||
"Calculated Field",
|
||||
["PivotTable Analyze", "Calculations"],
|
||||
None,
|
||||
"{pivot?, name, formula?: \"=Sales*0.1\" (fields bare or 'quoted'; evaluated on each cell's sums), delete?: bool, values?: bool (add a new field to Values, default true)} adds, changes or deletes a calculated field; without `name` opens the dialog → {name, formula, calculatedFields}",
|
||||
has_doc,
|
||||
calculated_field
|
||||
),
|
||||
cmd!(
|
||||
"insert.pivotChart",
|
||||
"PivotChart",
|
||||
["Insert", "Charts"],
|
||||
None,
|
||||
"{pivot?: name | id | a cell of the report (\"Sheet2!B5\"; default: the PivotTable at the active cell), type?: column|bar|line|pie|area|… (default column), subtype?, title?, at?: \"H2\", width?, height?} adds a chart bound to the PivotTable's report: categories are the innermost row items, series the column items × value fields; it follows refreshes and layout changes → {chart, pivot, series}",
|
||||
has_doc,
|
||||
insert_pivot_chart
|
||||
),
|
||||
cmd!(query "pivot.fields", "PivotTable Fields", [], None, "{pivot?} → source fields (name, numeric, date, samples, areas) and the current layout", has_doc, fields),
|
||||
cmd!(query "pivot.fieldList", "Field List", ["PivotTable Analyze", "Show"], None, "{pivot?} (same as pivot.fields)", has_doc, fields),
|
||||
cmd!(query "pivot.list", "PivotTables", [], None, "{} → every PivotTable in the workbook", has_doc, list),
|
||||
@@ -320,6 +338,19 @@ fn canonical(cmd: &str, src: &pv::Source, field: &str) -> Result<String> {
|
||||
src.col(field).and_then(|c| src.headers.get(c).cloned()).ok_or_else(|| bad(cmd, format!("no field `{field}` in the source data")))
|
||||
}
|
||||
|
||||
/// The calculated field named `field`, if any.
|
||||
fn calc_field<'a>(pt: &'a PivotTable, field: &str) -> Option<&'a PivotCalcField> {
|
||||
pt.calculated_fields.iter().find(|c| c.name.eq_ignore_ascii_case(field.trim()))
|
||||
}
|
||||
|
||||
/// The canonical name of a source or calculated field.
|
||||
fn canonical_any(cmd: &str, pt: &PivotTable, src: &pv::Source, field: &str) -> Result<String> {
|
||||
match calc_field(pt, field) {
|
||||
Some(c) if src.col(field).is_none() => Ok(c.name.clone()),
|
||||
_ => canonical(cmd, src, field),
|
||||
}
|
||||
}
|
||||
|
||||
fn unique_value_name(pt: &PivotTable, base: &str, except: Option<usize>) -> String {
|
||||
let taken = |n: &str| pt.values.iter().enumerate().any(|(i, v)| Some(i) != except && v.name.eq_ignore_ascii_case(n));
|
||||
if !taken(base) {
|
||||
@@ -343,7 +374,11 @@ fn add_to_area(
|
||||
name: Option<&str>,
|
||||
position: Option<usize>,
|
||||
) -> Result<()> {
|
||||
let field = canonical(cmd, src, field)?;
|
||||
let is_calc = src.col(field).is_none() && calc_field(pt, field).is_some();
|
||||
if is_calc && area != "values" {
|
||||
return Err(other("A calculated field can only be placed in the Values area."));
|
||||
}
|
||||
let field = canonical_any(cmd, pt, src, field)?;
|
||||
match area {
|
||||
"rows" | "columns" => {
|
||||
if pt.rows.len() + pt.columns.len() >= pv::MAX_AXIS_FIELDS && !in_axes(pt, &field) {
|
||||
@@ -369,7 +404,7 @@ fn add_to_area(
|
||||
if pt.values.len() >= pv::MAX_VALUE_FIELDS {
|
||||
return Err(other(format!("A PivotTable can have at most {} value fields.", pv::MAX_VALUE_FIELDS)));
|
||||
}
|
||||
let numeric = src.col(&field).and_then(|c| src.numeric.get(c).copied()).unwrap_or(false);
|
||||
let numeric = is_calc || src.col(&field).and_then(|c| src.numeric.get(c).copied()).unwrap_or(false);
|
||||
let f = func.unwrap_or(if numeric { PivotFunc::Sum } else { PivotFunc::Count });
|
||||
let base = match name {
|
||||
Some(n) if !n.trim().is_empty() => {
|
||||
@@ -663,6 +698,7 @@ fn pivot_json(wb: &Workbook, si: usize, pt: &PivotTable) -> Json {
|
||||
"subtotals": if !pt.subtotals { "none" } else if pt.subtotals_top { "top" } else { "bottom" },
|
||||
"style": pt.style,
|
||||
"showHeaders": pt.show_headers,
|
||||
"calculatedFields": pt.calculated_fields.iter().map(|c| json!({"name": c.name, "formula": c.formula})).collect::<Vec<_>>(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -702,6 +738,11 @@ fn fields(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
.collect(),
|
||||
Err(_) => vec![],
|
||||
};
|
||||
let mut list = list;
|
||||
for c in &pt.calculated_fields {
|
||||
let areas: Vec<&str> = if pt.values.iter().any(|v| v.source_col.eq_ignore_ascii_case(&c.name)) { vec!["values"] } else { vec![] };
|
||||
list.push(json!({"name": c.name, "numeric": true, "date": false, "samples": [], "areas": areas, "calculated": true, "formula": c.formula}));
|
||||
}
|
||||
if let Some(m) = out.as_object_mut() {
|
||||
m.insert("fields".into(), Json::Array(list));
|
||||
if let Ok(src) = &src {
|
||||
@@ -743,7 +784,76 @@ fn add_field(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let pos = position_param(p);
|
||||
update(s, p, |pt, src, _| {
|
||||
add_to_area(C, pt, src, &field, area, func, name.as_deref(), pos)?;
|
||||
Ok(json!({"field": canonical(C, src, &field)?, "area": area}))
|
||||
Ok(json!({"field": canonical_any(C, pt, src, &field)?, "area": area}))
|
||||
})
|
||||
}
|
||||
|
||||
fn calculated_field(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
const C: &str = "pivot.calculatedField";
|
||||
let Some(name) = str_param(p, "name").map(|n| n.trim().to_string()) else {
|
||||
// Menu-style call: the Insert Calculated Field dialog.
|
||||
let (si, pi) = find_pivot(s, p)?;
|
||||
let d = s.doc()?;
|
||||
let pt = d.wb.sheet(si).and_then(|sh| sh.pivots.get(pi)).ok_or_else(|| other("There's no such PivotTable."))?;
|
||||
let n = (1..).map(|k| format!("Field{k}")).find(|n| calc_field(pt, n).is_none()).unwrap_or_else(|| "Field1".into());
|
||||
let pivot = pt.name.clone();
|
||||
s.ui_requests.push(crate::UiRequest::Dialog("pivotCalculatedField".into(), json!({"pivot": pivot, "name": n, "formula": "= 0"})));
|
||||
return ok();
|
||||
};
|
||||
if name.is_empty() || name.chars().count() > 255 {
|
||||
return Err(bad(C, "`name` must be 1 to 255 characters"));
|
||||
}
|
||||
let delete = bool_param(p, "delete").unwrap_or(false);
|
||||
let formula = str_param(p, "formula").map(|f| {
|
||||
let f = f.trim();
|
||||
if f.starts_with('=') { f.to_string() } else { format!("={f}") }
|
||||
});
|
||||
let to_values = bool_param(p, "values").unwrap_or(true);
|
||||
if !delete && formula.is_none() {
|
||||
return Err(bad(C, "missing `formula`"));
|
||||
}
|
||||
update(s, p, |pt, src, _| {
|
||||
let existing = pt.calculated_fields.iter().position(|c| c.name.eq_ignore_ascii_case(&name));
|
||||
if delete {
|
||||
let i = existing.ok_or_else(|| bad(C, format!("no calculated field `{name}`")))?;
|
||||
let gone = pt.calculated_fields.remove(i);
|
||||
pt.values.retain(|v| !v.source_col.eq_ignore_ascii_case(&gone.name));
|
||||
return Ok(json!({"name": gone.name, "deleted": true}));
|
||||
}
|
||||
let formula = formula.clone().unwrap_or_default();
|
||||
if src.col(&name).is_some() {
|
||||
return Err(other("PivotTable field name already exists."));
|
||||
}
|
||||
let canon = match existing {
|
||||
Some(i) => {
|
||||
let c = pt.calculated_fields.get_mut(i).ok_or_else(|| other("There's no such calculated field."))?;
|
||||
c.formula = formula.clone();
|
||||
c.name.clone()
|
||||
}
|
||||
None => {
|
||||
if pt.calculated_fields.len() >= crate::pivot_calc::MAX_CALC_FIELDS {
|
||||
return Err(other(format!("A PivotTable can have at most {} calculated fields.", crate::pivot_calc::MAX_CALC_FIELDS)));
|
||||
}
|
||||
pt.calculated_fields.push(PivotCalcField { name: name.clone(), formula: formula.clone() });
|
||||
name.clone()
|
||||
}
|
||||
};
|
||||
let set = crate::pivot_calc::CalcSet::new(pt, &src.headers);
|
||||
let i = set.index(&canon).ok_or_else(|| other("There's no such calculated field."))?;
|
||||
if let Some(e) = set.error(i) {
|
||||
return Err(bad(C, e.to_string()));
|
||||
}
|
||||
if set.is_cyclic(i) {
|
||||
return Err(bad(C, format!("`{canon}` refers to itself through its formula")));
|
||||
}
|
||||
if existing.is_none() && to_values && !pt.values.iter().any(|v| v.source_col.eq_ignore_ascii_case(&canon)) {
|
||||
add_to_area(C, pt, src, &canon, "values", Some(PivotFunc::Sum), None, None)?;
|
||||
}
|
||||
Ok(json!({
|
||||
"name": canon,
|
||||
"formula": formula,
|
||||
"calculatedFields": pt.calculated_fields.iter().map(|c| json!({"name": c.name, "formula": c.formula})).collect::<Vec<_>>(),
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1171,6 +1281,18 @@ fn change_source(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
fn delete(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let (si, pi) = find_pivot(s, p)?;
|
||||
edit(s, |cx| {
|
||||
// Its PivotCharts become ordinary charts.
|
||||
let link = cx.wb.sheet(si).and_then(|sh| sh.pivots.get(pi).map(|pt| (sh.name.clone(), pt.name.clone())));
|
||||
if let Some((sheet, name)) = link {
|
||||
for i in 0..cx.wb.sheets.len() {
|
||||
let linked = |c: &Chart| c.pivot.as_deref().is_some_and(|l| pv::links_to(l, &sheet, &name));
|
||||
if cx.wb.sheet(i).is_some_and(|sh| sh.charts.iter().any(linked)) {
|
||||
for c in cx.sheet_mut(i)?.charts.iter_mut().filter(|c| linked(c)) {
|
||||
c.pivot = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pv::clear_output(&mut cx.wb, si, pi);
|
||||
let sh = cx.sheet_mut(si)?;
|
||||
if pi < sh.pivots.len() {
|
||||
@@ -1181,6 +1303,80 @@ fn delete(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- PivotChart
|
||||
|
||||
/// The PivotTable a PivotChart call targets: `pivot` may also be a cell of its report.
|
||||
fn chart_pivot(s: &Session, p: &Json) -> Result<(usize, usize)> {
|
||||
if let Some(t) = str_param(p, "pivot") {
|
||||
let d = s.doc()?;
|
||||
let (sheet, addr) = match t.rsplit_once('!') {
|
||||
Some((sh, a)) => {
|
||||
let sh = sh.trim();
|
||||
let sh = sh.strip_prefix('\'').and_then(|x| x.strip_suffix('\'')).map(|x| x.replace("''", "'")).unwrap_or_else(|| sh.to_string());
|
||||
(d.wb.sheet_index(&sh), a)
|
||||
}
|
||||
None => (Some(d.wb.active_sheet), t),
|
||||
};
|
||||
if let (Some(si), Some(c)) = (sheet, CellRef::parse(addr.trim().replace('$', "").as_str())) {
|
||||
let hit = d.wb.sheet(si).and_then(|sh| sh.pivots.iter().position(|pt| pt.last_range.is_some_and(|r| r.contains(c)) || pt.anchor == c));
|
||||
return hit.map(|pi| (si, pi)).ok_or_else(|| bad("insert.pivotChart", format!("{t} is not in a PivotTable")));
|
||||
}
|
||||
}
|
||||
find_pivot(s, p)
|
||||
}
|
||||
|
||||
fn insert_pivot_chart(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let (si, pi) = chart_pivot(s, p)?;
|
||||
let d = s.doc()?;
|
||||
let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?;
|
||||
let pt = sh.pivots.get(pi).ok_or_else(|| other("There's no such PivotTable."))?;
|
||||
let area = pt.last_range.ok_or_else(|| other("Refresh the PivotTable before adding a PivotChart."))?;
|
||||
let out = pv::compute(&d.wb, pt, si).map_err(other)?;
|
||||
if out.chart_rows.is_empty() || out.chart_cols.is_empty() {
|
||||
return Err(other("Add a field to the Values area and one to Rows or Columns before creating a PivotChart."));
|
||||
}
|
||||
let series = pv::chart_series(&sh.name, area.start, &out);
|
||||
let t = str_param(p, "type").unwrap_or("column");
|
||||
let kind = super::insert::chart_kind(t, str_param(p, "subtype").unwrap_or(""));
|
||||
let at = cell_param(p, "at").unwrap_or_else(|| CellRef::new(area.start.row, area.end.col.saturating_add(2).min(gridcraft_core::MAX_COLS - 1)));
|
||||
let title = match p.get("title") {
|
||||
Some(Json::String(t)) => Some(t.clone()),
|
||||
Some(Json::Null) => None,
|
||||
_ => Some(if series.len() == 1 { "Total".to_string() } else { "Chart Title".to_string() }),
|
||||
};
|
||||
let id = d.wb.next_object_id();
|
||||
let link = pv::chart_link(&sh.name, &pt.name);
|
||||
let pivot_name = pt.name.clone();
|
||||
let n = series.len();
|
||||
let chart = Chart {
|
||||
id,
|
||||
kind,
|
||||
anchor: Anchor {
|
||||
cell: at,
|
||||
dx: 0.0,
|
||||
dy: 0.0,
|
||||
width: f64_param(p, "width").unwrap_or(480.0).clamp(10.0, 10_000.0) as f32,
|
||||
height: f64_param(p, "height").unwrap_or(288.0).clamp(10.0, 10_000.0) as f32,
|
||||
mode: anchor_mode_param(p).unwrap_or_default(),
|
||||
},
|
||||
title,
|
||||
series,
|
||||
legend: if matches!(kind, ChartKind::Pie | ChartKind::Doughnut) { LegendPos::Right } else { LegendPos::Bottom },
|
||||
data_labels: false,
|
||||
gridlines: !matches!(kind, ChartKind::Pie | ChartKind::Doughnut | ChartKind::Treemap | ChartKind::Sunburst | ChartKind::Funnel),
|
||||
style: 1,
|
||||
x_title: None,
|
||||
y_title: None,
|
||||
source: None,
|
||||
by_rows: false,
|
||||
pivot: Some(link),
|
||||
};
|
||||
edit(s, |cx| {
|
||||
cx.sheet_mut(si)?.charts.push(chart);
|
||||
Ok(json!({"chart": id, "pivot": pivot_name, "series": n}))
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- recommended
|
||||
|
||||
fn suggestions(wb: &Workbook, src: &pv::Source) -> Vec<Json> {
|
||||
@@ -1280,8 +1476,8 @@ fn recommended(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use gridcraft_core::{CellRef, Value};
|
||||
use serde_json::json;
|
||||
use gridcraft_core::{CellError, CellRef, Value};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
@@ -1550,6 +1746,133 @@ mod tests {
|
||||
s.execute("pivot.refresh", json!({"pivot": "PivotTable1"})).unwrap();
|
||||
}
|
||||
|
||||
fn pivot_chart(s: &Session) -> gridcraft_model::Chart {
|
||||
s.doc().unwrap().wb.active().unwrap().charts.last().unwrap().clone()
|
||||
}
|
||||
fn plotted(s: &Session) -> Vec<Vec<Option<f64>>> {
|
||||
let d = s.doc().unwrap();
|
||||
let data = gridcraft_chart::resolve(&d.wb, d.wb.active_sheet, &pivot_chart(s));
|
||||
data.series.into_iter().map(|x| x.values).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pivot_chart_follows_the_report() {
|
||||
let mut s = sales();
|
||||
s.execute("insert.pivotTable", json!({"rows": ["Region"], "values": ["Sales"]})).unwrap();
|
||||
// Any cell of the report picks the PivotTable.
|
||||
let r = s.execute("insert.pivotChart", json!({"pivot": "B5"})).unwrap();
|
||||
assert_eq!(r["series"], 1);
|
||||
let ch = pivot_chart(&s);
|
||||
assert_eq!(ch.pivot.as_deref(), Some("Sheet2!PivotTable1"));
|
||||
assert_eq!(ch.title.as_deref(), Some("Total"));
|
||||
assert_eq!(ch.series[0].values, "Sheet2!$B$4:$B$6");
|
||||
assert_eq!(ch.series[0].categories.as_deref(), Some("Sheet2!$A$4:$A$6"));
|
||||
assert_eq!(plotted(&s), vec![vec![Some(415.0), Some(135.0), Some(285.0)]]);
|
||||
// A column field: one series per item, rebound by the layout change.
|
||||
s.execute("pivot.addField", json!({"field": "Product", "area": "columns"})).unwrap();
|
||||
let ch = pivot_chart(&s);
|
||||
let names: Vec<_> = ch.series.iter().map(|x| x.name.clone().unwrap_or_default()).collect();
|
||||
assert_eq!(names, vec!["\"Apples\"", "\"Pears\"", "\"Plums\""]);
|
||||
assert_eq!(plotted(&s)[0], vec![Some(160.0), Some(90.0), Some(170.0)]);
|
||||
// Nested row fields: the innermost items only, as a union around the parent rows.
|
||||
s.execute("pivot.moveField", json!({"field": "Product", "from": "columns", "to": "rows"})).unwrap();
|
||||
let ch = pivot_chart(&s);
|
||||
assert!(ch.series[0].values.starts_with('('), "{}", ch.series[0].values);
|
||||
let pts = plotted(&s);
|
||||
assert_eq!(pts[0].len(), 9);
|
||||
assert_eq!(pts[0].iter().flatten().sum::<f64>(), TOTAL);
|
||||
// Survives an XLSX round trip as a PivotChart.
|
||||
let wb = (*s.doc().unwrap().wb).clone();
|
||||
let (back, _) = gridcraft_xlsx::read_xlsx(&gridcraft_xlsx::write_xlsx(&wb).unwrap()).unwrap();
|
||||
let si = back.sheet_index("Sheet2").unwrap();
|
||||
assert_eq!(back.sheet(si).unwrap().charts.first().and_then(|c| c.pivot.clone()).as_deref(), Some("Sheet2!PivotTable1"));
|
||||
// Renaming the sheet keeps the link; a refresh still rebinds.
|
||||
s.execute("sheet.rename", json!({"name": "Report"})).unwrap();
|
||||
assert_eq!(pivot_chart(&s).pivot.as_deref(), Some("Report!PivotTable1"));
|
||||
s.execute("pivot.moveField", json!({"field": "Region", "from": "rows", "to": "columns"})).unwrap();
|
||||
assert_eq!(pivot_chart(&s).series.len(), 3);
|
||||
// Deleting the PivotTable leaves an ordinary chart.
|
||||
s.execute("pivot.delete", json!({})).unwrap();
|
||||
assert_eq!(pivot_chart(&s).pivot, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pivot_chart_errors() {
|
||||
let mut s = sales();
|
||||
// No PivotTable yet.
|
||||
assert!(s.execute("insert.pivotChart", json!({})).is_err());
|
||||
s.execute("insert.pivotTable", json!({"rows": ["Region"]})).unwrap();
|
||||
// Not in the report; no values.
|
||||
assert!(s.execute("insert.pivotChart", json!({"pivot": "Z99"})).is_err());
|
||||
assert!(s.execute("insert.pivotChart", json!({})).unwrap_err().to_string().contains("Values"));
|
||||
s.execute("pivot.addField", json!({"field": "Units", "area": "values"})).unwrap();
|
||||
let r = s.execute("insert.pivotChart", json!({"pivot": "PivotTable1", "type": "line", "title": null})).unwrap();
|
||||
assert_eq!(r["series"], 1);
|
||||
let ch = pivot_chart(&s);
|
||||
assert_eq!(ch.kind, gridcraft_model::ChartKind::Line);
|
||||
assert_eq!(ch.title, None);
|
||||
// One undo step.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(s.doc().unwrap().wb.active().unwrap().charts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calculated_field_on_sums() {
|
||||
let mut s = sales();
|
||||
s.execute("insert.pivotTable", json!({"rows": ["Region"], "values": ["Sales"]})).unwrap();
|
||||
let r = s.execute("pivot.calculatedField", json!({"name": "Price", "formula": "Sales / Units"})).unwrap();
|
||||
assert_eq!(r["formula"], "=Sales / Units");
|
||||
assert_eq!(text(&s, "C3"), "Sum of Price");
|
||||
assert_eq!(text(&s, "A4"), "East");
|
||||
// East: 415 sales over 33 units, from the sums.
|
||||
let east = val(&s, "C4").as_f64().unwrap();
|
||||
assert!((east - 415.0 / 33.0).abs() < 1e-12, "{east}");
|
||||
// The grand total applies the formula to the totals, not the sum of the rows.
|
||||
assert_eq!(text(&s, "A7"), "Grand Total");
|
||||
let total = val(&s, "C7").as_f64().unwrap();
|
||||
assert!((total - TOTAL / 72.0).abs() < 1e-12, "{total}");
|
||||
// Quoted names, functions, and another calculated field.
|
||||
s.execute("pivot.calculatedField", json!({"name": "Rounded", "formula": "=ROUND('Price'*2,1)"})).unwrap();
|
||||
assert_eq!(val(&s, "D4"), Value::Number(25.2));
|
||||
// Division by zero shows #DIV/0! cells.
|
||||
s.execute("pivot.calculatedField", json!({"name": "Zero", "formula": "=Sales/(Units-Units)"})).unwrap();
|
||||
assert_eq!(val(&s, "E4"), Value::Error(CellError::Div0));
|
||||
// Change and delete.
|
||||
s.execute("pivot.calculatedField", json!({"name": "zero", "formula": "=Units*2"})).unwrap();
|
||||
assert_eq!(val(&s, "E4"), Value::Number(66.0));
|
||||
s.execute("pivot.calculatedField", json!({"name": "Zero", "delete": true})).unwrap();
|
||||
assert_eq!(pt(&s).values.len(), 3);
|
||||
assert_eq!(pt(&s).calculated_fields.len(), 2);
|
||||
// The field list shows calculated fields.
|
||||
let f = s.execute("pivot.fields", json!({})).unwrap();
|
||||
assert!(f["fields"].as_array().unwrap().iter().any(|x| x["name"] == "Price" && x["calculated"] == true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calculated_field_errors() {
|
||||
let mut s = sales();
|
||||
s.execute("insert.pivotTable", json!({"rows": ["Region"], "values": ["Sales"]})).unwrap();
|
||||
let mut e = |p: Json| s.execute("pivot.calculatedField", p).unwrap_err().to_string();
|
||||
assert!(e(json!({"name": "Bad", "formula": "=Sales*Bogus"})).contains("Bogus"));
|
||||
assert!(e(json!({"name": "Bad", "formula": "=A1*2"})).contains("cells"));
|
||||
assert!(e(json!({"name": "Bad", "formula": "=Sales*("})).contains("error"));
|
||||
assert!(e(json!({"name": "Sales", "formula": "=1"})).contains("already exists"));
|
||||
assert!(e(json!({"name": "Bad"})).contains("formula"));
|
||||
assert!(e(json!({"name": "Gone", "delete": true})).contains("Gone"));
|
||||
// A cycle is refused.
|
||||
s.execute("pivot.calculatedField", json!({"name": "X", "formula": "=Sales"})).unwrap();
|
||||
s.execute("pivot.calculatedField", json!({"name": "Y", "formula": "=X*2"})).unwrap();
|
||||
let err = s.execute("pivot.calculatedField", json!({"name": "X", "formula": "=Y"})).unwrap_err().to_string();
|
||||
assert!(err.contains("itself"), "{err}");
|
||||
// Calculated fields only go to Values; nothing changed after the failures.
|
||||
assert!(s.execute("pivot.addField", json!({"field": "Y", "area": "rows"})).is_err());
|
||||
assert_eq!(pt(&s).calculated_fields.len(), 2);
|
||||
// Without a name the dialog opens (menu-style call).
|
||||
s.ui_requests.clear();
|
||||
s.execute("pivot.calculatedField", json!({})).unwrap();
|
||||
assert!(matches!(s.ui_requests.last(), Some(crate::UiRequest::Dialog(n, _)) if n == "pivotCalculatedField"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_protection() {
|
||||
let mut s = sales();
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
//! Review tab: protection, spelling (basic), workbook statistics.
|
||||
//! Review tab: protection, spelling (basic), workbook statistics, comment navigation and the
|
||||
//! Comments pane, Show All Notes and Convert to Comments.
|
||||
|
||||
use gridcraft_model::{SheetProtection, password_hash};
|
||||
use gridcraft_core::CellRef;
|
||||
use gridcraft_model::{EditRange, PasswordHash, SheetProtection, Visibility, password_hash};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
use super::*;
|
||||
@@ -19,10 +21,107 @@ 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),
|
||||
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<Json> {
|
||||
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<Json> {
|
||||
let sheets: Vec<usize> =
|
||||
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<Json> {
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let b = |k: &str| bool_param(p, k).unwrap_or(false);
|
||||
@@ -60,6 +159,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 +307,139 @@ fn accessibility(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
}
|
||||
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 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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,6 +155,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();
|
||||
@@ -669,6 +673,12 @@ fn rename_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
se.categories = se.categories.as_deref().map(fix);
|
||||
se.name = se.name.as_deref().map(fix);
|
||||
}
|
||||
// A PivotChart follows its PivotTable's sheet.
|
||||
if let Some((sheet, pivot)) = ch.pivot.as_deref().and_then(crate::pivot::parse_chart_link)
|
||||
&& sheet.eq_ignore_ascii_case(&old)
|
||||
{
|
||||
ch.pivot = Some(crate::pivot::chart_link(&name, &pivot));
|
||||
}
|
||||
}
|
||||
}
|
||||
for n in cx.wb.names.iter_mut() {
|
||||
@@ -824,6 +834,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();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -28,6 +28,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("view.showZeros", "Show Zeros", [], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "zeros")),
|
||||
cmd!("view.rightToLeft", "Sheet Right-to-Left", ["Page Layout", "Sheet Options"], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "rtl")),
|
||||
cmd!("view.zoom", "Zoom", ["View", "Zoom"], None, "{percent: 10..400}", has_doc, zoom),
|
||||
cmd!("view.zoom100", "100%", ["View", "Zoom"], None, "{}", has_doc, |s, _| zoom(s, &json!({"percent": 100}))),
|
||||
cmd!(query "view.formulaBar", "Formula Bar", ["View", "Show"], None, "{on?: bool}", always, |s, p| {
|
||||
s.prefs.formula_bar = bool_param(p, "on").unwrap_or(!s.prefs.formula_bar);
|
||||
Ok(json!({"on": s.prefs.formula_bar}))
|
||||
}),
|
||||
cmd!("view.zoomToSelection", "Zoom to Selection", ["View", "Zoom"], None, "{viewWidth?, viewHeight?}", has_doc, zoom_selection),
|
||||
cmd!("pageLayout.orientation", "Orientation", ["Page Layout", "Page Setup"], None, "{orientation: portrait|landscape}", has_doc, orientation),
|
||||
cmd!("pageLayout.size", "Size", ["Page Layout", "Page Setup"], None, "{paper: Letter|Legal|A4|A3|A5|Tabloid|Executive}", has_doc, paper),
|
||||
@@ -336,3 +341,28 @@ fn theme(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn unused(_: CellRef) {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn zoom_100_resets_the_active_sheet_zoom() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("view.zoom", json!({"percent": 150})).unwrap();
|
||||
assert_eq!(s.execute("view.zoom100", json!({})).unwrap()["zoom"], 100);
|
||||
assert_eq!(s.doc().unwrap().wb.active().unwrap().zoom, 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formula_bar_toggles_without_a_workbook_and_defaults_to_shown() {
|
||||
let mut s = Session::new();
|
||||
assert!(s.prefs.formula_bar);
|
||||
assert_eq!(s.execute("view.formulaBar", json!({})).unwrap()["on"], false);
|
||||
assert_eq!(s.execute("view.formulaBar", json!({"on": true})).unwrap()["on"], true);
|
||||
// A prefs.json saved before the option existed keeps the formula bar shown.
|
||||
let old: crate::Prefs = serde_json::from_str(r#"{"userName": "Ann"}"#).unwrap();
|
||||
assert!(old.formula_bar);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@ fn cell_arg(s: &Session, p: &Json, key: &str, cmd: &str) -> Result<(usize, CellR
|
||||
|
||||
/// A range text (`"A1:C9"`, `"Sheet2!A:C"`) resolved against the workbook. Whole rows/columns
|
||||
/// are trimmed to the used range.
|
||||
fn parse_range(wb: &Workbook, t: &str, cmd: &str) -> Result<(usize, RangeRef)> {
|
||||
pub(crate) fn parse_range(wb: &Workbook, t: &str, cmd: &str) -> Result<(usize, RangeRef)> {
|
||||
let (sh, body) = split_sheet(t);
|
||||
let si = sheet_named(wb, sh, wb.active_sheet, cmd)?;
|
||||
let r = RangeRef::parse(body.trim()).ok_or_else(|| bad(cmd, format!("not a range: `{t}`")))?;
|
||||
|
||||
Reference in New Issue
Block a user