Merge pull request #31 from zaphar/exporting

Exporting to csv
This commit is contained in:
Jeremy Wall 2025-03-04 21:08:42 -05:00 committed by GitHub
commit 49786fd6d9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 149 additions and 21 deletions

16
Cargo.lock generated
View File

@ -482,9 +482,9 @@ dependencies = [
[[package]] [[package]]
name = "csv" name = "csv"
version = "1.3.0" version = "1.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac574ff4d437a7b5ad237ef331c17ccca63c46479e5b5453eb8e10bb99a759fe" checksum = "acdc4883a9c96732e4733212c01447ebd805833b7275a73ca3ee080fd77afdaf"
dependencies = [ dependencies = [
"csv-core", "csv-core",
"itoa", "itoa",
@ -501,16 +501,6 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "csvx"
version = "0.1.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92081efd8b1d03f5a1bf242876cfdd8fa2bf9fe521ddb2d31f8747dfa2dd2cb7"
dependencies = [
"csv",
"thiserror",
]
[[package]] [[package]]
name = "deranged" name = "deranged"
version = "0.3.11" version = "0.3.11"
@ -1507,7 +1497,7 @@ dependencies = [
"clap", "clap",
"colorsys", "colorsys",
"crossterm", "crossterm",
"csvx", "csv",
"futures", "futures",
"ironcalc", "ironcalc",
"ratatui", "ratatui",

View File

@ -9,7 +9,6 @@ edition = "2021"
anyhow = { version = "1.0.91", features = ["backtrace"] } anyhow = { version = "1.0.91", features = ["backtrace"] }
clap = { version = "4.5.20", features = ["derive"] } clap = { version = "4.5.20", features = ["derive"] }
crossterm = { version = "0.28.1", features = ["event-stream", "serde"] } crossterm = { version = "0.28.1", features = ["event-stream", "serde"] }
csvx = "0.1.17"
# this revision introduces a way to get the Model back out of the UserModel # this revision introduces a way to get the Model back out of the UserModel
ironcalc = { git = "https://github.com/ironcalc/IronCalc" } ironcalc = { git = "https://github.com/ironcalc/IronCalc" }
futures = "0.3.31" futures = "0.3.31"
@ -21,3 +20,4 @@ slice-utils = { git = "https://dev.zaphar.net/zaphar/slice-cursor-rs.git" }
serde_json = "1.0.133" serde_json = "1.0.133"
colorsys = "0.6.7" colorsys = "0.6.7"
tui-markdown = { version = "0.3.1", features = [] } tui-markdown = { version = "0.3.1", features = [] }
csv = "1.3.1"

View File

@ -12,6 +12,7 @@ The currently supported commands are:
* `new-sheet [name]` Creates a new sheet. If the name is provided then uses that. If omitted then uses a default sheet name. * `new-sheet [name]` Creates a new sheet. If the name is provided then uses that. If omitted then uses a default sheet name.
* `select-sheet <name>` Select a sheet by name. * `select-sheet <name>` Select a sheet by name.
* `edit <path>` Edit a new spreadsheet at the current path. `e` is a shorthand alias for this command. * `edit <path>` Edit a new spreadsheet at the current path. `e` is a shorthand alias for this command.
* `export-csv <path>` Export the current sheet to a csv file at `<path>`.
* `quit` Quits the application. `q` is a shorthand alias for this command. * `quit` Quits the application. `q` is a shorthand alias for this command.
<aside>Note that in the case of `quit` and `edit` that we do not currently <aside>Note that in the case of `quit` and `edit` that we do not currently

View File

@ -23,7 +23,6 @@ pub(crate) const COL_PIXELS: f64 = 5.0;
pub(crate) const LAST_COLUMN: i32 = 16_384; pub(crate) const LAST_COLUMN: i32 = 16_384;
pub(crate) const LAST_ROW: i32 = 1_048_576; pub(crate) const LAST_ROW: i32 = 1_048_576;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct AddressRange<'book> { pub struct AddressRange<'book> {
pub start: &'book Address, pub start: &'book Address,
@ -37,7 +36,11 @@ impl<'book> AddressRange<'book> {
for ri in row_range.iter() { for ri in row_range.iter() {
let mut row = Vec::with_capacity(col_range.len()); let mut row = Vec::with_capacity(col_range.len());
for ci in col_range.iter() { for ci in col_range.iter() {
row.push(Address { sheet: self.start.sheet, row: *ri, col: *ci }); row.push(Address {
sheet: self.start.sheet,
row: *ri,
col: *ci,
});
} }
rows.push(row); rows.push(row);
} }
@ -49,7 +52,11 @@ impl<'book> AddressRange<'book> {
let mut rows = Vec::with_capacity(row_range.len() * col_range.len()); let mut rows = Vec::with_capacity(row_range.len() * col_range.len());
for ri in row_range.iter() { for ri in row_range.iter() {
for ci in col_range.iter() { for ci in col_range.iter() {
rows.push(Address { sheet: self.start.sheet, row: *ri, col: *ci }); rows.push(Address {
sheet: self.start.sheet,
row: *ri,
col: *ci,
});
} }
} }
rows rows
@ -112,6 +119,58 @@ impl Book {
)?)) )?))
} }
pub fn csv_for_sheet<W>(&self, sheet: u32, sink: W) -> Result<()>
where
W: std::io::Write,
{
let rows = self.get_export_rows_for_sheet(sheet)?;
let mut writer = csv::Writer::from_writer(sink);
for row in rows {
writer.write_record(row)?;
}
Ok(())
}
pub fn get_export_rows(&self) -> Result<Vec<Vec<String>>> {
let sheet = self.location.sheet;
Ok(self.get_export_rows_for_sheet(sheet)?)
}
pub fn get_export_rows_for_sheet(&self, sheet: u32) -> Result<Vec<Vec<String>>, anyhow::Error> {
let worksheet = self
.model
.get_model()
.workbook
.worksheet(sheet)
.map_err(|e| anyhow!(e))?;
let mut max_row = 0;
let mut max_col = 0;
for (r, cols) in worksheet.sheet_data.iter() {
if max_row <= *r {
max_row = *r;
}
for (c, _) in cols.iter() {
if max_col <= *c {
max_col = *c;
}
}
}
let mut rows = Vec::new();
for ri in 0..=max_row {
let mut row = Vec::new();
for ci in 0..=max_col {
let cell_content = self.get_cell_addr_rendered(&Address {
sheet,
row: ri as usize,
col: ci as usize,
})?;
row.push(cell_content);
}
rows.push(row);
}
Ok(rows)
}
/// Evaluate the spreadsheet calculating formulas and style changes. /// Evaluate the spreadsheet calculating formulas and style changes.
/// This can be an expensive operation. /// This can be an expensive operation.
pub fn evaluate(&mut self) { pub fn evaluate(&mut self) {
@ -123,6 +182,15 @@ impl Book {
Ok(Self::from_model(load_from_xlsx(path, locale, tz)?)) Ok(Self::from_model(load_from_xlsx(path, locale, tz)?))
} }
/// Save a sheet in the book to a csv file
pub fn save_sheet_to_csv(&self, sheet: u32, path: &str) -> Result<()> {
let file_path = std::path::Path::new(path);
let file = std::fs::File::create(file_path)?;
let writer = std::io::BufWriter::new(file);
self.csv_for_sheet(sheet, writer)?;
Ok(())
}
/// Save book to an xlsx file. /// Save book to an xlsx file.
pub fn save_to_xlsx(&mut self, path: &str) -> Result<()> { pub fn save_to_xlsx(&mut self, path: &str) -> Result<()> {
// TODO(zaphar): Currently overwrites. Should we prompt in this case? // TODO(zaphar): Currently overwrites. Should we prompt in this case?
@ -604,7 +672,13 @@ impl Book {
.get_model() .get_model()
.workbook .workbook
.worksheet(self.location.sheet) .worksheet(self.location.sheet)
.map_err(|s| anyhow!("Invalid Worksheet id: {}: error: {}", self.location.sheet, s))?) .map_err(|s| {
anyhow!(
"Invalid Worksheet id: {}: error: {}",
self.location.sheet,
s
)
})?)
} }
pub(crate) fn get_sheet_name_by_idx(&self, idx: usize) -> Result<&str> { pub(crate) fn get_sheet_name_by_idx(&self, idx: usize) -> Result<&str> {
@ -636,7 +710,15 @@ impl Default for Book {
fn default() -> Self { fn default() -> Self {
let mut book = let mut book =
Book::new(UserModel::new_empty("default_name", "en", "America/New_York").unwrap()); Book::new(UserModel::new_empty("default_name", "en", "America/New_York").unwrap());
book.update_cell(&Address { sheet: 0, row: 1, col: 1 }, "").unwrap(); book.update_cell(
&Address {
sheet: 0,
row: 1,
col: 1,
},
"",
)
.unwrap();
book book
} }
} }

View File

@ -108,3 +108,21 @@ fn test_book_col_size() {
book.set_col_size(1, 20).expect("Failed to set column size"); book.set_col_size(1, 20).expect("Failed to set column size");
assert_eq!(20, book.get_col_size(1).expect("Failed to get column size")); assert_eq!(20, book.get_col_size(1).expect("Failed to get column size"));
} }
#[test]
fn test_book_get_exportable_rows() {
let mut book = Book::default();
book.update_cell(&Address { sheet: 0, row: 1, col: 3 }, "1-3")
.expect("failed to edit cell");
book.update_cell(&Address { sheet: 0, row: 3, col: 6 }, "3-6")
.expect("failed to edit cell");
let rows = book.get_export_rows().expect("Failed to get export rows");
assert_eq!(4, rows.len());
assert_eq!(rows, vec![
vec!["", "" , "", "", "", "", ""],
vec!["", "" , "", "1-3", "", "", ""],
vec!["", "" , "", "", "", "", ""],
vec!["", "" , "", "", "", "", "3-6"],
]);
}

