diff --git a/CHANGELOG.md b/CHANGELOG.md index ea6b199b7..970f06db6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Linux browse export can write the current page as CSV, JSON, SQL INSERT, or Markdown. CSV opens an options dialog for headers, line endings, quote style, and a UTF-8 BOM. - **File > Close All Tabs**, **Close Other Tabs**, and **Close Tabs for Other Databases** close a group of tabs in one step instead of one at a time. Closing every tab leaves the window open on its empty state with the connection still live, and each closed tab stays in **Recently Closed**. None of the three has a shortcut out of the box; bind them in **Settings > Keyboard**. (#1972) - AI Explain, Optimize, and Fix Error now answer with a walkthrough in the chat panel: a before/after SQL diff you can switch between unified and split, numbered steps anchored to the lines they change, and a follow-up prompt on any step. Optimize and Fix add an Apply to Editor button that asks before replacing your query. (#1945) - Create a connection from a project folder. Pick one from the welcome screen or File > Open Project Folder..., and TablePro reads the database settings it finds in `.env` files, `wp-config.php`, `prisma/schema.prisma`, `config/database.yml`, `docker-compose.yml`, `application.properties`, `application.yml`, and `appsettings.json`. A project that uses more than one engine gets a row for each. Review what it found, pick one, and the connection form opens filled in. Nothing is saved or connected until you save it. (#1959) diff --git a/linux/ROADMAP.md b/linux/ROADMAP.md index 552e1a2e5..b378ae224 100644 --- a/linux/ROADMAP.md +++ b/linux/ROADMAP.md @@ -135,8 +135,8 @@ Exit criterion: a developer can demo the basic flows (connect, browse, edit, que ### Export / import (~1 week) -- [ ] Export current grid to CSV / JSON / SQL INSERT / Markdown -- [ ] Export with options: include headers, quote style, line endings, UTF-8 BOM toggle +- [x] Export current grid to CSV / JSON / SQL INSERT / Markdown +- [x] Export with options: include headers, quote style, line endings, UTF-8 BOM toggle - [ ] Import CSV → table (with column mapping dialog) - [ ] Run SQL file (load + execute via SQL editor) diff --git a/linux/crates/app/src/ui/app/browse.rs b/linux/crates/app/src/ui/app/browse.rs index d6362b69c..7177ec1dc 100644 --- a/linux/crates/app/src/ui/app/browse.rs +++ b/linux/crates/app/src/ui/app/browse.rs @@ -8,7 +8,19 @@ use uuid::Uuid; use crate::services::database_service; use crate::ui::browse_tab::BrowseTabInput; -use super::{App, AppMsg, ExportFormat, OpenMode, render_csv, render_json}; +use super::export::{ + CsvOptions, CsvQuoteStyle, ExportFormat, LineEnding, render_csv, render_json, render_markdown, render_sql_insert, +}; +use super::{App, AppMsg, OpenMode}; + +struct ExportRequest { + format: ExportFormat, + result: QueryResult, + driver_id: String, + schema: Option, + table: String, + csv_options: CsvOptions, +} impl App { /// Sidebar click — routes via OpenMode (smart switch / new tab). @@ -245,16 +257,158 @@ impl App { self.show_toast(&crate::tr!("Nothing to export")); return; }; - let result = { + let (result, driver_id) = { let tabs = self.workspace_tabs.borrow(); - tabs.get(&active_id) - .and_then(|t| t.browse_controller()) - .and_then(|c| c.model().snapshot()) + match tabs.get(&active_id).and_then(|t| t.browse_controller()) { + Some(controller) => { + let model = controller.model(); + (model.snapshot(), model.driver_id().to_string()) + } + None => (None, String::new()), + } }; let Some(result) = result else { self.show_toast(&crate::tr!("Nothing to export")); return; }; + if matches!(format, ExportFormat::Csv) { + self.present_csv_export_options(result, schema, table); + return; + } + self.run_export_file_dialog(ExportRequest { + format, + result, + driver_id, + schema, + table, + csv_options: CsvOptions::default(), + }); + } + + fn present_csv_export_options(&self, result: QueryResult, schema: Option, table: String) { + let dialog = adw::Dialog::builder() + .title(crate::tr!("Export as CSV")) + .content_width(420) + .build(); + + let header = adw::HeaderBar::new(); + let cancel = gtk::Button::builder().label(crate::tr!("Cancel")).build(); + cancel.add_css_class("flat"); + let export_btn = gtk::Button::builder().label(crate::tr!("Export…")).build(); + export_btn.add_css_class("suggested-action"); + header.pack_start(&cancel); + header.pack_end(&export_btn); + + let group = adw::PreferencesGroup::builder().title(crate::tr!("Options")).build(); + + let headers_row = adw::SwitchRow::builder() + .title(crate::tr!("Include headers")) + .subtitle(crate::tr!("Write column names as the first row")) + .active(true) + .build(); + + let bom_row = adw::SwitchRow::builder() + .title(crate::tr!("UTF-8 BOM")) + .subtitle(crate::tr!("Helps Excel recognize UTF-8 encoding")) + .active(false) + .build(); + + let endings = gtk::StringList::new(&[&crate::tr!("LF (Unix)"), &crate::tr!("CRLF (Windows)")]); + let ending_row = adw::ComboRow::builder() + .title(crate::tr!("Line endings")) + .model(&endings) + .selected(0) + .build(); + + let quotes = gtk::StringList::new(&[ + &crate::tr!("Minimal (only when needed)"), + &crate::tr!("Always quote fields"), + ]); + let quote_row = adw::ComboRow::builder() + .title(crate::tr!("Quote style")) + .model("es) + .selected(0) + .build(); + + group.add(&headers_row); + group.add(&bom_row); + group.add(&ending_row); + group.add("e_row); + + let content = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(12) + .margin_top(12) + .margin_bottom(18) + .margin_start(18) + .margin_end(18) + .build(); + content.append(&group); + + let toolbar = adw::ToolbarView::new(); + toolbar.add_top_bar(&header); + toolbar.set_content(Some(&content)); + dialog.set_child(Some(&toolbar)); + + let parent = self.window.clone(); + let toast_overlay = self.toast_overlay.clone(); + let dialog_close = dialog.clone(); + cancel.connect_clicked(move |_| { + dialog_close.close(); + }); + + let dialog_export = dialog.clone(); + let parent_for_save = parent.clone(); + export_btn.connect_clicked(move |_| { + let options = CsvOptions { + include_headers: headers_row.is_active(), + utf8_bom: bom_row.is_active(), + line_ending: if ending_row.selected() == 1 { + LineEnding::Crlf + } else { + LineEnding::Lf + }, + quote_style: if quote_row.selected() == 1 { + CsvQuoteStyle::Always + } else { + CsvQuoteStyle::Minimal + }, + }; + dialog_export.close(); + App::run_export_file_dialog_static( + &parent_for_save, + &toast_overlay, + ExportRequest { + format: ExportFormat::Csv, + result: result.clone(), + driver_id: String::new(), + schema: schema.clone(), + table: table.clone(), + csv_options: options, + }, + ); + }); + + dialog.present(Some(&parent)); + } + + fn run_export_file_dialog(&self, request: ExportRequest) { + Self::run_export_file_dialog_static(&self.window, &self.toast_overlay, request); + } + + fn run_export_file_dialog_static( + parent: &adw::ApplicationWindow, + toast_overlay: &adw::ToastOverlay, + request: ExportRequest, + ) { + let ExportRequest { + format, + result, + driver_id, + schema, + table, + csv_options, + } = request; let table_label = match &schema { Some(s) => format!("{s}.{table}"), None => table.clone(), @@ -262,6 +416,8 @@ impl App { let suggested = match format { ExportFormat::Csv => format!("{table_label}.csv"), ExportFormat::Json => format!("{table_label}.json"), + ExportFormat::SqlInsert => format!("{table_label}.sql"), + ExportFormat::Markdown => format!("{table_label}.md"), }; let filter = gtk::FileFilter::new(); match format { @@ -275,6 +431,16 @@ impl App { filter.add_mime_type("application/json"); filter.add_suffix("json"); } + ExportFormat::SqlInsert => { + filter.set_name(Some(&crate::tr!("SQL files"))); + filter.add_mime_type("application/sql"); + filter.add_suffix("sql"); + } + ExportFormat::Markdown => { + filter.set_name(Some(&crate::tr!("Markdown files"))); + filter.add_mime_type("text/markdown"); + filter.add_suffix("md"); + } }; let filters = gio::ListStore::new::(); filters.append(&filter); @@ -282,30 +448,31 @@ impl App { .title(match format { ExportFormat::Csv => crate::tr!("Export as CSV"), ExportFormat::Json => crate::tr!("Export as JSON"), + ExportFormat::SqlInsert => crate::tr!("Export as SQL INSERT"), + ExportFormat::Markdown => crate::tr!("Export as Markdown"), }) .modal(true) .initial_name(&suggested) .default_filter(&filter) .filters(&filters) .build(); - let parent = self.window.clone(); + let parent = parent.clone(); let parent_for_alert = parent.clone(); - let toast_overlay = self.toast_overlay.clone(); + let toast_overlay = toast_overlay.clone(); + let schema_for_sql = schema.clone(); dialog.save(Some(&parent), gtk::gio::Cancellable::NONE, move |outcome| { let Ok(file) = outcome else { return }; let Some(path) = file.path() else { return }; let bytes = match format { - ExportFormat::Csv => render_csv(&result), + ExportFormat::Csv => render_csv(&result, csv_options), ExportFormat::Json => render_json(&result), + ExportFormat::SqlInsert => render_sql_insert(&result, &driver_id, schema_for_sql.as_deref(), &table), + ExportFormat::Markdown => render_markdown(&result), }; match std::fs::write(&path, bytes) { Ok(()) => toast_overlay.add_toast(relm4::adw::Toast::new( &crate::tr!("Exported to {path}").replace("{path}", &path.display().to_string()), )), - // Failures use AdwAlertDialog instead of a transient - // toast — the user needs time to read the IO error - // (and probably copy the path to retry elsewhere). - // Matches the Save / Drop error-handling pattern. Err(e) => { let alert = adw::AlertDialog::new( Some(&crate::tr!("Couldn't export")), diff --git a/linux/crates/app/src/ui/app/export.rs b/linux/crates/app/src/ui/app/export.rs new file mode 100644 index 000000000..a73a89abd --- /dev/null +++ b/linux/crates/app/src/ui/app/export.rs @@ -0,0 +1,312 @@ +//! Pure grid-export renderers (CSV / JSON / SQL INSERT / Markdown). +//! +//! Kept free of GTK so unit tests can cover escaping, options, and +//! dialect-aware quoting without spinning up a window. + +use tablepro_core::sql_dialect::quote_ident; +use tablepro_core::{QueryResult, Value}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExportFormat { + Csv, + Json, + SqlInsert, + Markdown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LineEnding { + Lf, + Crlf, +} + +impl LineEnding { + fn as_str(self) -> &'static str { + match self { + Self::Lf => "\n", + Self::Crlf => "\r\n", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CsvQuoteStyle { + Minimal, + Always, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CsvOptions { + pub include_headers: bool, + pub line_ending: LineEnding, + pub utf8_bom: bool, + pub quote_style: CsvQuoteStyle, +} + +impl Default for CsvOptions { + fn default() -> Self { + Self { + include_headers: true, + line_ending: LineEnding::Lf, + utf8_bom: false, + quote_style: CsvQuoteStyle::Minimal, + } + } +} + +pub fn render_csv(result: &QueryResult, options: CsvOptions) -> Vec { + let ending = options.line_ending.as_str(); + let mut out = String::new(); + if options.include_headers { + let cols: Vec = result + .columns + .iter() + .map(|c| csv_escape(&c.name, options.quote_style)) + .collect(); + out.push_str(&cols.join(",")); + out.push_str(ending); + } + for row in &result.rows { + let cells: Vec = row + .iter() + .map(|v| csv_escape(&value_to_export_text(v), options.quote_style)) + .collect(); + out.push_str(&cells.join(",")); + out.push_str(ending); + } + let mut bytes = Vec::new(); + if options.utf8_bom { + bytes.extend_from_slice(&[0xEF, 0xBB, 0xBF]); + } + bytes.extend_from_slice(out.as_bytes()); + bytes +} + +pub fn render_json(result: &QueryResult) -> Vec { + let cols: Vec<&str> = result.columns.iter().map(|c| c.name.as_str()).collect(); + let rows: Vec = result + .rows + .iter() + .map(|row| { + let mut obj = serde_json::Map::new(); + for (i, col) in cols.iter().enumerate() { + let v = row.get(i).cloned().unwrap_or(Value::Null); + obj.insert((*col).to_string(), value_to_json(&v)); + } + serde_json::Value::Object(obj) + }) + .collect(); + serde_json::to_vec_pretty(&rows).unwrap_or_default() +} + +pub fn render_sql_insert(result: &QueryResult, driver_id: &str, schema: Option<&str>, table: &str) -> Vec { + let table_sql = match schema { + Some(s) => format!("{}.{}", quote_ident(driver_id, s), quote_ident(driver_id, table)), + None => quote_ident(driver_id, table), + }; + let cols: Vec = result.columns.iter().map(|c| quote_ident(driver_id, &c.name)).collect(); + let col_list = cols.join(", "); + let mut out = String::new(); + for row in &result.rows { + let values: Vec = row.iter().map(format_sql_literal).collect(); + out.push_str(&format!( + "INSERT INTO {table_sql} ({col_list}) VALUES ({});\n", + values.join(", "), + )); + } + out.into_bytes() +} + +pub fn render_markdown(result: &QueryResult) -> Vec { + let cols: Vec<&str> = result.columns.iter().map(|c| c.name.as_str()).collect(); + let mut out = String::new(); + out.push('|'); + for col in &cols { + out.push(' '); + out.push_str(&md_escape(col)); + out.push_str(" |"); + } + out.push('\n'); + out.push('|'); + for _ in &cols { + out.push_str(" --- |"); + } + out.push('\n'); + for row in &result.rows { + out.push('|'); + for i in 0..cols.len() { + let text = row.get(i).map(value_to_export_text).unwrap_or_default(); + out.push(' '); + out.push_str(&md_escape(&text)); + out.push_str(" |"); + } + out.push('\n'); + } + out.into_bytes() +} + +/// Render a `Value` as a SQL literal for clipboard / file export. +pub fn format_sql_literal(v: &Value) -> String { + match v { + Value::Null => "NULL".into(), + Value::Bool(b) => b.to_string(), + Value::Int(i) => i.to_string(), + Value::Float(f) => f.to_string(), + Value::Decimal(d) => d.to_string(), + Value::Text(s) => format!("'{}'", s.replace('\'', "''")), + Value::Bytes(_) => "/* bytes omitted */ NULL".into(), + Value::Date(d) => format!("'{}'", d.format("%Y-%m-%d")), + Value::Time(t) => format!("'{}'", t.format("%H:%M:%S")), + Value::DateTime(dt) => format!("'{}'", dt.format("%Y-%m-%d %H:%M:%S")), + Value::TimestampTz(ts) => format!("'{}'", ts.to_rfc3339()), + Value::Uuid(u) => format!("'{u}'"), + Value::Json(j) => format!("'{}'", j.to_string().replace('\'', "''")), + } +} + +fn csv_escape(s: &str, style: CsvQuoteStyle) -> String { + let needs_quotes = matches!(style, CsvQuoteStyle::Always) + || s.contains(',') + || s.contains('"') + || s.contains('\n') + || s.contains('\r'); + if needs_quotes { + format!("\"{}\"", s.replace('"', "\"\"")) + } else { + s.to_string() + } +} + +fn md_escape(s: &str) -> String { + s.replace('|', "\\|").replace('\n', " ").replace('\r', "") +} + +fn value_to_export_text(v: &Value) -> String { + match v { + Value::Null => String::new(), + Value::Bool(b) => b.to_string(), + Value::Int(i) => i.to_string(), + Value::Float(f) => f.to_string(), + Value::Text(s) => s.clone(), + Value::Bytes(b) => format!("<{} bytes>", b.len()), + Value::Date(d) => d.format("%Y-%m-%d").to_string(), + Value::Time(t) => t.format("%H:%M:%S").to_string(), + Value::DateTime(dt) => dt.format("%Y-%m-%d %H:%M:%S").to_string(), + Value::TimestampTz(ts) => ts.to_rfc3339(), + Value::Decimal(d) => d.to_string(), + Value::Uuid(u) => u.to_string(), + Value::Json(j) => j.to_string(), + } +} + +fn value_to_json(v: &Value) -> serde_json::Value { + use serde_json::Value as J; + match v { + Value::Null => J::Null, + Value::Bool(b) => J::Bool(*b), + Value::Int(i) => J::from(*i), + Value::Float(f) => J::from(*f), + Value::Text(s) => J::String(s.clone()), + Value::Bytes(b) => J::String(format!("<{} bytes>", b.len())), + Value::Date(d) => J::String(d.to_string()), + Value::Time(t) => J::String(t.to_string()), + Value::DateTime(dt) => J::String(dt.to_string()), + Value::TimestampTz(ts) => J::String(ts.to_rfc3339()), + Value::Decimal(d) => J::String(d.to_string()), + Value::Uuid(u) => J::String(u.to_string()), + Value::Json(j) => j.clone(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tablepro_core::ColumnInfo; + + fn col(name: &str) -> ColumnInfo { + ColumnInfo { + name: name.into(), + data_type: "text".into(), + nullable: true, + primary_key: false, + is_auto_increment: false, + default_value: None, + is_generated: false, + } + } + + fn sample_result() -> QueryResult { + QueryResult { + columns: vec![col("id"), col("name")], + rows: vec![ + vec![Value::Int(1), Value::Text("alice".into())], + vec![Value::Int(2), Value::Text("a,b\"c".into())], + vec![Value::Int(3), Value::Null], + ], + truncated: false, + } + } + + #[test] + fn csv_default_includes_header_and_escapes() { + let bytes = render_csv(&sample_result(), CsvOptions::default()); + let text = String::from_utf8(bytes).unwrap(); + assert_eq!(text, "id,name\n1,alice\n2,\"a,b\"\"c\"\n3,\n"); + } + + #[test] + fn csv_options_bom_crlf_no_header_always_quote() { + let options = CsvOptions { + include_headers: false, + line_ending: LineEnding::Crlf, + utf8_bom: true, + quote_style: CsvQuoteStyle::Always, + }; + let bytes = render_csv(&sample_result(), options); + assert_eq!(&bytes[..3], &[0xEF, 0xBB, 0xBF]); + let text = String::from_utf8(bytes[3..].to_vec()).unwrap(); + assert_eq!(text, "\"1\",\"alice\"\r\n\"2\",\"a,b\"\"c\"\r\n\"3\",\"\"\r\n"); + } + + #[test] + fn json_exports_objects() { + let bytes = render_json(&sample_result()); + let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(v.as_array().unwrap().len(), 3); + assert_eq!(v[2]["name"], serde_json::Value::Null); + } + + #[test] + fn sql_insert_quotes_postgres_idents_and_nulls() { + let bytes = render_sql_insert(&sample_result(), "postgres", Some("public"), "users"); + let text = String::from_utf8(bytes).unwrap(); + assert!(text.contains("INSERT INTO \"public\".\"users\" (\"id\", \"name\") VALUES (1, 'alice');")); + assert!(text.contains("VALUES (3, NULL);")); + assert!(text.contains("'a,b''c'") || text.contains("a,b\"c")); + assert!(text.contains("VALUES (2, 'a,b\"c');")); + } + + #[test] + fn sql_insert_mysql_backticks() { + let bytes = render_sql_insert(&sample_result(), "mysql", None, "users"); + let text = String::from_utf8(bytes).unwrap(); + assert!(text.starts_with("INSERT INTO `users` (`id`, `name`) VALUES (1, 'alice');")); + } + + #[test] + fn markdown_table_escapes_pipes() { + let mut result = sample_result(); + result.rows[0][1] = Value::Text("a|b".into()); + let text = String::from_utf8(render_markdown(&result)).unwrap(); + assert!(text.starts_with("| id | name |\n| --- | --- |\n")); + assert!(text.contains("| 1 | a\\|b |")); + assert!(text.contains("| 3 | |")); + } + + #[test] + fn format_sql_literal_escapes_quotes() { + assert_eq!(format_sql_literal(&Value::Text("O'Reilly".into())), "'O''Reilly'"); + assert_eq!(format_sql_literal(&Value::Null), "NULL"); + assert_eq!(format_sql_literal(&Value::Bool(true)), "true"); + } +} diff --git a/linux/crates/app/src/ui/app/mod.rs b/linux/crates/app/src/ui/app/mod.rs index 1e5d1175b..1e21ccde5 100644 --- a/linux/crates/app/src/ui/app/mod.rs +++ b/linux/crates/app/src/ui/app/mod.rs @@ -1,5 +1,6 @@ mod browse; mod connection; +mod export; mod row_ops; mod status_pages; mod structure; @@ -322,6 +323,8 @@ pub enum AppMsg { RowCountLoaded(Uuid, u64), ExportCsv, ExportJson, + ExportSqlInsert, + ExportMarkdown, CopyToClipboard(String), CopyRowAsInsert { tab_id: Uuid, @@ -510,12 +513,6 @@ pub enum AppMsg { ShowFilterDialog, } -#[derive(Debug, Clone, Copy)] -enum ExportFormat { - Csv, - Json, -} - /// Determines which icon and styling adw::StatusPage uses. /// /// Replaces the previous title-string sniffing in `set_status_page`, @@ -1432,8 +1429,10 @@ impl SimpleComponent for App { AppMsg::ShowShortcuts => self.on_show_shortcuts(), AppMsg::ShowAbout => self.on_show_about(), AppMsg::ShowPreferences => super::preferences::present(&self.window), - AppMsg::ExportCsv => self.on_export(ExportFormat::Csv), - AppMsg::ExportJson => self.on_export(ExportFormat::Json), + AppMsg::ExportCsv => self.on_export(export::ExportFormat::Csv), + AppMsg::ExportJson => self.on_export(export::ExportFormat::Json), + AppMsg::ExportSqlInsert => self.on_export(export::ExportFormat::SqlInsert), + AppMsg::ExportMarkdown => self.on_export(export::ExportFormat::Markdown), AppMsg::CopyToClipboard(text) => self.on_copy_to_clipboard(text), AppMsg::CopyRowAsInsert { tab_id, row_position } => self.on_copy_row_as_insert(tab_id, row_position), AppMsg::DeleteConnection(id) => self.on_delete_connection(id, sender), @@ -1444,66 +1443,6 @@ impl SimpleComponent for App { } } -fn render_csv(result: &QueryResult) -> Vec { - let mut out = String::new(); - let cols: Vec<&str> = result.columns.iter().map(|c| c.name.as_str()).collect(); - out.push_str(&cols.iter().map(|c| csv_escape(c)).collect::>().join(",")); - out.push('\n'); - for row in &result.rows { - let cells: Vec = row - .iter() - .map(|v| csv_escape(&super::grid::value_to_display_text(v))) - .collect(); - out.push_str(&cells.join(",")); - out.push('\n'); - } - out.into_bytes() -} - -fn csv_escape(s: &str) -> String { - if s.contains(',') || s.contains('"') || s.contains('\n') || s.contains('\r') { - format!("\"{}\"", s.replace('"', "\"\"")) - } else { - s.to_string() - } -} - -fn render_json(result: &QueryResult) -> Vec { - let cols: Vec<&str> = result.columns.iter().map(|c| c.name.as_str()).collect(); - let rows: Vec = result - .rows - .iter() - .map(|row| { - let mut obj = serde_json::Map::new(); - for (i, col) in cols.iter().enumerate() { - let v = row.get(i).cloned().unwrap_or(Value::Null); - obj.insert((*col).to_string(), value_to_json(&v)); - } - serde_json::Value::Object(obj) - }) - .collect(); - serde_json::to_vec_pretty(&rows).unwrap_or_default() -} - -fn value_to_json(v: &Value) -> serde_json::Value { - use serde_json::Value as J; - match v { - Value::Null => J::Null, - Value::Bool(b) => J::Bool(*b), - Value::Int(i) => J::from(*i), - Value::Float(f) => J::from(*f), - Value::Text(s) => J::String(s.clone()), - Value::Bytes(b) => J::String(format!("<{} bytes>", b.len())), - Value::Date(d) => J::String(d.to_string()), - Value::Time(t) => J::String(t.to_string()), - Value::DateTime(dt) => J::String(dt.to_string()), - Value::TimestampTz(ts) => J::String(ts.to_rfc3339()), - Value::Decimal(d) => J::String(d.to_string()), - Value::Uuid(u) => J::String(u.to_string()), - Value::Json(j) => j.clone(), - } -} - fn qualified_label(schema: Option<&str>, table: &str) -> String { match schema { Some(s) => format!("{s}.{table}"), @@ -1563,6 +1502,8 @@ fn install_window_actions(window: &adw::ApplicationWindow, sender: ComponentSend input_action!("refresh-page", AppMsg::RefreshPage), input_action!("export-csv", AppMsg::ExportCsv), input_action!("export-json", AppMsg::ExportJson), + input_action!("export-sql", AppMsg::ExportSqlInsert), + input_action!("export-markdown", AppMsg::ExportMarkdown), input_action!("save-changes", AppMsg::SaveActiveBrowseTab), input_action!("undo-change", AppMsg::UndoActiveBrowseTab), input_action!("redo-change", AppMsg::RedoActiveBrowseTab), diff --git a/linux/crates/app/src/ui/app/row_ops.rs b/linux/crates/app/src/ui/app/row_ops.rs index 5c53ea0b9..2562eb898 100644 --- a/linux/crates/app/src/ui/app/row_ops.rs +++ b/linux/crates/app/src/ui/app/row_ops.rs @@ -108,7 +108,7 @@ impl App { .iter() .map(|c| tablepro_core::sql_dialect::quote_ident(&driver_id, &c.name)) .collect(); - let values: Vec = row.iter().map(format_sql_literal).collect(); + let values: Vec = row.iter().map(super::export::format_sql_literal).collect(); let sql = format!( "INSERT INTO {} ({}) VALUES ({});", tablepro_core::sql_dialect::quote_ident(&driver_id, &table), @@ -161,27 +161,6 @@ fn compute_concurrency_warning(statements: &[(String, Vec)], affected: &[ ) } -/// Render a `Value` as a SQL literal — used by the "Copy row as -/// INSERT" clipboard helper to produce a self-contained statement -/// that round-trips through any SQL client. -fn format_sql_literal(v: &Value) -> String { - match v { - Value::Null => "NULL".into(), - Value::Bool(b) => b.to_string(), - Value::Int(i) => i.to_string(), - Value::Float(f) => f.to_string(), - Value::Decimal(d) => d.to_string(), - Value::Text(s) => format!("'{}'", s.replace('\'', "''")), - Value::Bytes(_) => "/* bytes omitted */ NULL".into(), - Value::Date(d) => format!("'{}'", d.format("%Y-%m-%d")), - Value::Time(t) => format!("'{}'", t.format("%H:%M:%S")), - Value::DateTime(dt) => format!("'{}'", dt.format("%Y-%m-%d %H:%M:%S")), - Value::TimestampTz(ts) => format!("'{}'", ts.to_rfc3339()), - Value::Uuid(u) => format!("'{u}'"), - Value::Json(j) => format!("'{}'", j.to_string().replace('\'', "''")), - } -} - #[cfg(test)] mod tests { use super::compute_concurrency_warning; diff --git a/linux/crates/app/src/ui/browse_tab.rs b/linux/crates/app/src/ui/browse_tab.rs index b29613dc1..c5d40f706 100644 --- a/linux/crates/app/src/ui/browse_tab.rs +++ b/linux/crates/app/src/ui/browse_tab.rs @@ -458,12 +458,14 @@ impl BrowseTab { // background, and high-contrast theming come for free. let paginator_bar = gtk::ActionBar::new(); - // Export menu uses win.export-csv / win.export-json (App-level - // actions); they read the active tab's snapshot so the buttons - // implicitly target this tab when this tab is active. + // Export menu uses win.export-* (App-level actions); they read + // the active tab's snapshot so the buttons implicitly target + // this tab when this tab is active. let export_menu = gtk::gio::Menu::new(); export_menu.append(Some(&crate::tr!("Export as CSV…")), Some("win.export-csv")); export_menu.append(Some(&crate::tr!("Export as JSON…")), Some("win.export-json")); + export_menu.append(Some(&crate::tr!("Export as SQL INSERT…")), Some("win.export-sql")); + export_menu.append(Some(&crate::tr!("Export as Markdown…")), Some("win.export-markdown")); let export_button = gtk::MenuButton::builder() .icon_name("document-save-symbolic") .tooltip_text(crate::tr!("Export results"))