492 lines
17 KiB
Rust
492 lines
17 KiB
Rust
//! The Data menu: sort, filter, duplicates, columns, pastes (TABLE-08).
|
|
|
|
use ely_gpui_component::{
|
|
forms::TextInput,
|
|
primitives::Severity,
|
|
tables::{FilterRule, SortKey, Test},
|
|
};
|
|
use ely_lok::{
|
|
Request,
|
|
sheet::{self, Entry, Selection},
|
|
};
|
|
use ely_office::prelude::*;
|
|
use ely_platform::clipboard::{self, Clip};
|
|
use gpui::{
|
|
App, AppContext as _, Context, Div, InteractiveElement as _, Menu, MenuItem, SharedString,
|
|
Window, actions,
|
|
};
|
|
|
|
use crate::book::Book;
|
|
use crate::dialog::Dialog;
|
|
|
|
/// The state a selection was read in, and its sheet.
|
|
pub(crate) type ReadState = (ely_lok::Doc, usize);
|
|
|
|
mod dialogs;
|
|
pub use dialogs::{column_dialog, columns_dialog, dedupe_dialog, filter_dialog, sort_dialog};
|
|
|
|
actions!(
|
|
table,
|
|
[
|
|
Sort,
|
|
AutoFilter,
|
|
StandardFilter,
|
|
ClearFilter,
|
|
RemoveDuplicates,
|
|
TextToColumns,
|
|
PasteValues,
|
|
PasteFormulas
|
|
]
|
|
);
|
|
|
|
pub fn menu(cx: &App) -> Menu {
|
|
let label = |key: &str| Book::text(cx, key, &[]);
|
|
Menu::new(label("menu.data")).items([
|
|
MenuItem::action(label("table.data.sort"), Sort),
|
|
MenuItem::separator(),
|
|
MenuItem::action(label("table.data.autofilter"), AutoFilter),
|
|
MenuItem::action(label("table.data.filter"), StandardFilter),
|
|
MenuItem::action(label("table.data.unfilter"), ClearFilter),
|
|
MenuItem::separator(),
|
|
MenuItem::action(label("table.data.dedupe"), RemoveDuplicates),
|
|
MenuItem::action(label("table.data.columns"), TextToColumns),
|
|
MenuItem::separator(),
|
|
MenuItem::action(label("office.find.open"), ely_office::FindReplace),
|
|
MenuItem::action(label("table.data.paste_values"), PasteValues),
|
|
MenuItem::action(label("table.data.paste_formulas"), PasteFormulas),
|
|
MenuItem::separator(),
|
|
MenuItem::action(label("table.rules.validation"), crate::rules::Validate),
|
|
MenuItem::action(label("table.rules.unvalidate"), crate::rules::Unvalidate),
|
|
])
|
|
}
|
|
|
|
pub fn bind(root: Div, cx: &mut Context<Book>) -> Div {
|
|
root.on_action(cx.listener(|book, _: &Sort, _, cx| book.ask_data(Asked::Sort, cx)))
|
|
.on_action(cx.listener(|book, _: &StandardFilter, _, cx| book.ask_data(Asked::Filter, cx)))
|
|
.on_action(
|
|
cx.listener(|book, _: &RemoveDuplicates, _, cx| book.ask_data(Asked::Dedupe, cx)),
|
|
)
|
|
.on_action(cx.listener(|book, _: &TextToColumns, _, cx| book.ask_columns(cx)))
|
|
.on_action(
|
|
cx.listener(|book, _: &AutoFilter, _, cx| book.run_active("data.autofilter", &[], cx)),
|
|
)
|
|
.on_action(
|
|
cx.listener(|book, _: &ClearFilter, _, cx| book.run_active("data.unfilter", &[], cx)),
|
|
)
|
|
.on_action(cx.listener(|book, _: &PasteValues, _, cx| book.paste_only("paste.values", cx)))
|
|
.on_action(
|
|
cx.listener(|book, _: &PasteFormulas, _, cx| book.paste_only("paste.formulas", cx)),
|
|
)
|
|
}
|
|
|
|
/// Which data dialog to open.
|
|
#[derive(Clone, Copy, Debug)]
|
|
pub enum Asked {
|
|
Sort,
|
|
Filter,
|
|
Dedupe,
|
|
}
|
|
|
|
/// The kernel's operator for a builder test.
|
|
fn op(test: Test) -> &'static str {
|
|
match test {
|
|
Test::Contains => "contains",
|
|
Test::Equals => "equals",
|
|
Test::NotEquals => "notequals",
|
|
Test::Above => "greater",
|
|
Test::Below => "less",
|
|
Test::Empty => "empty",
|
|
}
|
|
}
|
|
|
|
/// The `data.filter` query for builder rules.
|
|
pub fn query(rules: &[FilterRule], any: bool) -> String {
|
|
let conditions: Vec<_> = rules
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(at, rule)| {
|
|
serde_json::json!({
|
|
"field": rule.column.parse::<u32>().expect("columns are indexes"),
|
|
"op": op(rule.test),
|
|
"value": rule.value.as_ref(),
|
|
"or": any && at > 0,
|
|
})
|
|
})
|
|
.collect();
|
|
serde_json::json!({ "conditions": conditions }).to_string()
|
|
}
|
|
|
|
/// The `data.filter` query keeping `picked` of a column.
|
|
pub fn column_query(column: u32, entries: &[Entry], picked: &[SharedString]) -> String {
|
|
if entries
|
|
.iter()
|
|
.all(|entry| picked.iter().any(|p| *p == entry.text))
|
|
{
|
|
return serde_json::json!({ "column": column }).to_string();
|
|
}
|
|
let values: Vec<&str> = picked.iter().map(SharedString::as_ref).collect();
|
|
serde_json::json!({
|
|
"column": column,
|
|
"conditions": [{ "op": "values", "values": values }],
|
|
})
|
|
.to_string()
|
|
}
|
|
|
|
/// The `data.sort` values for up to three keys.
|
|
pub fn sort_values(header: bool, keys: &[SortKey]) -> Vec<String> {
|
|
let mut values = vec![header.to_string()];
|
|
for at in 0..3 {
|
|
match keys.get(at) {
|
|
Some(key) => {
|
|
let column: u32 = key.column.parse().expect("columns are indexes");
|
|
values.push((column + 1).to_string());
|
|
values.push(key.rising.to_string());
|
|
}
|
|
None => values.extend(["0".to_string(), "true".to_string()]),
|
|
}
|
|
}
|
|
values
|
|
}
|
|
|
|
/// A column's title: its header text, else its letters.
|
|
pub fn title(picked: &Selection, column: u32, header: bool) -> SharedString {
|
|
let (first, ..) = picked.area.expect("a data area");
|
|
let text = picked.top[(column - first) as usize].trim();
|
|
if header && !text.is_empty() {
|
|
text.to_string().into()
|
|
} else {
|
|
sheet::column_name(column).into()
|
|
}
|
|
}
|
|
|
|
impl Book {
|
|
pub(crate) fn warn_key(&self, key: &str, cx: &mut Context<Self>) {
|
|
let text = Book::text(cx, key, &[]);
|
|
self.toast(text, Severity::Warning, cx);
|
|
}
|
|
|
|
/// Reads tab `id`'s selection; a lone cell grows to data.
|
|
pub(crate) fn data_of(
|
|
&mut self,
|
|
id: u64,
|
|
grow: bool,
|
|
then: impl FnOnce(&mut Self, Selection, ReadState, &mut Window, &mut Context<Self>) + 'static,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
self.ask_in(
|
|
id,
|
|
move |worker, doc| {
|
|
let read = |worker: &mut ely_lok::Worker| {
|
|
sheet::selected_range(&worker.values(doc, sheet::SELECTED)?)
|
|
};
|
|
let picked = match read(worker) {
|
|
// The kernel's data area around the cell.
|
|
Ok(Some(lone)) if grow && !lone.range.contains(':') => {
|
|
worker.run(doc, "select.data", &[])?;
|
|
let grown = read(worker)?;
|
|
// No data: unmark, or Enter stays in the cell.
|
|
if grown.as_ref().is_some_and(|picked| picked.area.is_none()) {
|
|
worker.run(doc, "cell.goto", &[&lone.range])?;
|
|
}
|
|
Ok(grown)
|
|
}
|
|
other => other,
|
|
}?;
|
|
// Numbers in the picked range hold in this state.
|
|
Ok((picked, (worker.now(doc)?, doc.sheets(worker)?.current)))
|
|
},
|
|
move |book, read, window, cx| match read {
|
|
Ok((Some(picked), state)) if picked.area.is_some() => then(book, picked, state, window, cx),
|
|
Ok((Some(_), _)) => book.warn_key("table.data.empty", cx),
|
|
Ok((None, _)) => book.warn_key("table.data.one_range", cx),
|
|
Err(error) => book.engine_failed(&error.to_string(), cx),
|
|
},
|
|
cx,
|
|
);
|
|
}
|
|
|
|
pub(crate) fn ask_data(&mut self, asked: Asked, cx: &mut Context<Self>) {
|
|
let Some(id) = self.core.active else {
|
|
return self.warn_key("command.blocked.no_document", cx);
|
|
};
|
|
self.data_in(id, asked, cx);
|
|
}
|
|
|
|
/// The data dialog for tab `id`, once its input lands.
|
|
fn data_in(&mut self, id: u64, asked: Asked, cx: &mut Context<Self>) {
|
|
if !self.settled(id, cx) {
|
|
return self.when_settled(id, move |book, _, cx| book.data_in(id, asked, cx));
|
|
}
|
|
self.data_of(
|
|
id,
|
|
true,
|
|
move |book, picked, state, _, cx| book.data_dialog(id, asked, picked, state, cx),
|
|
cx,
|
|
);
|
|
}
|
|
|
|
fn data_dialog(&mut self, id: u64, asked: Asked, picked: Selection, state: ReadState, cx: &mut Context<Self>) {
|
|
log::info!("table: {asked:?} on {}", picked.range);
|
|
let header = picked.header;
|
|
self.open_dialog(match asked {
|
|
Asked::Sort => {
|
|
let (first, ..) = picked.area.expect("a data area");
|
|
Dialog::Sort {
|
|
tab: id,
|
|
keys: vec![SortKey {
|
|
column: first.to_string().into(),
|
|
rising: true,
|
|
}],
|
|
header,
|
|
picked,
|
|
}
|
|
}
|
|
Asked::Filter => Dialog::Filter {
|
|
tab: id,
|
|
rules: Vec::new(),
|
|
any: false,
|
|
picked,
|
|
},
|
|
Asked::Dedupe => Dialog::Dedupe {
|
|
tab: id,
|
|
header,
|
|
picked,
|
|
state,
|
|
},
|
|
});
|
|
cx.notify();
|
|
}
|
|
|
|
/// Sorts, filters or removes duplicates as asked.
|
|
pub(crate) fn data_asked(&mut self, cx: &mut Context<Self>) {
|
|
let Some(dialog) = self.dialog.take() else {
|
|
return;
|
|
};
|
|
match dialog {
|
|
Dialog::Sort {
|
|
tab, keys, header, ..
|
|
} => {
|
|
let values = sort_values(header, &keys);
|
|
let values: Vec<&str> = values.iter().map(String::as_str).collect();
|
|
self.run_in(Some(tab), "data.sort", &values, cx);
|
|
}
|
|
Dialog::Filter {
|
|
tab, rules, any, ..
|
|
} => self.run_in(Some(tab), "data.filter", &[&query(&rules, any)], cx),
|
|
Dialog::Dedupe {
|
|
tab,
|
|
header,
|
|
picked,
|
|
state: (at, sheet),
|
|
} => {
|
|
let (c1, r1, c2, r2) = picked.area.expect("a data area");
|
|
let sheet = sheet.to_string();
|
|
let corners = [c1, r1, c2, r2].map(|n| n.to_string());
|
|
let header = header.to_string();
|
|
let mut values = vec!["true", header.as_str(), "true"];
|
|
values.extend(corners.iter().map(String::as_str));
|
|
values.push(&sheet);
|
|
self.run_on(tab, &at, "data.dedupe", &values, cx);
|
|
}
|
|
other => self.dialog = Some(other),
|
|
}
|
|
}
|
|
|
|
/// Splits one column of text by the chosen separators.
|
|
pub(crate) fn ask_columns(&mut self, cx: &mut Context<Self>) {
|
|
let Some(id) = self.core.active else {
|
|
return self.warn_key("command.blocked.no_document", cx);
|
|
};
|
|
self.columns_in(id, cx);
|
|
}
|
|
|
|
/// The columns dialog for tab `id`, once its input lands.
|
|
pub(crate) fn columns_in(&mut self, id: u64, cx: &mut Context<Self>) {
|
|
if !self.settled(id, cx) {
|
|
return self.when_settled(id, move |book, _, cx| book.columns_in(id, cx));
|
|
}
|
|
self.data_of(
|
|
id,
|
|
false,
|
|
move |book, picked, _, window, cx| {
|
|
let (c1, _, c2, _) = picked.area.expect("a data area");
|
|
if c1 != c2 {
|
|
return book.warn_key("table.columns.same", cx);
|
|
}
|
|
let other = cx.new(|cx| TextInput::new(window, cx));
|
|
book.open_dialog(Dialog::Columns {
|
|
tab: id,
|
|
separators: vec!["9".into()],
|
|
other,
|
|
});
|
|
cx.notify();
|
|
},
|
|
cx,
|
|
);
|
|
}
|
|
|
|
/// Runs the split the columns dialog describes.
|
|
pub(crate) fn columns_asked(&mut self, cx: &mut Context<Self>) {
|
|
let Some(Dialog::Columns {
|
|
tab,
|
|
separators,
|
|
other,
|
|
}) = self.dialog.take()
|
|
else {
|
|
return;
|
|
};
|
|
let mut codes: Vec<String> = separators.iter().map(ToString::to_string).collect();
|
|
if let Some(c) = other.read(cx).text().chars().next() {
|
|
codes.push(u32::from(c).to_string());
|
|
}
|
|
// Separators, quote, UTF-8, first row.
|
|
let options = format!("{},34,76,1", codes.join("/"));
|
|
self.run_in(Some(tab), "data.columns", &[&options], cx);
|
|
}
|
|
|
|
fn paste_only(&mut self, id: &'static str, cx: &mut Context<Self>) {
|
|
let Some(tab) = self.core.active else {
|
|
return self.warn_key("command.blocked.no_document", cx);
|
|
};
|
|
self.paste_only_in(tab, id, cx);
|
|
}
|
|
|
|
/// Values or formulas alone, from cells this workbook copied.
|
|
pub(crate) fn paste_only_in(&mut self, tab: u64, id: &'static str, cx: &mut Context<Self>) {
|
|
if !self.settled(tab, cx) {
|
|
return self.when_settled(tab, move |book, _, cx| book.paste_only_in(tab, id, cx));
|
|
}
|
|
let Some(view) = self.tab(tab).map(|tab| tab.view.clone()) else {
|
|
return;
|
|
};
|
|
let html = match clipboard::read() {
|
|
Ok(Some(Clip::Html { html, .. })) => Some(html),
|
|
Ok(_) => None,
|
|
Err(error) => {
|
|
let text = Book::text(cx, "table.data.clipboard", &[("error", &error.to_string())]);
|
|
return self.toast(text, Severity::Danger, cx);
|
|
}
|
|
};
|
|
// The kernel keeps only its own cells' values and formulas.
|
|
if !html.is_some_and(|html| view.read(cx).holds_copy(&html)) {
|
|
return self.warn_key("table.data.paste_foreign", cx);
|
|
}
|
|
log::info!("table: {id}");
|
|
view.update(cx, |view, _| view.paste_only(id));
|
|
}
|
|
|
|
/// The filter menu a column button asks for.
|
|
pub(crate) fn ask_column(&mut self, id: u64, request: &Request, cx: &mut Context<Self>) {
|
|
let Request::Filter { column, row } = *request else {
|
|
return;
|
|
};
|
|
self.ask_in(
|
|
id,
|
|
move |worker, doc| {
|
|
let json = worker.values(doc, &sheet::cell_query(sheet::FILTER, column, row))?;
|
|
sheet::filter_values(&json)
|
|
},
|
|
move |book, read, _, cx| match read {
|
|
Ok(filter) => {
|
|
let picked = filter
|
|
.entries
|
|
.iter()
|
|
.filter(|entry| entry.selected)
|
|
.map(|entry| SharedString::from(entry.text.clone()))
|
|
.collect();
|
|
book.open_dialog(Dialog::Column {
|
|
tab: id,
|
|
column,
|
|
row,
|
|
entries: filter.entries,
|
|
picked,
|
|
});
|
|
cx.notify();
|
|
}
|
|
Err(error) => book.engine_failed(&error.to_string(), cx),
|
|
},
|
|
cx,
|
|
);
|
|
}
|
|
|
|
/// Keeps the picked entries, or sorts by the column.
|
|
pub(crate) fn column_asked(&mut self, sort: Option<bool>, cx: &mut Context<Self>) {
|
|
let Some(Dialog::Column {
|
|
tab,
|
|
column,
|
|
row,
|
|
entries,
|
|
picked,
|
|
}) = self.dialog.take()
|
|
else {
|
|
return;
|
|
};
|
|
// The kernel filters the cursor's range: go to the button.
|
|
self.run_in(Some(tab), "cell.goto", &[&sheet::address(column, row)], cx);
|
|
match sort {
|
|
Some(rising) => {
|
|
let key = SortKey {
|
|
column: column.to_string().into(),
|
|
rising,
|
|
};
|
|
let values = sort_values(true, &[key]);
|
|
let values: Vec<&str> = values.iter().map(String::as_str).collect();
|
|
self.run_in(Some(tab), "data.sort", &values, cx);
|
|
}
|
|
None => {
|
|
let query = column_query(column, &entries, &picked);
|
|
self.run_in(Some(tab), "data.filter", &[&query], cx);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn rules_and_keys_become_kernel_values() {
|
|
let rules = [
|
|
FilterRule {
|
|
column: "2".into(),
|
|
test: Test::Above,
|
|
value: "5".into(),
|
|
},
|
|
FilterRule {
|
|
column: "0".into(),
|
|
test: Test::Empty,
|
|
value: "".into(),
|
|
},
|
|
];
|
|
let json: serde_json::Value = serde_json::from_str(&query(&rules, true)).expect("json");
|
|
assert_eq!(json["conditions"][0]["op"], "greater");
|
|
assert_eq!(json["conditions"][1]["or"], true);
|
|
let keys = [SortKey {
|
|
column: "1".into(),
|
|
rising: false,
|
|
}];
|
|
assert_eq!(
|
|
sort_values(true, &keys),
|
|
["true", "2", "false", "0", "true", "0", "true"]
|
|
);
|
|
let entries = [
|
|
Entry {
|
|
text: "a".into(),
|
|
hidden: false,
|
|
selected: true,
|
|
},
|
|
Entry {
|
|
text: "b".into(),
|
|
hidden: false,
|
|
selected: true,
|
|
},
|
|
];
|
|
assert_eq!(
|
|
column_query(3, &entries, &["a".into(), "b".into()]),
|
|
r#"{"column":3}"#
|
|
);
|
|
assert!(column_query(3, &entries, &["a".into()]).contains(r#""values":["a"]"#));
|
|
}
|
|
}
|