View File

@ -15,6 +15,7 @@ pub enum Cmd<'a> {
SelectSheet(&'a str), SelectSheet(&'a str),
Edit(&'a str), Edit(&'a str),
Help(Option<&'a str>), Help(Option<&'a str>),
ExportCsv(&'a str),
Quit, Quit,
} }
@ -35,10 +36,14 @@ pub fn parse<'cmd, 'i: 'cmd>(input: &'i str) -> Result<Option<Cmd<'cmd>>, &'stat
if let Some(cmd) = try_consume_insert_row(cursor.clone())? { if let Some(cmd) = try_consume_insert_row(cursor.clone())? {
return Ok(Some(cmd)); return Ok(Some(cmd));
} }
//// try consume insert-col command. // try consume insert-col command.
if let Some(cmd) = try_consume_insert_column(cursor.clone())? { if let Some(cmd) = try_consume_insert_column(cursor.clone())? {
return Ok(Some(cmd)); return Ok(Some(cmd));
} }
// Try consume export
if let Some(cmd) = try_consume_export_csv(cursor.clone())? {
return Ok(Some(cmd));
}
// try consume edit command. // try consume edit command.
if let Some(cmd) = try_consume_edit(cursor.clone())? { if let Some(cmd) = try_consume_edit(cursor.clone())? {
return Ok(Some(cmd)); return Ok(Some(cmd));
@ -99,7 +104,7 @@ fn try_consume_write<'cmd, 'i: 'cmd>(
return Ok(None); return Ok(None);
} }
if input.remaining() > 0 && !is_ws(&mut input) { if input.remaining() > 0 && !is_ws(&mut input) {
return Err("Invalid command: Did you mean to type `write <arg>`?"); return Err("Invalid command: Did you mean to type `write <path>`?");
} }
let arg = input.span(0..).trim(); let arg = input.span(0..).trim();
return Ok(Some(Cmd::Write(if arg.is_empty() { return Ok(Some(Cmd::Write(if arg.is_empty() {
@ -109,6 +114,23 @@ fn try_consume_write<'cmd, 'i: 'cmd>(
}))); })));
} }
fn try_consume_export_csv<'cmd, 'i: 'cmd>(
mut input: StrCursor<'i>,
) -> Result<Option<Cmd<'cmd>>, &'static str> {
const LONG: &'static str = "export-csv";
if compare(input.clone(), LONG) {
input.seek(LONG.len());
} else {
return Ok(None);
}
if input.remaining() == 0 || !is_ws(&mut input) {
return Err("Invalid command: Did you mean to type `export <path>`?");
}
let arg = input.span(0..).trim();
return Ok(Some(Cmd::ExportCsv(arg)));
}
fn try_consume_new_sheet<'cmd, 'i: 'cmd>( fn try_consume_new_sheet<'cmd, 'i: 'cmd>(
mut input: StrCursor<'i>, mut input: StrCursor<'i>,
) -> Result<Option<Cmd<'cmd>>, &'static str> { ) -> Result<Option<Cmd<'cmd>>, &'static str> {

View File

@ -425,6 +425,10 @@ impl<'ws> Workspace<'ws> {
} }
Ok(None) Ok(None)
} }
Ok(Some(Cmd::ExportCsv(path))) => {
self.book.save_sheet_to_csv(self.book.location.sheet, path)?;
Ok(None)
}
Ok(Some(Cmd::InsertColumns(count))) => { Ok(Some(Cmd::InsertColumns(count))) => {
self.book.insert_columns(self.book.location.col, count)?; self.book.insert_columns(self.book.location.col, count)?;
self.book.evaluate(); self.book.evaluate();

View File

@ -217,6 +217,17 @@ fn test_cmd_new_sheet_with_name() {
assert_eq!(cmd, Cmd::NewSheet(Some("test"))); assert_eq!(cmd, Cmd::NewSheet(Some("test")));
} }
#[test]
fn test_cmd_export() {
let input = "export-csv test.csv";
let result = parse(input);
assert!(result.is_ok());
let output = result.unwrap();
assert!(output.is_some());
let cmd = output.unwrap();
assert_eq!(cmd, Cmd::Export("test.csv"));
}
#[test] #[test]
fn test_cmd_new_sheet_no_name() { fn test_cmd_new_sheet_no_name() {
let input = "new-sheet"; let input = "new-sheet";