mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-29 05:06:51 -06:00
229 lines
9.0 KiB
Rust
229 lines
9.0 KiB
Rust
//! Data tab: sort, filter, remove duplicates, text to columns, data validation, outline.
|
|
|
|
use std::cmp::Ordering;
|
|
use std::sync::Arc;
|
|
|
|
use gridcraft_core::{CellRef, RangeRef, Value, sort_compare};
|
|
use gridcraft_model::{
|
|
Anchor, AnchorMode, AutoFilter, Cell, CfOperator, Comment, ErrorStyle, FilterCriterion, Formula, Hyperlink, LineInfo, Sheet, Validation,
|
|
ValidationKind, Workbook,
|
|
};
|
|
use serde_json::{Value as Json, json};
|
|
|
|
use super::{
|
|
CommandSpec, EngineError, Result, Session, bad, bool_param, cell_param, cell_param_on, cmd, edit, has_doc, ok, parse_lines, split_sheet,
|
|
str_param, target_range, target_ranges, target_sheet,
|
|
};
|
|
use crate::outline;
|
|
use crate::selection::current_region;
|
|
|
|
mod filter;
|
|
mod grouping;
|
|
mod sort;
|
|
mod tools;
|
|
mod validation;
|
|
|
|
pub use filter::filter_values;
|
|
pub(crate) use filter::{apply_filter, grow_autofilter};
|
|
use filter::{clear_filter, filter_by, reapply, toggle_filter};
|
|
use grouping::{detail, group, outline_settings, show_levels, subtotal};
|
|
use sort::{quick_sort, sort};
|
|
#[cfg(test)]
|
|
use tools::split_delimited;
|
|
use tools::{remove_duplicates, text_to_columns};
|
|
pub use validation::{check_validation, list_items};
|
|
use validation::{circle_invalid, validate_cmd, validation};
|
|
pub(super) use validation::{move_rule_formula, remove_validation_cells};
|
|
|
|
pub fn specs() -> Vec<CommandSpec> {
|
|
vec![
|
|
cmd!("data.sortAscending", "Sort A to Z", ["Data", "Sort & Filter"], None, "{range?, column?: \"B\"}", has_doc, |s, p| quick_sort(
|
|
s, p, true
|
|
)),
|
|
cmd!("data.sortDescending", "Sort Z to A", ["Data", "Sort & Filter"], None, "{range?, column?}", has_doc, |s, p| quick_sort(s, p, false)),
|
|
cmd!(
|
|
"data.sort",
|
|
"Sort…",
|
|
["Data", "Sort & Filter"],
|
|
None,
|
|
"{range?, header?: bool, keys: [{column: \"B\", order: asc|desc, by?: values|cellColor|fontColor, customList?: [..]}], orientation?: rows|columns, matchCase?}",
|
|
has_doc,
|
|
sort
|
|
),
|
|
cmd!(
|
|
"data.filter",
|
|
"Filter",
|
|
["Data", "Sort & Filter"],
|
|
Some("Ctrl+Shift+L"),
|
|
"{range?} toggles AutoFilter on the current region",
|
|
has_doc,
|
|
toggle_filter
|
|
),
|
|
cmd!(
|
|
"data.filterBy",
|
|
"Filter Column",
|
|
[],
|
|
None,
|
|
"{column: \"B\" (or 0-based offset), values?: [..], blanks?: bool, custom?: {op: \">\", value: \"10\", op2?, value2?, and?}, top?: {count, bottom?, percent?}, aboveAverage?: bool, clear?: bool}",
|
|
has_doc,
|
|
filter_by
|
|
),
|
|
cmd!("data.clearFilter", "Clear", ["Data", "Sort & Filter"], None, "{}", has_doc, clear_filter),
|
|
cmd!("data.reapply", "Reapply", ["Data", "Sort & Filter"], None, "{}", has_doc, reapply),
|
|
cmd!(
|
|
"data.removeDuplicates",
|
|
"Remove Duplicates",
|
|
["Data", "Data Tools"],
|
|
None,
|
|
"{range?, columns?: [\"A\",\"C\"], header?: bool}",
|
|
has_doc,
|
|
remove_duplicates
|
|
),
|
|
cmd!(
|
|
"data.textToColumns",
|
|
"Text to Columns",
|
|
["Data", "Data Tools"],
|
|
None,
|
|
"{range?, delimiters?: [\",\", \"\\t\", \" \", \";\"], other?: \"|\", fixedWidths?: [5, 10], treatConsecutive?: bool, textQualifier?: \"\\\"\", destination?: \"B1\"}",
|
|
has_doc,
|
|
text_to_columns
|
|
),
|
|
cmd!(
|
|
"data.validation",
|
|
"Data Validation…",
|
|
["Data", "Data Tools"],
|
|
None,
|
|
"{range?, type: any|whole|decimal|list|date|time|textLength|custom, operator?: between|notBetween|equal|notEqual|greater|less|greaterOrEqual|lessOrEqual, formula1, formula2?, ignoreBlank?, dropdown?, inputTitle?, inputMessage?, errorStyle?: stop|warning|information, errorTitle?, errorMessage?, clear?: bool}",
|
|
has_doc,
|
|
validation
|
|
),
|
|
cmd!(query "data.validate", "Check Value Against Validation", [], None, "{cell?, input} → {ok, message?}", has_doc, validate_cmd),
|
|
cmd!(noundo "data.circleInvalid", "Circle Invalid Data", ["Data", "Data Tools", "Data Validation"], None, "{} → invalid cells", has_doc, circle_invalid),
|
|
cmd!(
|
|
"data.group",
|
|
"Group",
|
|
["Data", "Outline"],
|
|
Some("Alt+Shift+Right"),
|
|
"{rows?: \"2:5\" | cols?: \"B:C\" | by?: rows|cols (the selection's rows or columns; a block of cells groups rows by default), sheet?}",
|
|
has_doc,
|
|
|s, p| group(s, p, true)
|
|
),
|
|
cmd!("data.ungroup", "Ungroup", ["Data", "Outline"], Some("Alt+Shift+Left"), "{rows? | cols? | by?: rows|cols, sheet?}", has_doc, |s, p| {
|
|
group(s, p, false)
|
|
}),
|
|
cmd!(
|
|
"data.hideDetail",
|
|
"Hide Detail",
|
|
["Data", "Outline"],
|
|
None,
|
|
"{rows? | cols? | by?, level?: 1-7} (the innermost group, or the one at `level`, holding the lines or summarised by them)",
|
|
has_doc,
|
|
|s, p| detail(s, p, true)
|
|
),
|
|
cmd!("data.showDetail", "Show Detail", ["Data", "Outline"], None, "{rows? | cols? | by?, level?: 1-7}", has_doc, |s, p| detail(s, p, false)),
|
|
cmd!(
|
|
"data.showLevels",
|
|
"Show Outline Levels",
|
|
[],
|
|
None,
|
|
"{rows?: 1-8, cols?: 1-8} (1 shows only the top-level summaries, as the outline's level buttons)",
|
|
has_doc,
|
|
show_levels
|
|
),
|
|
cmd!(
|
|
"data.outlineSettings",
|
|
"Outline Settings",
|
|
["Data", "Outline"],
|
|
None,
|
|
"{sheet?, summaryBelow?: bool, summaryRight?: bool} (summary rows below their detail, summary columns right of it; off puts them above / left) → {summaryBelow, summaryRight}",
|
|
has_doc,
|
|
outline_settings
|
|
),
|
|
cmd!(
|
|
"data.subtotal",
|
|
"Subtotal",
|
|
["Data", "Outline"],
|
|
None,
|
|
"{range?, groupBy: \"A\", function?: sum|count|average|max|min|product, columns: [\"C\"], header?: true}",
|
|
has_doc,
|
|
subtotal
|
|
),
|
|
cmd!("data.flashFill", "Flash Fill", ["Data", "Data Tools"], None, "{range?}", has_doc, |s, p| s.execute("edit.flashFill", p.clone())),
|
|
]
|
|
}
|
|
|
|
fn col_param(v: Option<&Json>, base: u32) -> Option<u32> {
|
|
match v? {
|
|
Json::String(s) => gridcraft_core::letters_to_col(s.trim_start_matches('$')),
|
|
Json::Number(n) => n.as_u64().and_then(|o| u32::try_from(o).ok()).and_then(|o| base.checked_add(o)),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// The range a data command works on: the selection when it's a block, else the current
|
|
/// region (trimmed to the used range for whole columns).
|
|
fn data_range(s: &Session, p: &Json) -> Result<RangeRef> {
|
|
let d = s.doc()?;
|
|
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
|
|
if p.get("range").is_some() {
|
|
return target_range(s, p);
|
|
}
|
|
if let Some(t) = sh.table_at(d.selection.active) {
|
|
return Ok(t.data_range().map(|dr| RangeRef::new(t.range.start, dr.end)).unwrap_or(t.range));
|
|
}
|
|
let sel = d.selection.current();
|
|
if !sel.is_single() {
|
|
// An empty sheet has no data to work on: just the active cell.
|
|
let Some(used) = sh.used_range() else { return Ok(RangeRef::cell(d.selection.active)) };
|
|
return Ok(sel.intersection(&used).unwrap_or(sel));
|
|
}
|
|
Ok(current_region(sh, d.selection.active))
|
|
}
|
|
|
|
/// The sheet and range a data command works on: the sheet `sheet` or a `Sheet!` prefix on `range`
|
|
/// names, else the active one. The selection belongs to the active sheet, so another sheet needs `range`.
|
|
fn data_target(s: &Session, p: &Json, cmd: &str) -> Result<(usize, RangeRef)> {
|
|
let sheet = target_sheet(s, p)?;
|
|
let d = s.doc()?;
|
|
if let Some(t) = str_param(p, "range")
|
|
&& let (Some(name), _) = split_sheet(t)
|
|
&& d.wb.sheet_index(&name) != Some(sheet)
|
|
{
|
|
return Err(bad(cmd, format!("`{t}` names a sheet other than `sheet`")));
|
|
}
|
|
if sheet != d.wb.active_sheet() && p.get("range").is_none() {
|
|
return Err(bad(cmd, "missing `range`: the selection is on the active sheet"));
|
|
}
|
|
Ok((sheet, data_range(s, p)?))
|
|
}
|
|
|
|
/// Excel's header guess: the first row is text over a column of numbers, or is bold.
|
|
fn guess_header(wb: &Workbook, sh: &Sheet, r: RangeRef) -> bool {
|
|
if let Some(t) = sh.table_at(r.start) {
|
|
return t.header_row;
|
|
}
|
|
if r.height() < 2 {
|
|
return false;
|
|
}
|
|
let mut votes = 0i32;
|
|
for c in r.start.col..=r.end.col {
|
|
let a = sh.value(CellRef::new(r.start.row, c));
|
|
let b = sh.value(CellRef::new(r.start.row + 1, c));
|
|
if a.is_text() && !b.is_text() && !b.is_empty() {
|
|
votes += 1;
|
|
}
|
|
if wb.styles.get(sh.style_id(CellRef::new(r.start.row, c))).font.bold
|
|
!= wb.styles.get(sh.style_id(CellRef::new(r.start.row + 1, c))).font.bold
|
|
{
|
|
votes += 1;
|
|
}
|
|
if a.is_text() && b.is_text() {
|
|
votes -= 0;
|
|
}
|
|
}
|
|
votes > 0
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests;
|