//! 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 { 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 { 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 { 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;