mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-29 05:06:51 -06:00
Key tips HAT/HAM/HAB run Align Top/Middle/Bottom, with a test (review fix-up for #261)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+119
-14
@@ -90,10 +90,7 @@ fn lerp(a: [u8; 3], b: [u8; 3], t: f64) -> [u8; 3] {
|
||||
]
|
||||
}
|
||||
|
||||
/// Number of icons in a set name like `3TrafficLights1`, `5Quarters`.
|
||||
pub fn icon_count(set: &str) -> usize {
|
||||
set.chars().next().and_then(|c| c.to_digit(10)).unwrap_or(3) as usize
|
||||
}
|
||||
pub use gridcraft_model::icon_count;
|
||||
|
||||
impl CfCache {
|
||||
pub fn new() -> Self {
|
||||
@@ -144,7 +141,8 @@ impl CfCache {
|
||||
CfOperator::GreaterOrEqual => compare(&v, &av) != std::cmp::Ordering::Less,
|
||||
CfOperator::LessOrEqual => compare(&v, &av) != std::cmp::Ordering::Greater,
|
||||
};
|
||||
(ok && !v.is_empty()).then_some(style.as_ref())
|
||||
// As in Excel, a blank cell compares as 0 (or ""), and an error never matches.
|
||||
(ok && !v.is_error()).then_some(style.as_ref())
|
||||
}
|
||||
CfRule::Expression { formula, style } => {
|
||||
gridcraft_calc::evaluate(wb, sheet, c, &relative(formula, first, c)).to_bool().unwrap_or(false).then_some(style.as_ref())
|
||||
@@ -154,7 +152,7 @@ impl CfCache {
|
||||
CfRule::BeginsWith { text: t, style } => text.to_lowercase().starts_with(&t.to_lowercase()).then_some(style.as_ref()),
|
||||
CfRule::EndsWith { text: t, style } => text.to_lowercase().ends_with(&t.to_lowercase()).then_some(style.as_ref()),
|
||||
CfRule::Blanks { style } => (v.is_empty() || text.trim().is_empty()).then_some(style.as_ref()),
|
||||
CfRule::NoBlanks { style } => (!v.is_empty()).then_some(style.as_ref()),
|
||||
CfRule::NoBlanks { style } => (!v.is_empty() && !text.trim().is_empty()).then_some(style.as_ref()),
|
||||
CfRule::Errors { style } => v.is_error().then_some(style.as_ref()),
|
||||
CfRule::NoErrors { style } => (!v.is_error()).then_some(style.as_ref()),
|
||||
CfRule::Duplicate { style } => {
|
||||
@@ -261,11 +259,18 @@ impl CfCache {
|
||||
CfRule::IconSet { set, thresholds, reverse, show_value } => {
|
||||
if let Some(n) = v.as_f64() {
|
||||
let k = icon_count(set);
|
||||
let cuts: Vec<f64> = if thresholds.len() + 1 == k {
|
||||
thresholds.iter().map(|t| threshold(wb, sheet, c, &st, t)).collect()
|
||||
} else {
|
||||
(1..k).map(|i| percentile(&st.sorted, i as f64 * 100.0 / k as f64)).collect()
|
||||
// One threshold per icon, the first being the lower bound (as in the file);
|
||||
// also accept the lower bound left out, else Excel's defaults.
|
||||
let defaults;
|
||||
let lower = match thresholds.len() {
|
||||
n if n == k => thresholds.get(1..).unwrap_or_default(),
|
||||
n if n + 1 == k => thresholds.as_slice(),
|
||||
_ => {
|
||||
defaults = default_icon_thresholds(set);
|
||||
defaults.get(1..).unwrap_or_default()
|
||||
}
|
||||
};
|
||||
let cuts: Vec<f64> = lower.iter().map(|t| threshold(wb, sheet, c, &st, t)).collect();
|
||||
let mut idx = cuts.iter().filter(|cut| n >= **cut).count();
|
||||
if *reverse {
|
||||
idx = k - 1 - idx.min(k - 1);
|
||||
@@ -281,10 +286,12 @@ impl CfCache {
|
||||
};
|
||||
if let Some(st) = matched_style {
|
||||
any = true;
|
||||
out.style = Some(match out.style.take() {
|
||||
None => st.clone(),
|
||||
Some(prev) => prev,
|
||||
});
|
||||
// Every matching rule applies; where two set the same part, the rule with the
|
||||
// higher priority (seen first) wins.
|
||||
match &mut out.style {
|
||||
None => out.style = Some(st.clone()),
|
||||
Some(prev) => layer_under(prev, st),
|
||||
}
|
||||
if cf.stop_if_true {
|
||||
break;
|
||||
}
|
||||
@@ -294,6 +301,39 @@ impl CfCache {
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds the parts `lower` sets that `style` leaves unset (differential styles: default parts
|
||||
/// are unset).
|
||||
fn layer_under(style: &mut Style, lower: &Style) {
|
||||
if style.fill.pattern == PatternType::None {
|
||||
style.fill = lower.fill;
|
||||
}
|
||||
if style.font.color == Color::Auto {
|
||||
style.font.color = lower.font.color;
|
||||
}
|
||||
style.font.bold |= lower.font.bold;
|
||||
style.font.italic |= lower.font.italic;
|
||||
style.font.strike |= lower.font.strike;
|
||||
if style.font.underline == Underline::None {
|
||||
style.font.underline = lower.font.underline;
|
||||
}
|
||||
let b = &mut style.borders;
|
||||
for (dst, src) in [
|
||||
(&mut b.left, lower.borders.left),
|
||||
(&mut b.right, lower.borders.right),
|
||||
(&mut b.top, lower.borders.top),
|
||||
(&mut b.bottom, lower.borders.bottom),
|
||||
(&mut b.diag_down, lower.borders.diag_down),
|
||||
(&mut b.diag_up, lower.borders.diag_up),
|
||||
] {
|
||||
if dst.is_none() {
|
||||
*dst = src;
|
||||
}
|
||||
}
|
||||
if style.num_fmt.as_str() == "General" {
|
||||
style.num_fmt = lower.num_fmt.clone();
|
||||
}
|
||||
}
|
||||
|
||||
/// Shifts a rule formula written for the top-left cell of the range to `c`.
|
||||
fn relative(f: &str, first: CellRef, c: CellRef) -> String {
|
||||
if first == c {
|
||||
@@ -306,3 +346,68 @@ fn relative(f: &str, first: CellRef, c: CellRef) -> String {
|
||||
Err(_) => f.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_matching_rule_applies() {
|
||||
let mut s = crate::Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[200], [400], [510]]})).unwrap();
|
||||
let rule = |op: &str, value: &str, preset: &str| json!({"range": "A1:A3", "rule": {"type": "cellIs", "operator": op, "value": value, "preset": preset}});
|
||||
s.execute("home.conditionalFormat", rule("greaterThan", "300", "lightRedFill")).unwrap();
|
||||
s.execute("home.conditionalFormat", rule("greaterThan", "500", "redBorder")).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
let mut cache = CfCache::new();
|
||||
let mut look = |a: &str| cache.look(&d.wb, 0, CellRef::parse(a).unwrap()).and_then(|l| l.style);
|
||||
assert!(look("A1").is_none());
|
||||
let only_fill = look("A2").unwrap();
|
||||
assert_eq!(only_fill.fill.pattern, PatternType::Solid);
|
||||
assert!(only_fill.borders.top.is_none());
|
||||
let both = look("A3").unwrap();
|
||||
assert_eq!(both.fill, only_fill.fill);
|
||||
assert!(!both.borders.top.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cell_value_rules_on_blanks_and_errors() {
|
||||
let mut s = crate::Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[3, "=NA()"], [null, 7], ["=NA()", null], [" ", null], [7, null]]})).unwrap();
|
||||
let rule = |range: &str, r: serde_json::Value| json!({"range": range, "rule": r});
|
||||
s.execute("home.conditionalFormat", rule("A1:A5", json!({"type": "cellIs", "operator": "less", "value": "5", "preset": "lightRedFill"})))
|
||||
.unwrap();
|
||||
s.execute("home.conditionalFormat", rule("A1:A5", json!({"type": "noBlanks", "preset": "redBorder"}))).unwrap();
|
||||
s.execute("home.conditionalFormat", rule("B1:B2", json!({"type": "cellIs", "operator": "greater", "value": "5", "preset": "lightRedFill"})))
|
||||
.unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
let mut cache = CfCache::new();
|
||||
let mut look = |a: &str| cache.look(&d.wb, 0, CellRef::parse(a).unwrap()).and_then(|l| l.style);
|
||||
let fill = |st: Option<Style>| st.is_some_and(|st| st.fill.pattern == PatternType::Solid);
|
||||
let border = |st: Option<Style>| st.is_some_and(|st| !st.borders.top.is_none());
|
||||
// Less than 5: 3 and the blank cell (as 0), not #N/A.
|
||||
assert_eq!([look("A1"), look("A2"), look("A3"), look("A5")].map(fill), [true, true, false, false]);
|
||||
// Greater than 5: 7, not #N/A.
|
||||
assert_eq!([look("B1"), look("B2")].map(fill), [false, true]);
|
||||
// No blanks: neither the empty cell nor the one holding a space.
|
||||
assert_eq!([look("A1"), look("A2"), look("A4")].map(border), [true, false, false]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cell_value_operator_names() {
|
||||
let mut s = crate::Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[3], [7]]})).unwrap();
|
||||
let rule = |op: &str| json!({"range": "A1:A2", "rule": {"type": "cellIs", "operator": op, "value": "5", "preset": "lightRedFill"}});
|
||||
s.execute("home.conditionalFormat", rule("lessThan")).unwrap();
|
||||
assert!(s.execute("home.conditionalFormat", rule("smaller")).is_err());
|
||||
let d = s.doc().unwrap();
|
||||
let mut cache = CfCache::new();
|
||||
let mut look = |a: &str| cache.look(&d.wb, 0, CellRef::parse(a).unwrap()).and_then(|l| l.style).is_some();
|
||||
assert_eq!([look("A1"), look("A2")], [true, false]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
//! Reading the HTML flavour of the system clipboard on paste.
|
||||
//!
|
||||
//! Spreadsheets put an HTML table on the clipboard next to the tab-separated text. The text holds
|
||||
//! what each cell *shows* ("Rp10,000.00"), which en-US input parsing can't always turn back into a
|
||||
//! number. The HTML of the common sources also carries the underlying number as a cell attribute:
|
||||
//! `x:num="10000"` (Excel, Excel for the web) and `sdval="10000"` (LibreOffice Calc). Paste uses
|
||||
//! it to keep such cells numeric (#257). Only the table structure, the cell text and these two
|
||||
//! attributes are read; everything else in the markup is ignored.
|
||||
|
||||
/// Largest clipboard HTML we look at; past this the paste uses the plain text alone.
|
||||
const MAX_HTML: usize = 16 * 1024 * 1024;
|
||||
/// Widest `colspan` honoured (the sheet's column count).
|
||||
const MAX_SPAN: usize = gridcraft_core::MAX_COLS as usize;
|
||||
|
||||
/// One table cell: its visible text and the number the source recorded for it, if any.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub(crate) struct HtmlCell {
|
||||
pub text: String,
|
||||
pub num: Option<f64>,
|
||||
}
|
||||
|
||||
/// The cells of the first table in `html`, row by row; a cell spanning columns is followed by
|
||||
/// empty cells so positions line up with the tab-separated text. Empty when there is no table.
|
||||
pub(crate) fn table_cells(html: &str) -> Vec<Vec<HtmlCell>> {
|
||||
let mut rows: Vec<Vec<HtmlCell>> = Vec::new();
|
||||
if html.len() > MAX_HTML {
|
||||
return rows;
|
||||
}
|
||||
let mut cell: Option<(HtmlCell, usize)> = None;
|
||||
let mut in_table = false;
|
||||
let mut rest = html;
|
||||
while !rest.is_empty() {
|
||||
let Some(lt) = rest.find('<') else {
|
||||
if let Some((c, _)) = cell.as_mut() {
|
||||
c.text.push_str(rest);
|
||||
}
|
||||
break;
|
||||
};
|
||||
if let Some((c, _)) = cell.as_mut() {
|
||||
c.text.push_str(rest.get(..lt).unwrap_or_default());
|
||||
}
|
||||
rest = rest.get(lt..).unwrap_or_default();
|
||||
if let Some(after) = rest.strip_prefix("<!--") {
|
||||
rest = after.find("-->").and_then(|e| after.get(e + 3..)).unwrap_or_default();
|
||||
continue;
|
||||
}
|
||||
let Some(gt) = tag_end(rest) else { break };
|
||||
let tag = rest.get(1..gt).unwrap_or_default();
|
||||
rest = rest.get(gt + 1..).unwrap_or_default();
|
||||
let closing = tag.starts_with('/');
|
||||
let body = tag.trim_start_matches('/');
|
||||
let name_end = body.find(|c: char| c.is_whitespace() || c == '/').unwrap_or(body.len());
|
||||
let name = body.get(..name_end).unwrap_or_default().to_ascii_lowercase();
|
||||
let attrs = body.get(name_end..).unwrap_or_default();
|
||||
match (name.as_str(), closing) {
|
||||
("style" | "script", false) => {
|
||||
// Raw text: skip to the closing tag.
|
||||
let end = format!("</{name}");
|
||||
rest = find_ci(rest, &end).and_then(|i| rest.get(i..)).unwrap_or_default();
|
||||
}
|
||||
("table", false) => {
|
||||
if in_table {
|
||||
break; // a nested table: the outer one is the clipboard grid, stop here
|
||||
}
|
||||
in_table = true;
|
||||
}
|
||||
("table", true) => {
|
||||
finish(&mut rows, &mut cell);
|
||||
if !rows.is_empty() {
|
||||
break;
|
||||
}
|
||||
in_table = false;
|
||||
}
|
||||
("tr", false) if in_table => {
|
||||
finish(&mut rows, &mut cell);
|
||||
rows.push(Vec::new());
|
||||
}
|
||||
("td" | "th", false) if in_table => {
|
||||
finish(&mut rows, &mut cell);
|
||||
if rows.is_empty() {
|
||||
rows.push(Vec::new());
|
||||
}
|
||||
let num = attr(attrs, "x:num").or_else(|| attr(attrs, "sdval")).and_then(|v| v.trim().parse::<f64>().ok()).filter(|n| n.is_finite());
|
||||
let span = attr(attrs, "colspan").and_then(|v| v.trim().parse::<usize>().ok()).unwrap_or(1).clamp(1, MAX_SPAN);
|
||||
cell = Some((HtmlCell { text: String::new(), num }, span));
|
||||
}
|
||||
("td" | "th", true) | ("tr", true) => finish(&mut rows, &mut cell),
|
||||
("br", _) => {
|
||||
if let Some((c, _)) = cell.as_mut() {
|
||||
c.text.push(' ');
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
finish(&mut rows, &mut cell);
|
||||
rows
|
||||
}
|
||||
|
||||
/// Closes the open cell (if any) into the last row, decoding its text.
|
||||
fn finish(rows: &mut Vec<Vec<HtmlCell>>, cell: &mut Option<(HtmlCell, usize)>) {
|
||||
let Some((mut c, span)) = cell.take() else { return };
|
||||
c.text = decode(&c.text);
|
||||
if rows.is_empty() {
|
||||
rows.push(Vec::new());
|
||||
}
|
||||
if let Some(row) = rows.last_mut() {
|
||||
row.push(c);
|
||||
let room = MAX_SPAN.saturating_sub(row.len());
|
||||
row.extend(std::iter::repeat_n(HtmlCell::default(), span.saturating_sub(1).min(room)));
|
||||
}
|
||||
}
|
||||
|
||||
/// Byte index of the `>` that closes the tag `rest` starts with; a `>` inside a quoted attribute
|
||||
/// value (an inline `mso-number-format` condition such as `[>=1000]`) doesn't end it.
|
||||
fn tag_end(rest: &str) -> Option<usize> {
|
||||
let mut quote: Option<char> = None;
|
||||
for (i, c) in rest.char_indices() {
|
||||
match (quote, c) {
|
||||
(Some(q), c) if c == q => quote = None,
|
||||
(Some(_), _) => {}
|
||||
(None, '"' | '\'') => quote = Some(c),
|
||||
(None, '>') => return Some(i),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Case-insensitive ASCII search.
|
||||
fn find_ci(hay: &str, needle: &str) -> Option<usize> {
|
||||
let (h, n) = (hay.as_bytes(), needle.as_bytes());
|
||||
if n.is_empty() || h.len() < n.len() {
|
||||
return None;
|
||||
}
|
||||
(0..=h.len() - n.len()).find(|&i| h.get(i..i + n.len()).is_some_and(|w| w.eq_ignore_ascii_case(n)))
|
||||
}
|
||||
|
||||
/// The value of attribute `name` in a tag's attribute text (quoted or bare). A bare attribute
|
||||
/// without `=` has no value.
|
||||
fn attr<'a>(attrs: &'a str, name: &str) -> Option<&'a str> {
|
||||
let mut s = attrs;
|
||||
loop {
|
||||
s = s.trim_start_matches(|c: char| c.is_whitespace() || c == '/');
|
||||
if s.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let key_end = s.find(|c: char| c.is_whitespace() || c == '=' || c == '/').unwrap_or(s.len());
|
||||
let key = s.get(..key_end).unwrap_or_default();
|
||||
s = s.get(key_end..).unwrap_or_default().trim_start();
|
||||
let mut value = None;
|
||||
if let Some(after) = s.strip_prefix('=') {
|
||||
let after = after.trim_start();
|
||||
let (v, next) = match after.chars().next() {
|
||||
Some(q @ ('"' | '\'')) => {
|
||||
let inner = after.get(1..).unwrap_or_default();
|
||||
let end = inner.find(q).unwrap_or(inner.len());
|
||||
(inner.get(..end).unwrap_or_default(), inner.get(end + 1..).unwrap_or_default())
|
||||
}
|
||||
_ => {
|
||||
let end = after.find(char::is_whitespace).unwrap_or(after.len());
|
||||
(after.get(..end).unwrap_or_default(), after.get(end..).unwrap_or_default())
|
||||
}
|
||||
};
|
||||
value = Some(v);
|
||||
s = next;
|
||||
}
|
||||
if key.eq_ignore_ascii_case(name) {
|
||||
return value;
|
||||
}
|
||||
if key.is_empty() && value.is_none() {
|
||||
// Stray character (e.g. a lone '='): skip it so the loop always advances.
|
||||
let mut chars = s.chars();
|
||||
chars.next();
|
||||
s = chars.as_str();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// HTML text to plain text: entities decoded, whitespace runs (and `<br>`) collapsed to one
|
||||
/// space, trimmed. The text only serves to match a cell with the tab-separated text.
|
||||
fn decode(raw: &str) -> String {
|
||||
let mut out = String::with_capacity(raw.len());
|
||||
let mut rest = raw;
|
||||
while let Some(amp) = rest.find('&') {
|
||||
out.push_str(rest.get(..amp).unwrap_or_default());
|
||||
rest = rest.get(amp..).unwrap_or_default();
|
||||
let semi = rest.get(..12).unwrap_or(rest).find(';');
|
||||
let entity = semi.and_then(|e| rest.get(1..e));
|
||||
let ch = entity.and_then(|e| match e {
|
||||
"amp" => Some('&'),
|
||||
"lt" => Some('<'),
|
||||
"gt" => Some('>'),
|
||||
"quot" => Some('"'),
|
||||
"apos" => Some('\''),
|
||||
"nbsp" => Some('\u{a0}'),
|
||||
_ => {
|
||||
let n = e
|
||||
.strip_prefix("#x")
|
||||
.or_else(|| e.strip_prefix("#X"))
|
||||
.map(|h| u32::from_str_radix(h, 16))
|
||||
.or_else(|| e.strip_prefix('#').map(str::parse::<u32>));
|
||||
n.and_then(Result::ok).and_then(char::from_u32)
|
||||
}
|
||||
});
|
||||
match (ch, semi) {
|
||||
(Some(c), Some(e)) => {
|
||||
out.push(c);
|
||||
rest = rest.get(e + 1..).unwrap_or_default();
|
||||
}
|
||||
_ => {
|
||||
out.push('&');
|
||||
rest = rest.get(1..).unwrap_or_default();
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push_str(rest);
|
||||
let mut text = String::with_capacity(out.len());
|
||||
let mut space = false;
|
||||
for c in out.chars() {
|
||||
if c.is_whitespace() && c != '\u{a0}' {
|
||||
space = true;
|
||||
continue;
|
||||
}
|
||||
if space && !text.is_empty() {
|
||||
text.push(' ');
|
||||
}
|
||||
space = false;
|
||||
text.push(c);
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
/// Same-looking text: non-breaking spaces as spaces, whitespace runs collapsed, trimmed.
|
||||
pub(crate) fn normalized(s: &str) -> String {
|
||||
s.replace('\u{a0}', " ").split_whitespace().collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
/// A number format that shows `n` exactly as `shown` ("Rp10,000.00" for 10000 →
|
||||
/// `"Rp"#,##0.00`), inferred from the text around the digits. `None` unless the format renders
|
||||
/// `shown` back character for character.
|
||||
pub(crate) fn format_for(shown: &str, n: f64) -> Option<String> {
|
||||
let shown = normalized(shown);
|
||||
let start = shown.find(|c: char| c.is_ascii_digit())?;
|
||||
let end = shown.rfind(|c: char| c.is_ascii_digit()).map(|i| i + 1)?;
|
||||
let core = shown.get(start..end)?;
|
||||
let mut prefix = shown.get(..start)?.to_string();
|
||||
let suffix = shown.get(end..)?;
|
||||
if core.chars().any(|c| !(c.is_ascii_digit() || c == ',' || c == '.')) {
|
||||
return None;
|
||||
}
|
||||
if n < 0.0 {
|
||||
// A one-section format shows the minus sign itself, in front of everything.
|
||||
prefix = prefix.strip_prefix('-')?.to_string();
|
||||
}
|
||||
let decimals = core.split_once('.').map_or(0, |(_, f)| f.len());
|
||||
let mut code = String::new();
|
||||
let quote = |s: &str| if s.is_empty() { String::new() } else { format!("\"{}\"", s.replace('"', "")) };
|
||||
code.push_str("e(&prefix));
|
||||
code.push_str(if core.contains(',') { "#,##0" } else { "0" });
|
||||
if decimals > 0 {
|
||||
code.push('.');
|
||||
code.push_str(&"0".repeat(decimals.min(30)));
|
||||
}
|
||||
code.push_str("e(suffix));
|
||||
let rendered = gridcraft_numfmt::text_function(&gridcraft_core::Value::number(n), &code, gridcraft_core::date::DateSystem::D1900).ok()?;
|
||||
(normalized(&rendered) == shown).then_some(code)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reads_excel_and_libreoffice_cell_values() {
|
||||
let excel = r#"<html><head><style>td{mso-number-format:General}</style></head><body><table>
|
||||
<!--StartFragment--><tr height=20><td class=xl65 x:num="10000">Rp10,000.00</td><td x:str>Name</td></tr>
|
||||
<tr><td colspan=2 x:num="2.5">2.50 & up</td><td>x</td></tr><!--EndFragment--></table></body></html>"#;
|
||||
let rows = table_cells(excel);
|
||||
assert_eq!(rows.len(), 2);
|
||||
assert_eq!(rows[0][0], HtmlCell { text: "Rp10,000.00".into(), num: Some(10000.0) });
|
||||
assert_eq!(rows[0][1], HtmlCell { text: "Name".into(), num: None });
|
||||
assert_eq!(rows[1].len(), 3, "colspan keeps the next cell in its column");
|
||||
assert_eq!(rows[1][0].text, "2.50 & up");
|
||||
assert_eq!(rows[1][2].text, "x");
|
||||
|
||||
let calc = r#"<table cellspacing="0" border="0"><colgroup width="85"></colgroup><tr>
|
||||
<td height="17" align="right" sdval="10000" sdnum="1033;0;[$Rp-421]#,##0.00"><font face="Liberation Sans">Rp10,000.00</font></td></tr></table>"#;
|
||||
assert_eq!(table_cells(calc), vec![vec![HtmlCell { text: "Rp10,000.00".into(), num: Some(10000.0) }]]);
|
||||
|
||||
let quoted_gt = r#"<table><tr><td style='mso-number-format:"\[>=1000\]0"' x:num="1500">1500</td></tr></table>"#;
|
||||
assert_eq!(table_cells(quoted_gt), vec![vec![HtmlCell { text: "1500".into(), num: Some(1500.0) }]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_markup_does_not_panic() {
|
||||
for html in [
|
||||
"",
|
||||
"<",
|
||||
"<td",
|
||||
"<table><td x:num=>",
|
||||
"<table><tr><td colspan=999999999>a",
|
||||
"�&",
|
||||
"<table><td x:num='1e999'>1</td>",
|
||||
"<!--",
|
||||
"<style>",
|
||||
"<table><table><td>",
|
||||
] {
|
||||
let _ = table_cells(html);
|
||||
}
|
||||
assert_eq!(table_cells("<table><td x:num='1e999'>1</td></table>")[0][0].num, None);
|
||||
assert!(table_cells("<table><tr><td colspan=999999999>a</td></tr></table>")[0].len() <= MAX_SPAN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn infers_a_format_that_shows_the_same_text() {
|
||||
assert_eq!(format_for("Rp10,000.00", 10000.0).as_deref(), Some("\"Rp\"#,##0.00"));
|
||||
assert_eq!(format_for("1 234 kr", 1234.0), None, "space grouping is not reproduced");
|
||||
assert_eq!(format_for("10.000,00 €", 10000.0), None, "comma decimals don't render back in en-US");
|
||||
assert_eq!(format_for("-Rp5", -5.0).as_deref(), Some("\"Rp\"0"));
|
||||
assert_eq!(format_for("12 EUR", 12.0).as_deref(), Some("0\" EUR\""));
|
||||
}
|
||||
}
|
||||
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
-26
@@ -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,25 +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();
|
||||
// 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, 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 {
|
||||
@@ -631,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)
|
||||
}
|
||||
@@ -674,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),
|
||||
@@ -700,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();
|
||||
@@ -769,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);
|
||||
@@ -1050,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()) {
|
||||
@@ -1061,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 {
|
||||
@@ -1068,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" => {
|
||||
@@ -1139,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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Axis::Cols
|
||||
)),
|
||||
cmd!("home.insertCells", "Insert Cells…", ["Home", "Cells", "Insert"], None, "{range?, shift: right|down|row|column}", has_doc, insert_cells),
|
||||
cmd!(
|
||||
"home.insertCopiedCells",
|
||||
"Insert Copied Cells",
|
||||
["Home", "Cells", "Insert"],
|
||||
None,
|
||||
"{at?: \"B3\", shift?: down|right}: inserts the copied cells at `at` (the active cell) and pastes them there. Whole copied rows or columns insert whole rows or columns; other blocks shift the cells at `at` down (default) or right. A cut of whole rows or columns moves them (Insert Cut Cells)",
|
||||
has_clipboard,
|
||||
insert_copied_cells
|
||||
),
|
||||
cmd!("home.deleteCells", "Delete Cells…", ["Home", "Cells", "Delete"], None, "{range?, shift: left|up|row|column}", has_doc, delete_cells),
|
||||
cmd!("home.insertSheet", "Insert Sheet", ["Home", "Cells", "Insert"], Some("Shift+F11"), "{name?, before?: index}", has_doc, insert_sheet),
|
||||
cmd!("home.deleteSheet", "Delete Sheet", ["Home", "Cells", "Delete"], None, "{sheet?}", has_doc, delete_sheet),
|
||||
@@ -146,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();
|
||||
@@ -381,6 +394,128 @@ fn delete_cells(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Insert Copied Cells / Insert Cut Cells: makes room at the destination, then pastes (or, for a
|
||||
/// cut, moves) the clipboard there, as one undo step.
|
||||
fn insert_copied_cells(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let Some(clip) = s.clipboard.clone() else { return Err(EngineError::Other("Nothing to paste.".into())) };
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let d = s.doc()?;
|
||||
let at = match str_param(p, "at") {
|
||||
Some(t) => cell_param(p, "at").ok_or_else(|| bad("home.insertCopiedCells", format!("not a cell: {t}")))?,
|
||||
None => d.selection.current().start,
|
||||
};
|
||||
let shift = str_param(p, "shift").unwrap_or("down");
|
||||
if shift != "down" && shift != "right" {
|
||||
return Err(bad("home.insertCopiedCells", format!("unknown `shift`: {shift} (down or right)")));
|
||||
}
|
||||
let sel = clip.selected;
|
||||
let rows = sel.is_full_rows();
|
||||
let cols = sel.is_full_cols();
|
||||
if rows && cols {
|
||||
return Err(EngineError::Other("The copy area is the whole sheet; there is no room to insert it.".into()));
|
||||
}
|
||||
let same_doc = d.uid == clip.doc_uid;
|
||||
let cut = clip.cut && same_doc;
|
||||
if cut && !rows && !cols {
|
||||
return Err(EngineError::Other("Insert Cut Cells works on whole rows or columns; cut whole rows or columns, or use Paste.".into()));
|
||||
}
|
||||
// A moved block can't be inserted inside itself.
|
||||
if cut && clip.sheet == sheet {
|
||||
let inside = if rows { at.row > sel.start.row && at.row <= sel.end.row } else { at.col > sel.start.col && at.col <= sel.end.col };
|
||||
if inside {
|
||||
return Err(EngineError::Other("The cut rows or columns can't be inserted inside themselves.".into()));
|
||||
}
|
||||
}
|
||||
let revision = d.revision;
|
||||
let before = (d.wb.clone(), d.selection.clone());
|
||||
let result = insert_copied_steps(s, &clip, sheet, at, shift, cut);
|
||||
let d = s.doc_mut()?;
|
||||
// The nested steps collapse into this command's single undo step.
|
||||
for _ in 0..d.revision.saturating_sub(revision) {
|
||||
d.undo.pop();
|
||||
}
|
||||
match result {
|
||||
Ok((final_range, out)) => {
|
||||
d.selection = crate::Selection { active: final_range.start, anchor: final_range.start, ranges: vec![final_range] };
|
||||
// Like inserting cells, this ends copy mode (the copied block may have moved).
|
||||
cancel_copy_mode(s);
|
||||
Ok(out)
|
||||
}
|
||||
Err(e) => {
|
||||
d.wb = before.0;
|
||||
d.selection = before.1;
|
||||
d.calc.rebuild(&d.wb);
|
||||
s.clipboard = Some(clip);
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn insert_copied_steps(s: &mut Session, clip: &crate::Clipboard, sheet: usize, at: CellRef, shift: &str, cut: bool) -> Result<(RangeRef, Json)> {
|
||||
let sel = clip.selected;
|
||||
let rows = sel.is_full_rows();
|
||||
let cols = sel.is_full_cols();
|
||||
// Where the copied block's top-left lands, relative to the (untrimmed) copied lines.
|
||||
let (inserted, paste_at) = if rows {
|
||||
let h = sel.height();
|
||||
let start = at.row;
|
||||
let r = format!("{}:{}", start as u64 + 1, start as u64 + h as u64);
|
||||
s.execute("home.insertRows", json!({"rows": r, "sheet": sheet}))?;
|
||||
let dr = clip.range.start.row.saturating_sub(sel.start.row);
|
||||
(RangeRef::rows(start, start.saturating_add(h - 1).min(MAX_ROWS - 1)), CellRef::new(start.saturating_add(dr), clip.range.start.col))
|
||||
} else if cols {
|
||||
let w = sel.width();
|
||||
let start = at.col;
|
||||
let end = start.saturating_add(w - 1).min(MAX_COLS - 1);
|
||||
s.execute("home.insertColumns", json!({"cols": RangeRef::cols(start, end).a1(), "sheet": sheet}))?;
|
||||
let dc = clip.range.start.col.saturating_sub(sel.start.col);
|
||||
(RangeRef::cols(start, end), CellRef::new(clip.range.start.row, start.saturating_add(dc)))
|
||||
} else {
|
||||
let end = at
|
||||
.offset(clip.range.height() as i64 - 1, clip.range.width() as i64 - 1)
|
||||
.ok_or_else(|| EngineError::Other("The copied cells don't fit on the sheet at this position.".into()))?;
|
||||
let block = RangeRef::new(at, end);
|
||||
s.execute("home.insertCells", json!({"range": block.a1(), "shift": shift, "sheet": sheet}))?;
|
||||
(block, at)
|
||||
};
|
||||
// Inserting ended copy mode; bring the clipboard back for the paste. A cut reads the live
|
||||
// workbook, so it follows the source if the insert pushed it along.
|
||||
let mut clip = clip.clone();
|
||||
if cut {
|
||||
let pushed = clip.sheet == sheet && if rows { sel.start.row >= at.row } else { sel.start.col >= at.col };
|
||||
if pushed {
|
||||
let (dr, dc) = if rows { (inserted.height() as i64, 0) } else { (0, inserted.width() as i64) };
|
||||
clip.range = RangeRef::new(clip.range.start.offset_clamped(dr, dc), clip.range.end.offset_clamped(dr, dc));
|
||||
clip.selected = RangeRef::new(sel.start.offset_clamped(dr, dc), sel.end.offset_clamped(dr, dc));
|
||||
}
|
||||
clip.wb = s.doc()?.wb.clone();
|
||||
}
|
||||
let source_lines = clip.selected;
|
||||
s.clipboard = Some(clip.clone());
|
||||
let out = s.execute("edit.pasteSpecial", json!({"what": "all", "at": paste_at.a1(), "sheet": sheet}))?;
|
||||
if !cut {
|
||||
return Ok((inserted, out));
|
||||
}
|
||||
// Close the gap the moved lines left behind.
|
||||
let (removed, before_dest) = if rows {
|
||||
s.execute(
|
||||
"home.deleteRows",
|
||||
json!({"rows": format!("{}:{}", source_lines.start.row as u64 + 1, source_lines.end.row as u64 + 1), "sheet": clip.sheet}),
|
||||
)?;
|
||||
(source_lines.height(), clip.sheet == sheet && source_lines.start.row < inserted.start.row)
|
||||
} else {
|
||||
s.execute("home.deleteColumns", json!({"cols": RangeRef::cols(source_lines.start.col, source_lines.end.col).a1(), "sheet": clip.sheet}))?;
|
||||
(source_lines.width(), clip.sheet == sheet && source_lines.start.col < inserted.start.col)
|
||||
};
|
||||
let moved = if before_dest {
|
||||
let (dr, dc) = if rows { (-(removed as i64), 0) } else { (0, -(removed as i64)) };
|
||||
RangeRef::new(inserted.start.offset_clamped(dr, dc), inserted.end.offset_clamped(dr, dc))
|
||||
} else {
|
||||
inserted
|
||||
};
|
||||
Ok((moved, out))
|
||||
}
|
||||
|
||||
fn insert_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if s.doc()?.wb.protected_structure {
|
||||
return Err(EngineError::Other("The workbook is protected and cannot be changed.".into()));
|
||||
@@ -538,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() {
|
||||
@@ -693,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}`")))?;
|
||||
|
||||
@@ -13,6 +13,9 @@ use crate::cmd::Ctx;
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum FillMode {
|
||||
Series,
|
||||
/// The Ctrl+drag fill: the opposite of a plain drag. A lone number fills a
|
||||
/// series stepping by 1; anything a plain drag would extend is copied.
|
||||
Alternate,
|
||||
Copy,
|
||||
Formats,
|
||||
ValuesOnly,
|
||||
@@ -62,13 +65,14 @@ pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: F
|
||||
if forward { (src.end.col + 1..=target.end.col).collect() } else { (target.start.col..src.start.col).rev().collect() };
|
||||
cols.into_iter().map(|c| CellRef::new(lane, c)).collect()
|
||||
};
|
||||
let pattern = if mode == FillMode::Series {
|
||||
Pattern::detect(
|
||||
let pattern = if matches!(mode, FillMode::Series | FillMode::Alternate) {
|
||||
let detected = Pattern::detect(
|
||||
&src_cells,
|
||||
&custom,
|
||||
cx.wb.date_system,
|
||||
cx.wb.styles.get(src_cells.first().and_then(|c| c.1.as_ref()).map(|c| c.style).unwrap_or_default()).num_fmt.as_str(),
|
||||
)
|
||||
);
|
||||
if mode == FillMode::Alternate { detected.alternate(&src_cells) } else { detected }
|
||||
} else {
|
||||
Pattern::Copy
|
||||
};
|
||||
@@ -84,7 +88,7 @@ pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: F
|
||||
writes.push((*t, Some(existing)));
|
||||
continue;
|
||||
}
|
||||
FillMode::ValuesOnly | FillMode::Series | FillMode::Copy => {
|
||||
FillMode::ValuesOnly | FillMode::Series | FillMode::Alternate | FillMode::Copy => {
|
||||
let style = if mode == FillMode::ValuesOnly { sh.cell(*t).map(|c| c.style).unwrap_or_default() } else { cell.style };
|
||||
if let Some(f) = &cell.formula {
|
||||
if let Some(e) = f.expr() {
|
||||
@@ -231,6 +235,19 @@ impl Pattern {
|
||||
Pattern::Copy
|
||||
}
|
||||
|
||||
/// The pattern of a Ctrl+drag, which swaps copying and extending: a single
|
||||
/// number (which a plain drag copies) counts up by 1, and every series a
|
||||
/// plain drag would extend (numbers, dates, lists, `Item 1`) is copied.
|
||||
fn alternate(self, cells: &[(CellRef, Option<Cell>)]) -> Pattern {
|
||||
match (self, cells) {
|
||||
(Pattern::Copy, [(_, Some(cell))]) if cell.formula.is_none() => match cell.value.as_f64() {
|
||||
Some(n) if cell.value.is_number() => Pattern::Linear { last: n, first: n, step: 1.0 },
|
||||
_ => Pattern::Copy,
|
||||
},
|
||||
_ => Pattern::Copy,
|
||||
}
|
||||
}
|
||||
|
||||
/// The value `k` steps beyond the source (forward) or before it (backward).
|
||||
fn value(&self, k: i64, forward: bool, _len: usize) -> Option<Value> {
|
||||
let sign = if forward { 1 } else { -1 };
|
||||
|
||||
@@ -8,11 +8,13 @@
|
||||
|
||||
pub mod catalog;
|
||||
pub mod cf;
|
||||
mod clip_html;
|
||||
pub mod cmd;
|
||||
pub mod display;
|
||||
pub mod fill;
|
||||
pub mod io;
|
||||
pub mod pivot;
|
||||
pub mod pivot_calc;
|
||||
pub mod sample;
|
||||
pub mod selection;
|
||||
pub mod tables;
|
||||
@@ -65,11 +67,17 @@ pub struct Clipboard {
|
||||
pub wb: Arc<Workbook>,
|
||||
pub sheet: usize,
|
||||
pub range: RangeRef,
|
||||
/// The range as copied, before whole rows/columns were trimmed to the used range
|
||||
/// (Insert Copied Cells inserts whole lines for a row or column copy).
|
||||
pub selected: RangeRef,
|
||||
pub cut: bool,
|
||||
/// Document uid of the source (a cut only moves within the same workbook).
|
||||
pub doc_uid: u64,
|
||||
/// Plain text (tab-separated) for the system clipboard.
|
||||
pub text: String,
|
||||
/// A multi-area copy: the areas as selected (for the marquee). `wb` then holds them slid
|
||||
/// together into one block at `range`. Empty for a single-range copy.
|
||||
pub areas: Vec<RangeRef>,
|
||||
}
|
||||
|
||||
/// In-cell edit mode as shown in the status bar.
|
||||
@@ -96,6 +104,8 @@ pub struct DocState {
|
||||
pub title: String,
|
||||
/// Per-sheet selection memory.
|
||||
pub sheet_selections: Vec<Selection>,
|
||||
/// Allow Edit Ranges unlocked with their password in this session: (sheet name, title).
|
||||
pub unlocked_ranges: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
@@ -119,6 +129,7 @@ impl DocState {
|
||||
uid: NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
|
||||
title,
|
||||
sheet_selections: vec![],
|
||||
unlocked_ranges: vec![],
|
||||
}
|
||||
}
|
||||
pub fn is_dirty(&self) -> bool {
|
||||
@@ -150,6 +161,8 @@ pub struct Prefs {
|
||||
pub r1c1: bool,
|
||||
/// Words added with Add to Dictionary.
|
||||
pub user_dictionary: Vec<String>,
|
||||
/// View › Show › Formula Bar (an application option in Excel, not saved in the file).
|
||||
pub formula_bar: bool,
|
||||
}
|
||||
|
||||
impl Default for Prefs {
|
||||
@@ -163,6 +176,7 @@ impl Default for Prefs {
|
||||
sheets_in_new_workbook: 1,
|
||||
r1c1: false,
|
||||
user_dictionary: vec![],
|
||||
formula_bar: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -184,6 +198,7 @@ pub struct Session {
|
||||
pub clipboard: Option<Clipboard>,
|
||||
pub prefs: Prefs,
|
||||
pub mode: Mode,
|
||||
/// Commands run so far, without their passwords ([`cmd::journal_params`]).
|
||||
pub journal: Vec<(String, Json)>,
|
||||
pub ui_requests: Vec<UiRequest>,
|
||||
/// Format painter source style (sticky when double-clicked).
|
||||
@@ -200,6 +215,11 @@ pub struct Session {
|
||||
pub draw_tool: String,
|
||||
pub draw_color: String,
|
||||
pub draw_width: f32,
|
||||
/// Review › Show Comments: the Comments pane is open.
|
||||
pub comments_pane: bool,
|
||||
/// Review › Notes › Show All Notes: every note is displayed. An application display setting
|
||||
/// (like Excel's comment indicator option), so it's not saved in the file.
|
||||
pub show_all_notes: bool,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
@@ -284,7 +304,7 @@ impl Session {
|
||||
}
|
||||
};
|
||||
if spec.journal && result.is_ok() {
|
||||
self.journal.push((id.to_string(), params.clone()));
|
||||
self.journal.push((id.to_string(), cmd::journal_params(¶ms)));
|
||||
if self.journal.len() > 10_000 {
|
||||
self.journal.drain(..5_000);
|
||||
}
|
||||
|
||||
+224
-14
@@ -12,6 +12,8 @@ use gridcraft_core::date::datetime_from_serial;
|
||||
use gridcraft_core::{CellError, CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value};
|
||||
use gridcraft_model::*;
|
||||
|
||||
use crate::pivot_calc::CalcSet;
|
||||
|
||||
/// Largest source block read (cells).
|
||||
pub const MAX_SOURCE_CELLS: u64 = 10_000_000;
|
||||
/// Largest report written (cells).
|
||||
@@ -503,6 +505,12 @@ pub struct Output {
|
||||
/// Rows above the body (report filters plus a blank row).
|
||||
pub body_offset: u32,
|
||||
pub width: u32,
|
||||
/// Grid rows of the innermost (or collapsed) items: a PivotChart's categories.
|
||||
pub chart_rows: Vec<u32>,
|
||||
/// Grid columns of item (not total) values with their series names: a PivotChart's series.
|
||||
pub chart_cols: Vec<(u32, String)>,
|
||||
/// Grid column holding the innermost row labels (`None` without row fields).
|
||||
pub label_col: Option<u32>,
|
||||
}
|
||||
|
||||
impl Output {
|
||||
@@ -546,6 +554,8 @@ enum LineKind {
|
||||
|
||||
struct Line {
|
||||
kind: LineKind,
|
||||
/// A category of a PivotChart: an innermost (or collapsed) item.
|
||||
leaf: bool,
|
||||
path: Vec<u32>,
|
||||
values: bool,
|
||||
/// (column, value, indent, number format)
|
||||
@@ -587,14 +597,21 @@ impl Ctx<'_> {
|
||||
let (col, indent) = if outline { (depth, 0) } else { (0, depth.min(250) as u8) };
|
||||
let collapsed = depth + 1 < n && self.rows.get(depth).is_some_and(|f| is_collapsed(f, &label));
|
||||
if depth + 1 >= n || collapsed || node.children.is_empty() {
|
||||
out.push(Line { kind: LineKind::Item(depth + 1 < n), path: prefix.clone(), values: true, labels: vec![(col, v, indent, fmt)] });
|
||||
out.push(Line {
|
||||
kind: LineKind::Item(depth + 1 < n),
|
||||
leaf: true,
|
||||
path: prefix.clone(),
|
||||
values: true,
|
||||
labels: vec![(col, v, indent, fmt)],
|
||||
});
|
||||
} else {
|
||||
let top = self.pt.subtotals && self.pt.subtotals_top;
|
||||
out.push(Line { kind: LineKind::Item(true), path: prefix.clone(), values: top, labels: vec![(col, v, indent, fmt)] });
|
||||
out.push(Line { kind: LineKind::Item(true), leaf: false, path: prefix.clone(), values: top, labels: vec![(col, v, indent, fmt)] });
|
||||
self.flatten(&node.children, depth + 1, prefix, out);
|
||||
if self.pt.subtotals && !self.pt.subtotals_top {
|
||||
out.push(Line {
|
||||
kind: LineKind::Subtotal,
|
||||
leaf: false,
|
||||
path: prefix.clone(),
|
||||
values: true,
|
||||
labels: vec![(col, Value::text(format!("{label} Total")), indent, None)],
|
||||
@@ -621,12 +638,13 @@ impl Ctx<'_> {
|
||||
labels.push((depth, v, 0, fmt));
|
||||
let collapsed = depth + 1 < n && self.rows.get(depth).is_some_and(|f| is_collapsed(f, &label));
|
||||
if depth + 1 >= n || collapsed || node.children.is_empty() {
|
||||
out.push(Line { kind: LineKind::Item(false), path: prefix.clone(), values: true, labels });
|
||||
out.push(Line { kind: LineKind::Item(false), leaf: true, path: prefix.clone(), values: true, labels });
|
||||
} else {
|
||||
self.flatten_tabular(&node.children, depth + 1, prefix, &labels, out);
|
||||
if self.pt.subtotals {
|
||||
out.push(Line {
|
||||
kind: LineKind::Subtotal,
|
||||
leaf: false,
|
||||
path: prefix.clone(),
|
||||
values: true,
|
||||
labels: vec![(depth, Value::text(format!("{label} Total")), 0, None)],
|
||||
@@ -666,13 +684,44 @@ fn flatten_cols(
|
||||
|
||||
type AggMap = HashMap<(Vec<u32>, Vec<u32>), Vec<Acc>>;
|
||||
|
||||
/// Where a value field's numbers come from.
|
||||
#[derive(Clone, Copy)]
|
||||
enum ValSrc {
|
||||
/// A source column.
|
||||
Col(usize),
|
||||
/// A calculated field (index into the PivotTable's calculated fields).
|
||||
Calc(usize),
|
||||
}
|
||||
|
||||
/// Computes a PivotTable report from the workbook.
|
||||
pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Output, String> {
|
||||
let src = read_source(wb, &pt.source, pivot_sheet)?;
|
||||
let fmt_of = |c: usize| src.formats.get(c).cloned().unwrap_or_else(|| "General".into());
|
||||
let rows: Vec<(usize, &PivotField)> = pt.rows.iter().filter_map(|f| src.col(&f.source_col).map(|c| (c, f))).collect();
|
||||
let cols: Vec<(usize, &PivotField)> = pt.columns.iter().filter_map(|f| src.col(&f.source_col).map(|c| (c, f))).collect();
|
||||
let vals: Vec<(usize, &PivotValue)> = pt.values.iter().filter_map(|v| src.col(&v.source_col).map(|c| (c, v))).collect();
|
||||
// Value fields: a source column, or a calculated field evaluated on sums (see `pivot_calc`).
|
||||
let calc = CalcSet::new(pt, &src.headers);
|
||||
let vals: Vec<(ValSrc, &PivotValue)> = pt
|
||||
.values
|
||||
.iter()
|
||||
.filter_map(|v| match src.col(&v.source_col) {
|
||||
Some(c) => Some((ValSrc::Col(c), v)),
|
||||
None => calc.index(&v.source_col).map(|i| (ValSrc::Calc(i), v)),
|
||||
})
|
||||
.collect();
|
||||
// Source columns summed for the calculated fields, accumulated after the value fields.
|
||||
let mut extra: Vec<usize> = Vec::new();
|
||||
for (vs, _) in &vals {
|
||||
if let ValSrc::Calc(i) = vs {
|
||||
for name in calc.source_refs(*i) {
|
||||
if let Some(c) = src.col(&name)
|
||||
&& !extra.contains(&c)
|
||||
{
|
||||
extra.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if rows.len() + cols.len() > MAX_AXIS_FIELDS {
|
||||
return Err(format!("A PivotTable can have at most {MAX_AXIS_FIELDS} row and column fields."));
|
||||
}
|
||||
@@ -728,8 +777,13 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
|
||||
}
|
||||
for a in 0..=rp.len() {
|
||||
for b in 0..=cp.len() {
|
||||
let e = aggs.entry((rp[..a].to_vec(), cp[..b].to_vec())).or_insert_with(|| vec![Acc::default(); k]);
|
||||
for (acc, (c, _)) in e.iter_mut().zip(vals.iter()) {
|
||||
let e = aggs.entry((rp[..a].to_vec(), cp[..b].to_vec())).or_insert_with(|| vec![Acc::default(); k + extra.len()]);
|
||||
for (acc, (vs, _)) in e.iter_mut().zip(vals.iter()) {
|
||||
if let ValSrc::Col(c) = vs {
|
||||
acc.add(rec.get(*c).unwrap_or(&Value::Empty));
|
||||
}
|
||||
}
|
||||
for (acc, c) in e.iter_mut().skip(k).zip(extra.iter()) {
|
||||
acc.add(rec.get(*c).unwrap_or(&Value::Empty));
|
||||
}
|
||||
}
|
||||
@@ -755,7 +809,7 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
|
||||
let ctx = Ctx { pt, rows: row_fields.clone(), row_items, row_fmts: row_fmts.clone() };
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
if rows.is_empty() {
|
||||
lines.push(Line { kind: LineKind::Item(false), path: vec![], values: true, labels: vec![] });
|
||||
lines.push(Line { kind: LineKind::Item(false), leaf: true, path: vec![], values: true, labels: vec![] });
|
||||
} else {
|
||||
if pt.layout == PivotLayout::Tabular {
|
||||
ctx.flatten_tabular(&row_tree, 0, &mut Vec::new(), &[], &mut lines);
|
||||
@@ -763,7 +817,13 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
|
||||
ctx.flatten(&row_tree, 0, &mut Vec::new(), &mut lines);
|
||||
}
|
||||
if pt.grand_totals_cols {
|
||||
lines.push(Line { kind: LineKind::Grand, path: vec![], values: true, labels: vec![(0, Value::text("Grand Total"), 0, None)] });
|
||||
lines.push(Line {
|
||||
kind: LineKind::Grand,
|
||||
leaf: false,
|
||||
path: vec![],
|
||||
values: true,
|
||||
labels: vec![(0, Value::text("Grand Total"), 0, None)],
|
||||
});
|
||||
}
|
||||
}
|
||||
let mut specs: Vec<ColSpec> = Vec::new();
|
||||
@@ -829,7 +889,15 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
|
||||
if empty {
|
||||
let r = g.push_row(RowKind::Placeholder)?;
|
||||
g.set(r, 0, Value::text(format!("{} — choose fields", pt.name)), CellKind::Placeholder, 0, None);
|
||||
return Ok(Output { width: g.width as u32, grid: g.rows, row_kinds: g.kinds, body_offset });
|
||||
return Ok(Output {
|
||||
width: g.width as u32,
|
||||
grid: g.rows,
|
||||
row_kinds: g.kinds,
|
||||
body_offset,
|
||||
chart_rows: vec![],
|
||||
chart_cols: vec![],
|
||||
label_col: None,
|
||||
});
|
||||
}
|
||||
|
||||
let val_name = |vi: usize| vals.get(vi).map(|(_, v)| v.name.clone()).unwrap_or_default();
|
||||
@@ -915,8 +983,21 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
|
||||
|
||||
// ---- body
|
||||
let get = |rp: &[u32], cp: &[u32], vi: usize| -> Option<Value> {
|
||||
let (_, v) = vals.get(vi)?;
|
||||
aggs.get(&(rp.to_vec(), cp.to_vec())).and_then(|a| a.get(vi)).map(|a| a.result(v.func))
|
||||
let (vs, v) = vals.get(vi)?;
|
||||
let accs = aggs.get(&(rp.to_vec(), cp.to_vec()))?;
|
||||
match vs {
|
||||
ValSrc::Col(_) => accs.get(vi).map(|a| a.result(v.func)),
|
||||
ValSrc::Calc(i) => {
|
||||
let sum = |name: &str| -> Value {
|
||||
src.col(name)
|
||||
.and_then(|c| extra.iter().position(|x| *x == c))
|
||||
.and_then(|j| accs.get(k + j))
|
||||
.map(|a| a.result(PivotFunc::Sum))
|
||||
.unwrap_or(Value::Number(0.0))
|
||||
};
|
||||
Some(calc.value(wb, *i, &sum))
|
||||
}
|
||||
}
|
||||
};
|
||||
let num = |v: &Option<Value>| v.as_ref().and_then(Value::as_f64);
|
||||
let ratio = |a: Value, b: Option<Value>| -> Value {
|
||||
@@ -935,7 +1016,7 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
|
||||
};
|
||||
let data_fmt: Vec<Option<String>> = vals
|
||||
.iter()
|
||||
.map(|(c, v)| match v.show_as {
|
||||
.map(|(vs, v)| match v.show_as {
|
||||
PivotShowAs::PercentOfGrandTotal | PivotShowAs::PercentOfColumnTotal | PivotShowAs::PercentOfRowTotal => {
|
||||
Some(v.number_format.clone().unwrap_or_else(|| "0.00%".into()))
|
||||
}
|
||||
@@ -943,12 +1024,42 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
|
||||
_ => v.number_format.clone().or_else(|| match v.func {
|
||||
PivotFunc::Count | PivotFunc::CountNumbers => None,
|
||||
_ => {
|
||||
let f = fmt_of(*c);
|
||||
let f = match vs {
|
||||
ValSrc::Col(c) => fmt_of(*c),
|
||||
ValSrc::Calc(_) => "General".to_string(),
|
||||
};
|
||||
(f != "General").then_some(f)
|
||||
}
|
||||
}),
|
||||
})
|
||||
.collect();
|
||||
let mut chart_rows: Vec<u32> = Vec::new();
|
||||
let chart_cols: Vec<(u32, String)> = data_cols
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(j, (si, vi))| {
|
||||
let spec = specs.get(*si)?;
|
||||
if spec.kind != ColKind::Leaf {
|
||||
return None;
|
||||
}
|
||||
let mut parts: Vec<String> =
|
||||
spec.path.iter().enumerate().map(|(lv, it)| col_items.get(lv).map(|fi| fi.label(*it)).unwrap_or_default()).collect();
|
||||
if let Some(vi) = vi
|
||||
&& (k > 1 || parts.is_empty())
|
||||
{
|
||||
parts.push(val_name(*vi));
|
||||
}
|
||||
Some(((l + j) as u32, parts.join(" - ")))
|
||||
})
|
||||
.filter(|_| k > 0)
|
||||
.collect();
|
||||
let label_col = if rows.is_empty() {
|
||||
None
|
||||
} else if compact {
|
||||
Some(0)
|
||||
} else {
|
||||
Some(rows.len().saturating_sub(1) as u32)
|
||||
};
|
||||
for line in &lines {
|
||||
let rk = match line.kind {
|
||||
LineKind::Item(b) => RowKind::Item(b),
|
||||
@@ -961,6 +1072,9 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
|
||||
LineKind::Grand => (CellKind::GrandTotal, CellKind::GrandTotal),
|
||||
};
|
||||
let r = g.push_row(rk)?;
|
||||
if line.leaf && k > 0 {
|
||||
chart_rows.push(r as u32);
|
||||
}
|
||||
for (c, v, indent, fmt) in &line.labels {
|
||||
g.set(r, *c, v.clone(), ck.0, *indent, fmt.clone());
|
||||
}
|
||||
@@ -1015,7 +1129,7 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
|
||||
g.set(r, l + j, shown, ck.1, 0, data_fmt.get(*vi).cloned().flatten());
|
||||
}
|
||||
}
|
||||
Ok(Output { width: g.width as u32, grid: g.rows, row_kinds: g.kinds, body_offset })
|
||||
Ok(Output { width: g.width as u32, grid: g.rows, row_kinds: g.kinds, body_offset, chart_rows, chart_cols, label_col })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- styling and writing
|
||||
@@ -1205,9 +1319,105 @@ pub fn refresh(wb: &mut Workbook, sheet: usize, idx: usize, replace: bool) -> Re
|
||||
if let Some(p) = shm.pivots.get_mut(idx) {
|
||||
p.last_range = Some(area);
|
||||
}
|
||||
let sheet_name = shm.name.clone();
|
||||
rebind_charts(wb, &sheet_name, &pt.name, origin, &out);
|
||||
Ok(area)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- PivotCharts
|
||||
|
||||
/// The link a PivotChart stores to its PivotTable: `Sheet2!PivotTable1`.
|
||||
pub fn chart_link(sheet: &str, pivot: &str) -> String {
|
||||
format!("{}!{pivot}", quote_sheet(sheet))
|
||||
}
|
||||
|
||||
/// (sheet, PivotTable name) of a PivotChart link; a leading `[Book.xlsx]` is ignored.
|
||||
pub fn parse_chart_link(link: &str) -> Option<(String, String)> {
|
||||
let t = link.trim();
|
||||
let t = match t.strip_prefix('[') {
|
||||
Some(rest) => rest.split_once(']').map(|(_, r)| r).unwrap_or(rest),
|
||||
None => t,
|
||||
};
|
||||
let (sheet, name) = t.rsplit_once('!')?;
|
||||
let sheet = sheet.trim();
|
||||
let sheet = sheet.strip_prefix('\'').and_then(|x| x.strip_suffix('\'')).map(|x| x.replace("''", "'")).unwrap_or_else(|| sheet.to_string());
|
||||
let name = name.trim();
|
||||
if sheet.is_empty() || name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some((sheet, name.to_string()))
|
||||
}
|
||||
|
||||
/// Whether a PivotChart link points at `pivot` on `sheet`.
|
||||
pub fn links_to(link: &str, sheet: &str, pivot: &str) -> bool {
|
||||
parse_chart_link(link).is_some_and(|(s, n)| s.eq_ignore_ascii_case(sheet) && n.eq_ignore_ascii_case(pivot))
|
||||
}
|
||||
|
||||
/// An absolute reference to `col` over grid `rows` (contiguous runs joined into a union).
|
||||
fn rows_ref(sheet: &str, origin: CellRef, col: u32, rows: &[u32]) -> Option<String> {
|
||||
let q = quote_sheet(sheet);
|
||||
let c = origin.col.checked_add(col)?;
|
||||
let letters = gridcraft_core::col_to_letters(c);
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
let mut i = 0;
|
||||
while let Some(&start) = rows.get(i) {
|
||||
let mut end = start;
|
||||
while rows.get(i + 1).is_some_and(|n| Some(*n) == end.checked_add(1)) {
|
||||
end += 1;
|
||||
i += 1;
|
||||
}
|
||||
i += 1;
|
||||
let (a, b) = (origin.row.checked_add(start)? + 1, origin.row.checked_add(end)? + 1);
|
||||
parts.push(if a == b { format!("{q}!${letters}${a}") } else { format!("{q}!${letters}${a}:${letters}${b}") });
|
||||
}
|
||||
match parts.len() {
|
||||
0 => None,
|
||||
1 => parts.pop(),
|
||||
_ => Some(format!("({})", parts.join(","))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Most category rows a PivotChart plots.
|
||||
const MAX_CHART_ROWS: usize = 10_000;
|
||||
|
||||
/// A PivotChart's series for a report written at `origin`: categories are the innermost row
|
||||
/// items, one series per item column (column items × value fields); totals are left out.
|
||||
pub fn chart_series(sheet: &str, origin: CellRef, out: &Output) -> Vec<Series> {
|
||||
let rows: Vec<u32> = out.chart_rows.iter().copied().take(MAX_CHART_ROWS).collect();
|
||||
let categories = out.label_col.and_then(|c| rows_ref(sheet, origin, c, &rows));
|
||||
out.chart_cols
|
||||
.iter()
|
||||
.filter_map(|(c, name)| {
|
||||
Some(Series {
|
||||
name: Some(format!("\"{}\"", name.replace('"', "\"\""))),
|
||||
categories: categories.clone(),
|
||||
values: rows_ref(sheet, origin, *c, &rows)?,
|
||||
bubble_sizes: None,
|
||||
color: None,
|
||||
secondary: false,
|
||||
kind: None,
|
||||
smooth: false,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Rebuilds the series of every PivotChart of a PivotTable after its report was written.
|
||||
fn rebind_charts(wb: &mut Workbook, sheet: &str, pivot: &str, origin: CellRef, out: &Output) {
|
||||
let linked = |ch: &Chart| ch.pivot.as_deref().is_some_and(|l| links_to(l, sheet, pivot));
|
||||
let hits: Vec<usize> = (0..wb.sheets.len()).filter(|i| wb.sheet(*i).is_some_and(|sh| sh.charts.iter().any(linked))).collect();
|
||||
if hits.is_empty() {
|
||||
return;
|
||||
}
|
||||
let series = chart_series(sheet, origin, out);
|
||||
for i in hits {
|
||||
let Some(sh) = wb.sheet_mut(i) else { continue };
|
||||
for ch in sh.charts.iter_mut().filter(|c| linked(c)) {
|
||||
ch.series = series.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Clears a pivot's last output area.
|
||||
pub fn clear_output(wb: &mut Workbook, sheet: usize, idx: usize) {
|
||||
let Some(shm) = wb.sheet_mut(sheet) else { return };
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
//! Calculated PivotTable fields: a formula over other fields (`=Sales*0.1`,
|
||||
//! `='Unit Price'*Units`, `=IF(Units>0,Sales/Units,0)`).
|
||||
//!
|
||||
//! As Microsoft documents for Excel, the formula operates on the *sums* of the fields it names
|
||||
//! for each report cell (items, subtotals and grand totals alike), not record by record: for
|
||||
//! `=Sales*1.2` a cell shows 1.2 × the sum of Sales of that cell's records. Fields are written
|
||||
//! bare (`Sales`) or in single quotes (`'Unit Price'`); cell references and defined names are not
|
||||
//! allowed. A calculated field may use other calculated fields; a cycle evaluates to `#REF!`.
|
||||
|
||||
use gridcraft_core::{CellError, CellRef, Value};
|
||||
use gridcraft_formula::Expr;
|
||||
use gridcraft_model::{PivotTable, Workbook};
|
||||
|
||||
/// Longest formula accepted.
|
||||
pub const MAX_FORMULA_LEN: usize = 8_192;
|
||||
/// Most calculated fields per PivotTable.
|
||||
pub const MAX_CALC_FIELDS: usize = 256;
|
||||
/// Deepest chain of calculated fields using calculated fields.
|
||||
const MAX_DEPTH: usize = 64;
|
||||
const PREFIX: &str = "__pcf";
|
||||
|
||||
fn placeholder(i: usize) -> String {
|
||||
format!("{PREFIX}{i}")
|
||||
}
|
||||
|
||||
/// A parsed formula: field names replaced by placeholder names `__pcf0`, `__pcf1`, … in the
|
||||
/// order of `refs`.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Compiled {
|
||||
pub body: Expr,
|
||||
/// Canonical names of the fields used.
|
||||
pub refs: Vec<String>,
|
||||
}
|
||||
|
||||
/// Parses a calculated-field formula. `field` maps a written name to the canonical name of a
|
||||
/// source or calculated field.
|
||||
pub fn compile(formula: &str, field: &dyn Fn(&str) -> Option<String>) -> Result<Compiled, String> {
|
||||
let text = formula.trim();
|
||||
let text = text.strip_prefix('=').unwrap_or(text).trim();
|
||||
if text.is_empty() {
|
||||
return Err("The formula is empty.".into());
|
||||
}
|
||||
if text.len() > MAX_FORMULA_LEN {
|
||||
return Err("The formula is too long.".into());
|
||||
}
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let at = |i: usize| chars.get(i).copied();
|
||||
let mut out = String::with_capacity(text.len() + 16);
|
||||
let mut refs: Vec<String> = Vec::new();
|
||||
let slot = |name: String, refs: &mut Vec<String>| -> String {
|
||||
let i = refs.iter().position(|r| r.eq_ignore_ascii_case(&name)).unwrap_or_else(|| {
|
||||
refs.push(name);
|
||||
refs.len() - 1
|
||||
});
|
||||
placeholder(i)
|
||||
};
|
||||
let mut i = 0;
|
||||
while let Some(c) = at(i) {
|
||||
match c {
|
||||
'"' => {
|
||||
out.push('"');
|
||||
i += 1;
|
||||
loop {
|
||||
match at(i) {
|
||||
None => return Err("The formula has an unterminated text value.".into()),
|
||||
Some('"') if at(i + 1) == Some('"') => {
|
||||
out.push_str("\"\"");
|
||||
i += 2;
|
||||
}
|
||||
Some('"') => {
|
||||
out.push('"');
|
||||
i += 1;
|
||||
break;
|
||||
}
|
||||
Some(x) => {
|
||||
out.push(x);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
'\'' => {
|
||||
let mut name = String::new();
|
||||
i += 1;
|
||||
loop {
|
||||
match at(i) {
|
||||
None => return Err("The formula has an unterminated field name.".into()),
|
||||
Some('\'') if at(i + 1) == Some('\'') => {
|
||||
name.push('\'');
|
||||
i += 2;
|
||||
}
|
||||
Some('\'') => {
|
||||
i += 1;
|
||||
break;
|
||||
}
|
||||
Some(x) => {
|
||||
name.push(x);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let canon = field(&name).ok_or_else(|| format!("There's no field named '{name}'."))?;
|
||||
out.push_str(&slot(canon, &mut refs));
|
||||
}
|
||||
c if c.is_ascii_digit() || (c == '.' && at(i + 1).is_some_and(|d| d.is_ascii_digit())) => {
|
||||
while at(i).is_some_and(|d| d.is_ascii_digit() || d == '.') {
|
||||
out.push(at(i).unwrap_or('0'));
|
||||
i += 1;
|
||||
}
|
||||
if matches!(at(i), Some('e' | 'E')) {
|
||||
let sign = usize::from(matches!(at(i + 1), Some('+' | '-')));
|
||||
if at(i + 1 + sign).is_some_and(|d| d.is_ascii_digit()) {
|
||||
for _ in 0..=sign {
|
||||
out.push(at(i).unwrap_or('0'));
|
||||
i += 1;
|
||||
}
|
||||
while at(i).is_some_and(|d| d.is_ascii_digit()) {
|
||||
out.push(at(i).unwrap_or('0'));
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
c if c.is_alphabetic() || c == '_' => {
|
||||
let start = i;
|
||||
while at(i).is_some_and(|d| d.is_alphanumeric() || d == '_' || d == '.') {
|
||||
i += 1;
|
||||
}
|
||||
let word: String = chars.get(start..i).map(|w| w.iter().collect()).unwrap_or_default();
|
||||
let mut j = i;
|
||||
while at(j).is_some_and(char::is_whitespace) {
|
||||
j += 1;
|
||||
}
|
||||
let is_call = at(j) == Some('(');
|
||||
match field(&word) {
|
||||
Some(canon) if !is_call => out.push_str(&slot(canon, &mut refs)),
|
||||
_ => out.push_str(&word),
|
||||
}
|
||||
}
|
||||
c => {
|
||||
out.push(c);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let body = gridcraft_formula::parse(&out).map_err(|_| "The formula contains an error.".to_string())?;
|
||||
let mut problem: Option<String> = None;
|
||||
body.walk(&mut |e| {
|
||||
if problem.is_some() {
|
||||
return;
|
||||
}
|
||||
match e {
|
||||
Expr::Name(n) if n.starts_with(PREFIX) => {}
|
||||
Expr::Name(n) => problem = Some(format!("There's no field named '{n}'.")),
|
||||
Expr::Ref(_) | Expr::Struct(_) => problem = Some("A calculated field can't refer to cells or tables; use field names.".into()),
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
match problem {
|
||||
Some(p) => Err(p),
|
||||
None => Ok(Compiled { body, refs }),
|
||||
}
|
||||
}
|
||||
|
||||
fn literal(v: &Value) -> Expr {
|
||||
match v {
|
||||
Value::Number(n) => Expr::Number(*n),
|
||||
Value::Error(e) => Expr::Error(*e),
|
||||
Value::Text(t) => Expr::Text(t.clone()),
|
||||
Value::Bool(b) => Expr::Bool(*b),
|
||||
Value::Empty => Expr::Number(0.0),
|
||||
Value::Array(_) => literal(&v.scalar()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluates a compiled formula with the given field values (in `refs` order).
|
||||
pub fn eval(wb: &Workbook, c: &Compiled, args: &[Value]) -> Value {
|
||||
let expr = if c.refs.is_empty() {
|
||||
c.body.clone()
|
||||
} else {
|
||||
let mut call = Vec::with_capacity(c.refs.len() * 2 + 1);
|
||||
for i in 0..c.refs.len() {
|
||||
call.push(Expr::Name(placeholder(i)));
|
||||
call.push(literal(args.get(i).unwrap_or(&Value::Empty)));
|
||||
}
|
||||
call.push(c.body.clone());
|
||||
Expr::Call("LET".into(), call)
|
||||
};
|
||||
match gridcraft_calc::recalc::evaluate_expr(wb, 0, CellRef::default(), &expr).scalar() {
|
||||
Value::Empty => Value::Number(0.0),
|
||||
v => v,
|
||||
}
|
||||
}
|
||||
|
||||
/// A PivotTable's calculated fields, compiled against its source headers.
|
||||
pub struct CalcSet {
|
||||
names: Vec<String>,
|
||||
compiled: Vec<Result<Compiled, String>>,
|
||||
}
|
||||
|
||||
impl CalcSet {
|
||||
pub fn new(pt: &PivotTable, headers: &[String]) -> CalcSet {
|
||||
let names: Vec<String> = pt.calculated_fields.iter().take(MAX_CALC_FIELDS).map(|c| c.name.clone()).collect();
|
||||
let lookup = |n: &str| -> Option<String> {
|
||||
let n = n.trim();
|
||||
headers.iter().chain(names.iter()).find(|h| h.trim().eq_ignore_ascii_case(n)).cloned()
|
||||
};
|
||||
let compiled = pt.calculated_fields.iter().take(MAX_CALC_FIELDS).map(|c| compile(&c.formula, &lookup)).collect();
|
||||
CalcSet { names, compiled }
|
||||
}
|
||||
|
||||
pub fn index(&self, name: &str) -> Option<usize> {
|
||||
self.names.iter().position(|n| n.eq_ignore_ascii_case(name.trim()))
|
||||
}
|
||||
|
||||
/// The compile error of a calculated field, if any.
|
||||
pub fn error(&self, i: usize) -> Option<&str> {
|
||||
self.compiled.get(i).and_then(|c| c.as_ref().err()).map(String::as_str)
|
||||
}
|
||||
|
||||
/// Source fields a calculated field needs, directly or through other calculated fields.
|
||||
pub fn source_refs(&self, i: usize) -> Vec<String> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
let mut seen = vec![false; self.names.len()];
|
||||
let mut stack = vec![i];
|
||||
while let Some(j) = stack.pop() {
|
||||
if !seen.get(j).copied().unwrap_or(true) {
|
||||
if let Some(s) = seen.get_mut(j) {
|
||||
*s = true;
|
||||
}
|
||||
if let Some(Ok(c)) = self.compiled.get(j) {
|
||||
for r in &c.refs {
|
||||
match self.index(r) {
|
||||
Some(k) => stack.push(k),
|
||||
None => {
|
||||
if !out.iter().any(|o| o.eq_ignore_ascii_case(r)) {
|
||||
out.push(r.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether a calculated field reaches itself through the fields it uses.
|
||||
pub fn is_cyclic(&self, i: usize) -> bool {
|
||||
let mut seen = vec![false; self.names.len()];
|
||||
let mut stack: Vec<usize> = match self.compiled.get(i) {
|
||||
Some(Ok(c)) => c.refs.iter().filter_map(|r| self.index(r)).collect(),
|
||||
_ => vec![],
|
||||
};
|
||||
while let Some(j) = stack.pop() {
|
||||
if j == i {
|
||||
return true;
|
||||
}
|
||||
if seen.get(j).copied().unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
if let Some(s) = seen.get_mut(j) {
|
||||
*s = true;
|
||||
}
|
||||
if let Some(Ok(c)) = self.compiled.get(j) {
|
||||
stack.extend(c.refs.iter().filter_map(|r| self.index(r)));
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// The value of calculated field `i` for one report cell; `sum` gives a source field's sum
|
||||
/// over that cell's records.
|
||||
pub fn value(&self, wb: &Workbook, i: usize, sum: &dyn Fn(&str) -> Value) -> Value {
|
||||
let mut memo: Vec<Option<Value>> = vec![None; self.names.len()];
|
||||
let mut active = vec![false; self.names.len()];
|
||||
self.value_in(wb, i, sum, &mut memo, &mut active, 0)
|
||||
}
|
||||
|
||||
fn value_in(&self, wb: &Workbook, i: usize, sum: &dyn Fn(&str) -> Value, memo: &mut [Option<Value>], active: &mut [bool], depth: usize) -> Value {
|
||||
if let Some(Some(v)) = memo.get(i) {
|
||||
return v.clone();
|
||||
}
|
||||
if depth > MAX_DEPTH || active.get(i).copied().unwrap_or(true) {
|
||||
return Value::Error(CellError::Ref);
|
||||
}
|
||||
let Some(Ok(c)) = self.compiled.get(i) else { return Value::Error(CellError::Name) };
|
||||
if let Some(a) = active.get_mut(i) {
|
||||
*a = true;
|
||||
}
|
||||
let args: Vec<Value> = c
|
||||
.refs
|
||||
.iter()
|
||||
.map(|r| match self.index(r) {
|
||||
Some(j) => self.value_in(wb, j, sum, memo, active, depth + 1),
|
||||
None => sum(r),
|
||||
})
|
||||
.collect();
|
||||
let v = eval(wb, c, &args);
|
||||
if let Some(a) = active.get_mut(i) {
|
||||
*a = false;
|
||||
}
|
||||
if let Some(m) = memo.get_mut(i) {
|
||||
*m = Some(v.clone());
|
||||
}
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn fields(n: &str) -> Option<String> {
|
||||
["Sales", "Units", "Unit Price", "Q1"].iter().find(|f| f.eq_ignore_ascii_case(n)).map(|f| f.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compiles_field_names() {
|
||||
let c = compile("= Sales*0.1 + 'Unit Price'/units - Sales", &fields).unwrap();
|
||||
assert_eq!(c.refs, vec!["Sales", "Unit Price", "Units"]);
|
||||
// A field named like a cell reference is still a field; functions stay functions.
|
||||
let c = compile("=ROUND(Q1*1.5E2,0)", &fields).unwrap();
|
||||
assert_eq!(c.refs, vec!["Q1"]);
|
||||
let wb = Workbook::new();
|
||||
assert_eq!(eval(&wb, &c, &[Value::Number(2.0)]), Value::Number(300.0));
|
||||
assert!(compile("=Sales*Bogus", &fields).unwrap_err().contains("Bogus"));
|
||||
assert!(compile("=A1*2", &fields).is_err());
|
||||
assert!(compile("='Nope'*2", &fields).is_err());
|
||||
assert!(compile("=Sales*(", &fields).is_err());
|
||||
assert!(compile("=", &fields).is_err());
|
||||
assert!(compile(&format!("={}", "1+".repeat(MAX_FORMULA_LEN)), &fields).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deep_and_cyclic_chains_are_bounded() {
|
||||
// 300 calculated fields, each using the previous one, and a two-field cycle.
|
||||
let mut pt = PivotTable::default();
|
||||
pt.calculated_fields.push(gridcraft_model::PivotCalcField { name: "C0".into(), formula: "=Sales+1".into() });
|
||||
for i in 1..300 {
|
||||
pt.calculated_fields.push(gridcraft_model::PivotCalcField { name: format!("C{i}"), formula: format!("=C{}+1", i - 1) });
|
||||
}
|
||||
pt.calculated_fields.push(gridcraft_model::PivotCalcField { name: "P".into(), formula: "=Q".into() });
|
||||
pt.calculated_fields.push(gridcraft_model::PivotCalcField { name: "Q".into(), formula: "=P*2".into() });
|
||||
let set = CalcSet::new(&pt, &["Sales".to_string()]);
|
||||
let wb = Workbook::new();
|
||||
let sum = |_: &str| Value::Number(10.0);
|
||||
assert_eq!(set.value(&wb, 0, &sum), Value::Number(11.0));
|
||||
assert_eq!(set.value(&wb, 10, &sum), Value::Number(21.0));
|
||||
// Past the depth bound (the list itself is capped at MAX_CALC_FIELDS).
|
||||
assert_eq!(set.value(&wb, 200, &sum), Value::Error(CellError::Ref));
|
||||
assert_eq!(set.index("P"), None, "P is past the cap of {MAX_CALC_FIELDS} fields");
|
||||
// A small cycle within the cap.
|
||||
let mut small = PivotTable::default();
|
||||
small.calculated_fields.push(gridcraft_model::PivotCalcField { name: "P".into(), formula: "=Q".into() });
|
||||
small.calculated_fields.push(gridcraft_model::PivotCalcField { name: "Q".into(), formula: "=P*2".into() });
|
||||
let set = CalcSet::new(&small, &["Sales".to_string()]);
|
||||
assert!(set.is_cyclic(0));
|
||||
assert_eq!(set.value(&wb, 0, &sum), Value::Error(CellError::Ref));
|
||||
assert_eq!(set.source_refs(0), Vec::<String>::new());
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,110 @@ fn entering_empty_cells_preserves_whole_sheet_formatting() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasted_currency_text_keeps_the_number_from_the_clipboard_html() {
|
||||
// #257: "Rp10,000.00" copied from Excel for the web / LibreOffice Calc pasted as text. Their
|
||||
// clipboard HTML records the number (x:num, sdval); paste keeps it and shows the same text.
|
||||
let mut s = s();
|
||||
let text = "Rp10,000.00\tRp2,500.50\tLabel\n";
|
||||
let html =
|
||||
r#"<table><tr><td x:num="10000">Rp10,000.00</td><td sdval="2500.5" sdnum="1033;0;x"><font>Rp2,500.50</font></td><td>Label</td></tr></table>"#;
|
||||
s.execute("edit.paste", json!({"at": "A1", "text": text, "html": html})).unwrap();
|
||||
assert_eq!(v(&s, "A1"), Value::number(10000.0));
|
||||
assert_eq!(v(&s, "B1"), Value::number(2500.5));
|
||||
assert_eq!(v(&s, "C1"), Value::text("Label"));
|
||||
let d = s.doc().unwrap();
|
||||
let sh = d.wb.active().unwrap();
|
||||
assert_eq!(crate::display::cell_text(&d.wb, sh, CellRef::parse("A1").unwrap()), "Rp10,000.00");
|
||||
assert_eq!(crate::display::cell_text(&d.wb, sh, CellRef::parse("B1").unwrap()), "Rp2,500.50");
|
||||
|
||||
// HTML that doesn't show the same text (left over from another copy) changes nothing; without
|
||||
// HTML the text stays text, as typing it would in an en-US locale.
|
||||
let stale = r#"<table><tr><td x:num="7">Rp7.00</td></tr></table>"#;
|
||||
s.execute("edit.paste", json!({"at": "A3", "text": "Rp10,000.00", "html": stale})).unwrap();
|
||||
s.execute("edit.paste", json!({"at": "A4", "text": "Rp10,000.00"})).unwrap();
|
||||
assert_eq!(v(&s, "A3"), Value::text("Rp10,000.00"));
|
||||
assert_eq!(v(&s, "A4"), Value::text("Rp10,000.00"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_a_name_renames_it_in_formulas() {
|
||||
// Name Manager › Edit…: `oldName` edits the name in place; formulas that use it follow.
|
||||
let mut s = s();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "21"})).unwrap();
|
||||
s.execute("formulas.defineName", json!({"name": "Price", "refersTo": "=Sheet1!$A$1"})).unwrap();
|
||||
s.execute("formulas.defineName", json!({"name": "Other", "refersTo": "=1"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "B1", "input": "=Price*2"})).unwrap();
|
||||
let err = s.execute("formulas.defineName", json!({"name": "Other", "refersTo": "=Sheet1!$A$1", "oldName": "Price"}));
|
||||
assert!(err.is_err(), "renaming onto another existing name is refused");
|
||||
s.execute("formulas.defineName", json!({"name": "Cost", "refersTo": "=Sheet1!$A$1", "comment": "unit", "oldName": "Price"})).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
let names: Vec<&str> = d.wb.names.iter().map(|n| n.name.as_str()).collect();
|
||||
assert_eq!(names, ["Other", "Cost"]);
|
||||
let b1 = d.wb.active().unwrap().cell(CellRef::parse("B1").unwrap()).and_then(|c| c.formula.clone()).unwrap();
|
||||
assert_eq!(b1.text, "Cost*2");
|
||||
assert_eq!(v(&s, "B1"), Value::number(42.0));
|
||||
assert!(s.execute("formulas.defineName", json!({"name": "X", "oldName": "Missing"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chart_recommendations_follow_the_data_shape() {
|
||||
// #215: Recommended Charts lists chart types for the data; programmatic insert stays dialog-free.
|
||||
let mut s = s();
|
||||
let kinds = |s: &mut Session, range: &str| -> Vec<String> {
|
||||
let r = s.execute("insert.chartRecommendations", json!({"range": range})).unwrap();
|
||||
r.as_array().unwrap().iter().map(|x| format!("{}{}", x["type"].as_str().unwrap(), x["subtype"].as_str().unwrap())).collect()
|
||||
};
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["Fruit", "Sales"], ["Apples", 5], ["Pears", 3], ["Plums", 2]]})).unwrap();
|
||||
assert_eq!(kinds(&mut s, "A1:B4"), ["column", "bar", "pie", "doughnut", "linemarkers"]);
|
||||
s.execute("range.setValues", json!({"range": "D1", "values": [[1, 2], [2, 4.5], [3, 5.5], [4, 9]]})).unwrap();
|
||||
assert_eq!(kinds(&mut s, "D1:E4").first().map(String::as_str), Some("scatter"), "two numeric columns: scatter first");
|
||||
s.execute("range.setValues", json!({"range": "G1", "values": [["Q", "North", "South"], ["Q1", 1, -2], ["Q2", 3, 4]]})).unwrap();
|
||||
let multi = kinds(&mut s, "G1:I3");
|
||||
assert_eq!(multi.first().map(String::as_str), Some("column"));
|
||||
assert!(!multi.iter().any(|k| k == "pie"), "several series: no pie");
|
||||
assert!(s.execute("insert.chartRecommendations", json!({"range": "K1:K3"})).is_err(), "no numbers, no chart");
|
||||
|
||||
// Without a type the command inserts the first recommendation; with one, that type.
|
||||
s.execute("insert.recommendedCharts", json!({"range": "D1:E4"})).unwrap();
|
||||
s.execute("insert.recommendedCharts", json!({"range": "A1:B4", "type": "pie"})).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
let kinds: Vec<_> = d.wb.active().unwrap().charts.iter().map(|c| c.kind).collect();
|
||||
assert_eq!(kinds, [gridcraft_model::ChartKind::Scatter, gridcraft_model::ChartKind::Pie]);
|
||||
assert!(s.ui_requests.is_empty(), "programmatic calls never open the dialog");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chart_category_axis_from_blank_corner_and_select_data() {
|
||||
// #287: numeric X values must be the category axis, not a series of their own.
|
||||
let mut s = s();
|
||||
let series = |s: &Session| -> Vec<(Option<String>, String)> {
|
||||
let d = s.doc().unwrap();
|
||||
d.wb.active().unwrap().charts.last().unwrap().series.iter().map(|x| (x.categories.clone(), x.values.clone())).collect()
|
||||
};
|
||||
// A blank top-left cell marks the first column as category labels, numbers or not.
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[null, "Sales"], [2020, 5], [2021, 7], [2022, 9]]})).unwrap();
|
||||
s.execute("insert.chart", json!({"range": "A1:B4", "type": "line"})).unwrap();
|
||||
assert_eq!(series(&s), [(Some("Sheet1!$A$2:$A$4".to_string()), "Sheet1!$B$2:$B$4".to_string())]);
|
||||
|
||||
// With a "Year" header the year column is a series, as before; Select Data fixes it:
|
||||
// Horizontal (Category) Axis Labels and Remove on the year series.
|
||||
s.execute("range.setValues", json!({"range": "D1", "values": [["Year", "Sales"], [2020, 5], [2021, 7], [2022, 9]]})).unwrap();
|
||||
s.execute("insert.chart", json!({"range": "D1:E4", "type": "line"})).unwrap();
|
||||
assert_eq!(series(&s).len(), 2);
|
||||
assert!(s.execute("chart.selectData", json!({})).is_err(), "nothing to change");
|
||||
assert!(s.execute("chart.selectData", json!({"categories": "not a range"})).is_err());
|
||||
s.execute("chart.selectData", json!({"categories": "=D2:D4", "removeSeries": [0, 7]})).unwrap();
|
||||
assert_eq!(series(&s), [(Some("Sheet1!$D$2:$D$4".to_string()), "Sheet1!$E$2:$E$4".to_string())]);
|
||||
let d = s.doc().unwrap();
|
||||
let sh = d.wb.active().unwrap();
|
||||
let data = gridcraft_chart::resolve(&d.wb, d.wb.active_sheet, sh.charts.last().unwrap());
|
||||
assert_eq!(data.categories, ["2020", "2021", "2022"]);
|
||||
// The last series can't be removed; "" clears the labels.
|
||||
s.execute("chart.selectData", json!({"categories": "", "removeSeries": [0]})).unwrap();
|
||||
assert_eq!(series(&s), [(None, "Sheet1!$E$2:$E$4".to_string())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entry_and_text_paste_use_inherited_formats_and_cell_overrides() {
|
||||
let mut s = s();
|
||||
@@ -417,6 +521,71 @@ fn insert_delete_rows_adjust() {
|
||||
assert_eq!(v(&s, "A1"), Value::Number(10.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_copied_cells_inserts_rows_columns_and_blocks() {
|
||||
// Issue #21: Insert Copied Cells.
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2], [3], [4], [5]]})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "B2", "input": "=A2*10"})).unwrap();
|
||||
s.execute("edit.copy", json!({"range": "2:2"})).unwrap();
|
||||
s.execute("selection.set", json!({"range": "C4"})).unwrap();
|
||||
s.execute("home.insertCopiedCells", json!({})).unwrap();
|
||||
assert_eq!(v(&s, "A4"), Value::Number(2.0));
|
||||
assert_eq!(v(&s, "B4"), Value::Number(20.0), "the formula follows its new row");
|
||||
assert_eq!(v(&s, "A5"), Value::Number(4.0));
|
||||
assert_eq!(v(&s, "A6"), Value::Number(5.0));
|
||||
assert_eq!(s.doc().unwrap().selection.current(), gridcraft_core::RangeRef::rows(3, 3));
|
||||
// One undo step puts everything back.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(v(&s, "A4"), Value::Number(4.0));
|
||||
assert_eq!(v(&s, "A6"), Value::Empty);
|
||||
// Whole columns.
|
||||
s.execute("edit.copy", json!({"range": "A:A"})).unwrap();
|
||||
s.execute("home.insertCopiedCells", json!({"at": "A1"})).unwrap();
|
||||
assert_eq!(v(&s, "A3"), Value::Number(3.0));
|
||||
assert_eq!(v(&s, "B3"), Value::Number(3.0));
|
||||
assert_eq!(v(&s, "C2"), Value::Number(20.0), "the old column B moved right");
|
||||
// A block shifts cells right.
|
||||
s.execute("cell.set", json!({"cell": "F1", "input": "x"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "G1", "input": "y"})).unwrap();
|
||||
s.execute("edit.copy", json!({"range": "F1"})).unwrap();
|
||||
s.execute("home.insertCopiedCells", json!({"at": "G1", "shift": "right"})).unwrap();
|
||||
assert_eq!(v(&s, "G1"), Value::from("x"));
|
||||
assert_eq!(v(&s, "H1"), Value::from("y"));
|
||||
assert!(s.clipboard.is_none(), "inserting ends copy mode");
|
||||
s.execute("edit.copy", json!({"range": "F1"})).unwrap();
|
||||
assert!(s.execute("home.insertCopiedCells", json!({"shift": "up"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_cut_cells_moves_whole_rows() {
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2], [3], [4], [5]]})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "C1", "input": "=A5"})).unwrap();
|
||||
// Up: row 5 goes above row 2.
|
||||
s.execute("edit.cut", json!({"range": "5:5"})).unwrap();
|
||||
s.execute("home.insertCopiedCells", json!({"at": "A2"})).unwrap();
|
||||
let col: Vec<Value> = (1..=6).map(|r| v(&s, &format!("A{r}"))).collect();
|
||||
assert_eq!(col, [1.0, 5.0, 2.0, 3.0, 4.0].map(Value::Number).into_iter().chain([Value::Empty]).collect::<Vec<_>>());
|
||||
assert_eq!(v(&s, "C1"), Value::Number(5.0), "references follow the moved cells");
|
||||
assert!(s.clipboard.is_none());
|
||||
// Down: row 1 goes above row 4.
|
||||
s.execute("edit.cut", json!({"range": "1:1"})).unwrap();
|
||||
s.execute("home.insertCopiedCells", json!({"at": "A4"})).unwrap();
|
||||
let col: Vec<Value> = (1..=5).map(|r| v(&s, &format!("A{r}"))).collect();
|
||||
assert_eq!(col, [5.0, 2.0, 1.0, 3.0, 4.0].map(Value::Number).to_vec());
|
||||
assert_eq!(s.doc().unwrap().selection.current(), gridcraft_core::RangeRef::rows(2, 2));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(v(&s, "A1"), Value::Number(1.0));
|
||||
// Inside itself, and a cut block, are refused without changing anything.
|
||||
s.execute("edit.cut", json!({"range": "2:4"})).unwrap();
|
||||
assert!(s.execute("home.insertCopiedCells", json!({"at": "A3"})).is_err());
|
||||
s.execute("edit.cut", json!({"range": "A1:A2"})).unwrap();
|
||||
assert!(s.execute("home.insertCopiedCells", json!({"at": "B5"})).is_err());
|
||||
assert_eq!(v(&s, "A1"), Value::Number(1.0));
|
||||
assert_eq!(v(&s, "B5"), Value::Empty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inserting_or_deleting_cells_cancels_copy_mode() {
|
||||
let mut s = s();
|
||||
@@ -452,6 +621,39 @@ fn fill_series() {
|
||||
assert_eq!(v(&s, "C2"), Value::from("Item 10"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_drag_fill_swaps_copy_and_series() {
|
||||
// Issue #264: Ctrl+drag fills a lone number as a series, and copies what a plain drag extends.
|
||||
let mut s = s();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "7"})).unwrap();
|
||||
s.execute("edit.autoFill", json!({"source": "A1", "target": "A1:A4"})).unwrap();
|
||||
assert_eq!(v(&s, "A4"), Value::Number(7.0), "a plain drag copies a lone number");
|
||||
s.execute("edit.autoFill", json!({"source": "A1", "target": "A1:A4", "mode": "alternate"})).unwrap();
|
||||
assert_eq!(v(&s, "A2"), Value::Number(8.0));
|
||||
assert_eq!(v(&s, "A4"), Value::Number(10.0));
|
||||
// Upward too.
|
||||
s.execute("cell.set", json!({"cell": "B5", "input": "7"})).unwrap();
|
||||
s.execute("edit.autoFill", json!({"source": "B5", "target": "B3:B5", "mode": "alternate"})).unwrap();
|
||||
assert_eq!(v(&s, "B3"), Value::Number(5.0));
|
||||
// A two-number series, a list entry and text with a number are copied.
|
||||
s.execute("range.setValues", json!({"range": "C1", "values": [[1], [3]]})).unwrap();
|
||||
s.execute("edit.autoFill", json!({"source": "C1:C2", "target": "C1:C4", "mode": "alternate"})).unwrap();
|
||||
assert_eq!(v(&s, "C3"), Value::Number(1.0));
|
||||
assert_eq!(v(&s, "C4"), Value::Number(3.0));
|
||||
s.execute("cell.set", json!({"cell": "D1", "input": "Mon"})).unwrap();
|
||||
s.execute("edit.autoFill", json!({"source": "D1", "target": "D1:D3", "mode": "alternate"})).unwrap();
|
||||
assert_eq!(v(&s, "D3"), Value::from("Mon"));
|
||||
s.execute("cell.set", json!({"cell": "E1", "input": "Item 9"})).unwrap();
|
||||
s.execute("edit.autoFill", json!({"source": "E1", "target": "E1:E2", "mode": "alternate"})).unwrap();
|
||||
assert_eq!(v(&s, "E2"), Value::from("Item 9"));
|
||||
// Plain text copies either way; an empty source clears.
|
||||
s.execute("cell.set", json!({"cell": "F1", "input": "abc"})).unwrap();
|
||||
s.execute("edit.autoFill", json!({"source": "F1", "target": "F1:F2", "mode": "alternate"})).unwrap();
|
||||
assert_eq!(v(&s, "F2"), Value::from("abc"));
|
||||
s.execute("edit.autoFill", json!({"source": "G1", "target": "G1:G3", "mode": "alternate"})).unwrap();
|
||||
assert_eq!(v(&s, "G3"), Value::Empty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_delete_cells_adjust() {
|
||||
let formula = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap()["formula"].clone();
|
||||
@@ -512,6 +714,63 @@ fn sort_and_filter() {
|
||||
assert_eq!(r["hiddenRows"], 1);
|
||||
}
|
||||
|
||||
/// Turning the filter on with only the header row selected filters the data below it (#201).
|
||||
#[test]
|
||||
fn filter_on_a_selected_header_row_covers_the_data_below() {
|
||||
let mut s = s();
|
||||
s.execute(
|
||||
"range.setValues",
|
||||
json!({"range": "A1", "values": [["Name", "City"], ["Ana", "SP"], ["Bia", "RJ"], ["Caio", "SP"], ["Duda", "BH"], ["Eva", "RJ"]]}),
|
||||
)
|
||||
.unwrap();
|
||||
s.execute("selection.set", json!({"range": "A1:B1"})).unwrap();
|
||||
let r = s.execute("data.filter", json!({})).unwrap();
|
||||
assert_eq!(r["range"], "A1:B6");
|
||||
let r = s.execute("data.filterBy", json!({"column": "B", "values": ["SP"]})).unwrap();
|
||||
assert_eq!(r["hiddenRows"], 3);
|
||||
// An explicit range is taken as given.
|
||||
s.execute("data.filter", json!({})).unwrap();
|
||||
let r = s.execute("data.filter", json!({"range": "A1:B1"})).unwrap();
|
||||
assert_eq!(r["range"], "A1:B1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protection_passwords_stay_out_of_the_journal_and_recorded_scripts() {
|
||||
const PW: &str = "synthetic-pw-7319";
|
||||
let mut s = s();
|
||||
s.execute("automate.recordActions", json!({})).unwrap();
|
||||
s.execute("review.protectSheet", json!({"password": PW, "formatCells": true})).unwrap();
|
||||
// The password still protects the sheet; the refused attempt isn't journaled.
|
||||
assert!(s.execute("review.unprotectSheet", json!({})).is_err());
|
||||
s.execute("review.unprotectSheet", json!({"password": PW})).unwrap();
|
||||
s.execute("automate.recordActions", json!({"stop": true, "name": "Lock"})).unwrap();
|
||||
|
||||
let journal = serde_json::to_string(&s.journal).unwrap();
|
||||
assert!(!journal.contains(PW), "{journal}");
|
||||
let params = |s: &Session, id: &str| s.journal.iter().find(|(c, _)| c == id).map(|(_, p)| p.clone());
|
||||
assert_eq!(params(&s, "review.protectSheet"), Some(json!({"formatCells": true})));
|
||||
assert_eq!(params(&s, "review.unprotectSheet"), Some(json!({})));
|
||||
|
||||
// The recorded script is stored in the workbook, so it is saved with the file.
|
||||
let bytes = crate::io::save_bytes(&s.doc().unwrap().wb, "book.xlsx").unwrap();
|
||||
let (reopened, _) = crate::io::open_bytes("book.xlsx", &bytes).unwrap();
|
||||
let script = reopened.names.iter().find(|n| n.name == "_sc_scripts").map(|n| n.formula.clone()).unwrap();
|
||||
assert!(script.contains("review.protectSheet"), "{script}");
|
||||
assert!(!script.contains(PW), "{script}");
|
||||
|
||||
// The journal still replays: protection without a password, then unprotection.
|
||||
let mut r = self::s();
|
||||
for (id, p) in s.journal.iter().filter(|(id, _)| !id.starts_with("automate.")) {
|
||||
r.execute(id, p.clone()).unwrap_or_else(|e| panic!("{id}: {e}"));
|
||||
let prot = r.doc().unwrap().wb.active().unwrap().protection.clone();
|
||||
if id == "review.protectSheet" {
|
||||
assert!(prot.as_ref().is_some_and(|p| p.format_cells && p.password_hash.is_none()), "{prot:?}");
|
||||
}
|
||||
}
|
||||
assert!(r.doc().unwrap().wb.active().unwrap().protection.is_none());
|
||||
s.execute("automate.runScript", json!({"name": "Lock"})).unwrap();
|
||||
}
|
||||
|
||||
/// A sheet with locked data in A1:B3, unlocked cells in D1:D3 and row 10, then protected.
|
||||
fn protected(extra: serde_json::Value) -> Session {
|
||||
let mut s = s();
|
||||
@@ -606,6 +865,27 @@ fn hidden_formulas_stay_hidden_on_protected_sheets() {
|
||||
assert_eq!(r["results"][0]["value"], "7");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn case_sensitive_sort_puts_lowercase_first() {
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["b"], ["B"], ["Ab"], ["a"], ["aB"], ["A"]]})).unwrap();
|
||||
s.execute("data.sort", json!({"range": "A1:A6", "header": false, "matchCase": true, "keys": [{"column": "A"}]})).unwrap();
|
||||
let col: Vec<Value> = (1..=6).map(|r| v(&s, &format!("A{r}"))).collect();
|
||||
assert_eq!(col, ["a", "A", "aB", "Ab", "b", "B"].map(Value::from));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_duplicates_compares_shown_text() {
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["Date", "Price"], ["2006-03-08", 1.01], ["2006-03-08", 1.02]]})).unwrap();
|
||||
s.execute("home.numberFormat", json!({"range": "A3", "code": "mmm d, yyyy"})).unwrap();
|
||||
s.execute("home.numberFormat", json!({"range": "B2:B3", "code": "0.0"})).unwrap();
|
||||
let r = s.execute("data.removeDuplicates", json!({"range": "A1:A3", "header": true})).unwrap();
|
||||
assert_eq!(r["removed"], 0);
|
||||
let r = s.execute("data.removeDuplicates", json!({"range": "B1:B3", "header": true})).unwrap();
|
||||
assert_eq!(r["removed"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formatting_and_styles() {
|
||||
let mut s = s();
|
||||
@@ -1036,6 +1316,74 @@ fn every_command_survives_empty_params() {
|
||||
}
|
||||
}
|
||||
|
||||
fn fmt_of(s: &Session, a: &str) -> String {
|
||||
let wb = &s.doc().unwrap().wb;
|
||||
let sh = wb.active().unwrap();
|
||||
wb.styles.get(sh.style_id(CellRef::parse(a).unwrap())).num_fmt.as_str().to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkboxes_keep_values_toggle_and_delete_in_two_passes() {
|
||||
// Issue #196.
|
||||
let mut s = s();
|
||||
s.execute("cell.set", json!({"cell": "B1", "input": "hi"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "C1", "input": "TRUE"})).unwrap();
|
||||
s.execute("insert.checkbox", json!({"range": "A1:B2"})).unwrap();
|
||||
assert_eq!(v(&s, "A1"), Value::Bool(false), "an empty cell starts unchecked");
|
||||
assert_eq!(v(&s, "B1"), Value::from("hi"), "other values stay");
|
||||
s.execute("cell.toggleCheckbox", json!({"cell": "A1"})).unwrap();
|
||||
assert_eq!(v(&s, "A1"), Value::Bool(true));
|
||||
// Space over a selection toggles only checkbox cells; an emptied checkbox counts as FALSE.
|
||||
s.execute("cell.set", json!({"cell": "A2", "input": ""})).unwrap();
|
||||
s.execute("selection.set", json!({"range": "A2:C2"})).unwrap();
|
||||
s.execute("cell.toggleCheckbox", json!({})).unwrap();
|
||||
assert_eq!(v(&s, "A2"), Value::Bool(true));
|
||||
s.execute("selection.set", json!({"range": "C1"})).unwrap();
|
||||
s.execute("cell.toggleCheckbox", json!({})).unwrap();
|
||||
assert_eq!(v(&s, "C1"), Value::Bool(true), "a plain TRUE is not a checkbox");
|
||||
// Delete: checked boxes are unchecked first, then the boxes go.
|
||||
s.execute("edit.clearContents", json!({"range": "A1:A2"})).unwrap();
|
||||
assert_eq!((v(&s, "A1"), v(&s, "A2")), (Value::Bool(false), Value::Bool(false)));
|
||||
assert_eq!(fmt_of(&s, "A1"), "checkbox");
|
||||
s.execute("edit.clearContents", json!({"range": "A1:A2"})).unwrap();
|
||||
assert_eq!(v(&s, "A1"), Value::Empty);
|
||||
assert_ne!(fmt_of(&s, "A1"), "checkbox");
|
||||
// Clear Formats removes the box and keeps the value (B2 was checked by the selection toggle).
|
||||
s.execute("edit.clearFormats", json!({"range": "B2"})).unwrap();
|
||||
assert_eq!(v(&s, "B2"), Value::Bool(true));
|
||||
assert_ne!(fmt_of(&s, "B2"), "checkbox");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkboxes_survive_xlsx_as_cell_controls() {
|
||||
let mut s = s();
|
||||
s.execute("insert.checkbox", json!({"range": "A1:A2"})).unwrap();
|
||||
s.execute("cell.toggleCheckbox", json!({"cell": "A1"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "B1", "input": "0.5"})).unwrap();
|
||||
reopen(&mut s);
|
||||
assert_eq!(fmt_of(&s, "A1"), "checkbox");
|
||||
assert_eq!(fmt_of(&s, "A2"), "checkbox");
|
||||
assert_eq!(fmt_of(&s, "B1"), "General");
|
||||
assert_eq!(v(&s, "A1"), Value::Bool(true));
|
||||
assert_eq!(v(&s, "A2"), Value::Bool(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_options_reports_settings_and_asks_for_the_options_page() {
|
||||
let mut s = Session::new();
|
||||
// No workbook: nothing to report, but still no error.
|
||||
assert_eq!(s.execute("file.options", json!({})).unwrap(), json!({"calculation": null}));
|
||||
s.take_ui_requests();
|
||||
s.new_workbook();
|
||||
s.execute("formulas.calculationOptions", json!({"mode": "manual", "iterative": true})).unwrap();
|
||||
let before = s.active().unwrap().wb.clone();
|
||||
let r = s.execute("file.options", json!({})).unwrap();
|
||||
assert_eq!(r["calculation"]["mode"], "manual");
|
||||
assert_eq!(r["calculation"]["iterative"], true);
|
||||
assert!(matches!(s.take_ui_requests().as_slice(), [crate::UiRequest::Dialog(name, _)] if name == "options"));
|
||||
assert_eq!(s.active().unwrap().wb, before, "Options never changes the workbook");
|
||||
}
|
||||
|
||||
fn reopen(s: &mut Session) {
|
||||
let r = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap();
|
||||
let b64 = r["base64"].as_str().unwrap().to_string();
|
||||
@@ -1667,3 +2015,117 @@ fn vertical_alignment_commands_and_roundtrip() {
|
||||
assert_eq!(reopen_style("A2").align.v, gridcraft_model::VAlign::Center);
|
||||
assert_eq!(reopen_style("A3").align.v, gridcraft_model::VAlign::Bottom);
|
||||
}
|
||||
|
||||
/// Icon-set thresholds as read from a file (one per icon, the first the lower bound) decide the
|
||||
/// icon; a rule without thresholds uses Excel's defaults (percent 33 and 67) (#235).
|
||||
#[test]
|
||||
fn icon_set_thresholds_from_files_pick_the_icon() {
|
||||
use gridcraft_model::{CfRule, CfValueKind, CondFormat};
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[0], [10], [20], [30], [100]]})).unwrap();
|
||||
s.execute("home.conditionalFormat", json!({"range": "A1:A5", "rule": {"type": "iconSet", "set": "3Arrows"}})).unwrap();
|
||||
let icon = |s: &Session, a: &str| {
|
||||
let wb = &s.doc().unwrap().wb;
|
||||
crate::cf::CfCache::new().look(wb, 0, CellRef::parse(a).unwrap()).and_then(|l| l.icon).map(|(_, i)| i)
|
||||
};
|
||||
// Defaults: percent of the 0..100 span, so 30 is still the lowest icon.
|
||||
assert_eq!([icon(&s, "A1"), icon(&s, "A4"), icon(&s, "A5")], [Some(0), Some(0), Some(2)]);
|
||||
let sh = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap();
|
||||
sh.cond_formats = vec![CondFormat {
|
||||
ranges: vec![gridcraft_core::RangeRef::parse("A1:A5").unwrap()],
|
||||
rule: CfRule::IconSet {
|
||||
set: "3Arrows".into(),
|
||||
thresholds: vec![CfValueKind::Percent(0.0), CfValueKind::Number(10.0), CfValueKind::Number(25.0)],
|
||||
reverse: false,
|
||||
show_value: true,
|
||||
},
|
||||
priority: 1,
|
||||
stop_if_true: false,
|
||||
}];
|
||||
assert_eq!([icon(&s, "A1"), icon(&s, "A2"), icon(&s, "A3"), icon(&s, "A4")], [Some(0), Some(1), Some(1), Some(2)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_height_and_column_width_take_a_list_of_areas() {
|
||||
// Issue #341: header drags on a multi-area selection size every selected line in one step.
|
||||
let mut s = s();
|
||||
s.execute("home.columnWidth", json!({"cols": "B:B,D:E", "width": 120})).unwrap();
|
||||
s.execute("home.rowHeight", json!({"rows": "2:2,4:5", "height": 40})).unwrap();
|
||||
let widths: Vec<f32> = (0..6).map(|c| s.doc().unwrap().wb.active().unwrap().col_width(c)).collect();
|
||||
let heights: Vec<f32> = (0..6).map(|r| s.doc().unwrap().wb.active().unwrap().row_height(r)).collect();
|
||||
for c in [1, 3, 4] {
|
||||
assert_eq!(widths[c], 120.0, "column {c}");
|
||||
}
|
||||
for c in [0, 2, 5] {
|
||||
assert_ne!(widths[c], 120.0, "column {c}");
|
||||
}
|
||||
for r in [1, 3, 4] {
|
||||
assert_eq!(heights[r], 40.0, "row {r}");
|
||||
}
|
||||
for r in [0, 2, 5] {
|
||||
assert_ne!(heights[r], 40.0, "row {r}");
|
||||
}
|
||||
assert!(s.execute("home.columnWidth", json!({"cols": "B:B,nope", "width": 50})).is_err());
|
||||
assert_eq!(s.doc().unwrap().wb.active().unwrap().col_width(1), 120.0, "a bad list changes nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copying_areas_that_share_rows_or_columns_pastes_them_as_one_block() {
|
||||
// Issue #348: Excel slides lined-up areas together; formulas travel as their values.
|
||||
let mut s = s();
|
||||
s.execute(
|
||||
"range.setValues",
|
||||
json!({"range": "A1", "values": [["a1", "b1", "c1", "d1"], [1, 2, 3, 4], ["a3", "b3", "c3", "d3"], ["a4", "b4", "c4", "d4"]]}),
|
||||
)
|
||||
.unwrap();
|
||||
s.execute("cell.set", json!({"cell": "C2", "input": "=A2+B2"})).unwrap();
|
||||
|
||||
// Same rows, selected right to left: pasted left to right, B left out.
|
||||
s.execute("selection.set", json!({"range": "C1:C2,A1:A2"})).unwrap();
|
||||
let copied = s.execute("edit.copy", json!({})).unwrap();
|
||||
assert_eq!(copied["text"], "a1\tc1\n1\t3\n");
|
||||
s.execute("edit.paste", json!({"at": "F1"})).unwrap();
|
||||
assert_eq!(v(&s, "F1"), Value::text("a1"));
|
||||
assert_eq!(v(&s, "G1"), Value::text("c1"));
|
||||
assert_eq!(v(&s, "F2"), Value::Number(1.0));
|
||||
assert_eq!(v(&s, "G2"), Value::Number(3.0));
|
||||
assert!(s.doc().unwrap().wb.active().unwrap().cell(CellRef::parse("G2").unwrap()).unwrap().formula.is_none());
|
||||
assert!(v(&s, "H1").is_empty());
|
||||
assert_eq!(s.doc().unwrap().selection.current().a1(), "F1:G2");
|
||||
// The source is untouched.
|
||||
assert_eq!(v(&s, "B1"), Value::text("b1"));
|
||||
assert!(s.doc().unwrap().wb.active().unwrap().cell(CellRef::parse("C2").unwrap()).unwrap().formula.is_some());
|
||||
|
||||
// Same columns: stacked, row 2 left out.
|
||||
s.execute("selection.set", json!({"range": "A1:B1,A3:B4"})).unwrap();
|
||||
assert_eq!(s.execute("edit.copy", json!({})).unwrap()["text"], "a1\tb1\na3\tb3\na4\tb4\n");
|
||||
s.execute("edit.paste", json!({"at": "F5"})).unwrap();
|
||||
for (cell, want) in [("F5", "a1"), ("G5", "b1"), ("F6", "a3"), ("G7", "b4")] {
|
||||
assert_eq!(v(&s, cell), Value::text(want), "{cell}");
|
||||
}
|
||||
assert!(v(&s, "F8").is_empty());
|
||||
|
||||
// Whole columns are trimmed to the used rows like a single-area copy.
|
||||
s.execute("edit.undo", json!({"steps": 2})).unwrap();
|
||||
s.execute("selection.set", json!({"range": "B:B,D:D"})).unwrap();
|
||||
assert_eq!(s.execute("edit.copy", json!({})).unwrap()["text"], "b1\td1\n2\t4\nb3\td3\nb4\td4\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copying_areas_that_do_not_line_up_is_refused_with_a_message() {
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[1, 2, 3], [4, 5, 6], [7, 8, 9]]})).unwrap();
|
||||
s.execute("selection.set", json!({"range": "A1"})).unwrap();
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
for (range, cut) in [("A1:A2,C2:C3", false), ("A1:A2,C1:D3", false), ("A1:A2,A2:A3", false), ("A1:A2,A1:A2", false), ("A1:A2,C1:C2", true)] {
|
||||
s.execute("selection.set", json!({"range": range})).unwrap();
|
||||
let e = s.execute(if cut { "edit.cut" } else { "edit.copy" }, json!({})).unwrap_err().to_string();
|
||||
assert!(e.contains("This action won't work on multiple selections."), "{range}: {e}");
|
||||
assert_eq!(s.clipboard.as_ref().map(|c| c.range.a1()), Some("A1".into()), "{range}: the earlier copy stays");
|
||||
}
|
||||
// Paste Link can't point at a slid-together block.
|
||||
s.execute("selection.set", json!({"range": "A1:A2,C1:C2"})).unwrap();
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
assert!(s.execute("edit.pasteSpecial", json!({"what": "all", "link": true, "at": "E1"})).is_err());
|
||||
assert!(v(&s, "E1").is_empty());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user