From 473ba9c66512b4308573a919b995b3a87ebcad4e Mon Sep 17 00:00:00 2001 From: Jeremy Wall Date: Tue, 4 Mar 2025 18:41:29 -0500 Subject: [PATCH 1/4] wip: method to get export rows for a given sheet --- src/book/mod.rs | 71 ++++++++++++++++++++++++++++++++++++++++++++---- src/book/test.rs | 18 ++++++++++++ 2 files changed, 84 insertions(+), 5 deletions(-) diff --git a/src/book/mod.rs b/src/book/mod.rs index 89ef8c0..4c477ea 100644 --- a/src/book/mod.rs +++ b/src/book/mod.rs @@ -23,7 +23,6 @@ pub(crate) const COL_PIXELS: f64 = 5.0; pub(crate) const LAST_COLUMN: i32 = 16_384; pub(crate) const LAST_ROW: i32 = 1_048_576; - #[derive(Debug, Clone)] pub struct AddressRange<'book> { pub start: &'book Address, @@ -37,7 +36,11 @@ impl<'book> AddressRange<'book> { for ri in row_range.iter() { let mut row = Vec::with_capacity(col_range.len()); 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); } @@ -49,7 +52,11 @@ impl<'book> AddressRange<'book> { let mut rows = Vec::with_capacity(row_range.len() * col_range.len()); for ri in row_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 @@ -112,6 +119,46 @@ impl Book { )?)) } + pub fn get_export_rows(&self) -> Result>> { + let sheet = self.location.sheet; + Ok(self.export_rows_for_sheet(sheet)?) + } + + pub fn export_rows_for_sheet(&self, sheet: u32) -> Result>, 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. /// This can be an expensive operation. pub fn evaluate(&mut self) { @@ -604,7 +651,13 @@ impl Book { .get_model() .workbook .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> { @@ -636,7 +689,15 @@ impl Default for Book { fn default() -> Self { let mut book = 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 } } diff --git a/src/book/test.rs b/src/book/test.rs index e3dfaf7..6ece6b7 100644 --- a/src/book/test.rs +++ b/src/book/test.rs @@ -108,3 +108,21 @@ fn test_book_col_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")); } + +#[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"], + ]); +} From 8cd93cb6b05505da387c1bf72fbbc22c4bed556d Mon Sep 17 00:00:00 2001 From: Jeremy Wall Date: Tue, 4 Mar 2025 19:23:45 -0500 Subject: [PATCH 2/4] wip: feat: method to save sheet rows to csv file --- Cargo.lock | 16 +++------------- Cargo.toml | 2 +- src/book/mod.rs | 28 ++++++++++++++++++++++++---- 3 files changed, 28 insertions(+), 18 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c088c03..69acda4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -482,9 +482,9 @@ dependencies = [ [[package]] name = "csv" -version = "1.3.0" +version = "1.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac574ff4d437a7b5ad237ef331c17ccca63c46479e5b5453eb8e10bb99a759fe" +checksum = "acdc4883a9c96732e4733212c01447ebd805833b7275a73ca3ee080fd77afdaf" dependencies = [ "csv-core", "itoa", @@ -501,16 +501,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "csvx" -version = "0.1.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92081efd8b1d03f5a1bf242876cfdd8fa2bf9fe521ddb2d31f8747dfa2dd2cb7" -dependencies = [ - "csv", - "thiserror", -] - [[package]] name = "deranged" version = "0.3.11" @@ -1507,7 +1497,7 @@ dependencies = [ "clap", "colorsys", "crossterm", - "csvx", + "csv", "futures", "ironcalc", "ratatui", diff --git a/Cargo.toml b/Cargo.toml index fb9fea2..360d494 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,6 @@ edition = "2021" anyhow = { version = "1.0.91", features = ["backtrace"] } clap = { version = "4.5.20", features = ["derive"] } 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 ironcalc = { git = "https://github.com/ironcalc/IronCalc" } 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" colorsys = "0.6.7" tui-markdown = { version = "0.3.1", features = [] } +csv = "1.3.1" diff --git a/src/book/mod.rs b/src/book/mod.rs index 4c477ea..70bae2d 100644 --- a/src/book/mod.rs +++ b/src/book/mod.rs @@ -119,12 +119,23 @@ impl Book { )?)) } - pub fn get_export_rows(&self) -> Result>> { - let sheet = self.location.sheet; - Ok(self.export_rows_for_sheet(sheet)?) + pub fn csv_for_sheet(&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 export_rows_for_sheet(&self, sheet: u32) -> Result>, anyhow::Error> { + pub fn get_export_rows(&self) -> Result>> { + let sheet = self.location.sheet; + Ok(self.get_export_rows_for_sheet(sheet)?) + } + + pub fn get_export_rows_for_sheet(&self, sheet: u32) -> Result>, anyhow::Error> { let worksheet = self .model .get_model() @@ -170,6 +181,15 @@ impl Book { 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. pub fn save_to_xlsx(&mut self, path: &str) -> Result<()> { // TODO(zaphar): Currently overwrites. Should we prompt in this case? From 4dd0365bdda019809c438c1136f718cddc244e7b Mon Sep 17 00:00:00 2001 From: Jeremy Wall Date: Tue, 4 Mar 2025 19:33:33 -0500 Subject: [PATCH 3/4] wip: cmd: Add a command for epxorting csv --- src/ui/cmd.rs | 30 ++++++++++++++++++++++++++++-- src/ui/mod.rs | 4 ++++ src/ui/test.rs | 11 +++++++++++ 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/src/ui/cmd.rs b/src/ui/cmd.rs index fed68d1..e5c7103 100644 --- a/src/ui/cmd.rs +++ b/src/ui/cmd.rs @@ -15,6 +15,7 @@ pub enum Cmd<'a> { SelectSheet(&'a str), Edit(&'a str), Help(Option<&'a str>), + Export(&'a str), Quit, } @@ -35,10 +36,14 @@ pub fn parse<'cmd, 'i: 'cmd>(input: &'i str) -> Result>, &'stat if let Some(cmd) = try_consume_insert_row(cursor.clone())? { return Ok(Some(cmd)); } - //// try consume insert-col command. + // try consume insert-col command. if let Some(cmd) = try_consume_insert_column(cursor.clone())? { return Ok(Some(cmd)); } + // Try consume export + if let Some(cmd) = try_consume_export(cursor.clone())? { + return Ok(Some(cmd)); + } // try consume edit command. if let Some(cmd) = try_consume_edit(cursor.clone())? { return Ok(Some(cmd)); @@ -99,7 +104,7 @@ fn try_consume_write<'cmd, 'i: 'cmd>( return Ok(None); } if input.remaining() > 0 && !is_ws(&mut input) { - return Err("Invalid command: Did you mean to type `write `?"); + return Err("Invalid command: Did you mean to type `write `?"); } let arg = input.span(0..).trim(); return Ok(Some(Cmd::Write(if arg.is_empty() { @@ -109,6 +114,27 @@ fn try_consume_write<'cmd, 'i: 'cmd>( }))); } +fn try_consume_export<'cmd, 'i: 'cmd>( + mut input: StrCursor<'i>, +) -> Result>, &'static str> { + const SHORT: &'static str = "ex"; + const LONG: &'static str = "export"; + + if compare(input.clone(), LONG) { + input.seek(LONG.len()); + } else if compare(input.clone(), SHORT) { + input.seek(SHORT.len()); + // Should we check for whitespace? + } else { + return Ok(None); + } + if input.remaining() == 0 || !is_ws(&mut input) { + return Err("Invalid command: Did you mean to type `export `?"); + } + let arg = input.span(0..).trim(); + return Ok(Some(Cmd::Export(arg))); +} + fn try_consume_new_sheet<'cmd, 'i: 'cmd>( mut input: StrCursor<'i>, ) -> Result>, &'static str> { diff --git a/src/ui/mod.rs b/src/ui/mod.rs index e18da11..ccfda4a 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -425,6 +425,10 @@ impl<'ws> Workspace<'ws> { } Ok(None) } + Ok(Some(Cmd::Export(path))) => { + self.book.save_sheet_to_csv(self.book.location.sheet, path)?; + Ok(None) + } Ok(Some(Cmd::InsertColumns(count))) => { self.book.insert_columns(self.book.location.col, count)?; self.book.evaluate(); diff --git a/src/ui/test.rs b/src/ui/test.rs index b45166a..b86e524 100644 --- a/src/ui/test.rs +++ b/src/ui/test.rs @@ -217,6 +217,17 @@ fn test_cmd_new_sheet_with_name() { assert_eq!(cmd, Cmd::NewSheet(Some("test"))); } +#[test] +fn test_cmd_export() { + let input = "export 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] fn test_cmd_new_sheet_no_name() { let input = "new-sheet"; From 7e4109e734f3b25eccfda6ea200aac8128a02d18 Mon Sep 17 00:00:00 2001 From: Jeremy Wall Date: Tue, 4 Mar 2025 21:04:29 -0500 Subject: [PATCH 4/4] wip: update the command syntax to be more future proof --- docs/command.md | 1 + src/book/mod.rs | 3 ++- src/ui/cmd.rs | 14 +++++--------- src/ui/mod.rs | 2 +- src/ui/test.rs | 2 +- 5 files changed, 10 insertions(+), 12 deletions(-) diff --git a/docs/command.md b/docs/command.md index 921c735..a8a5058 100644 --- a/docs/command.md +++ b/docs/command.md @@ -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. * `select-sheet ` Select a sheet by name. * `edit ` Edit a new spreadsheet at the current path. `e` is a shorthand alias for this command. +* `export-csv ` Export the current sheet to a csv file at ``. * `quit` Quits the application. `q` is a shorthand alias for this command.