Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion linux/crates/app/src/ui/browse_tab/clipboard_handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,13 @@ impl BrowseModel {
/// write them back as `INSERT` statements.
fn sql_target(&self) -> crate::ui::export_dialog::SqlTarget {
crate::ui::export_dialog::SqlTarget {
table: self.table.clone(),
table: self.table_ref(),
generated: self
.current_columns
.iter()
.filter(|column| column.is_generated)
.map(|column| column.name.clone())
.collect(),
}
}

Expand Down
4 changes: 4 additions & 0 deletions linux/crates/app/src/ui/clipboard/clipboard_copy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,10 @@ fn copy_error_text(error: &CopyError) -> String {
"Couldn't copy: the column “{column}” holds a value TablePro couldn't read",
&[("column", column)],
),
CopyError::Encode(EncodeError::NotXmlText { column }) => gettext_f(
"Couldn't copy: the column “{column}” holds a control character",
&[("column", column)],
),
CopyError::Insert(RenderInsertError::Literal(_)) => {
gettext("This row has a value that can't be written as SQL")
}
Expand Down
21 changes: 13 additions & 8 deletions linux/crates/app/src/ui/clipboard/copy_renderer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,18 +115,12 @@ fn render_rows(
CopyKind::Rows => encode(TsvEncoder::new(columns, false), rows, ClipboardFormat::Tsv),
CopyKind::RowsWithHeaders => encode(TsvEncoder::new(columns, true), rows, ClipboardFormat::Tsv),
CopyKind::Csv => encode(
CsvEncoder::new(
columns,
CsvOptions {
header_row: false,
..CsvOptions::default()
},
),
CsvEncoder::new(columns, clipboard_csv(false)),
rows,
ClipboardFormat::Csv,
),
CopyKind::CsvWithHeaders => encode(
CsvEncoder::new(columns, CsvOptions::default()),
CsvEncoder::new(columns, clipboard_csv(true)),
rows,
ClipboardFormat::Csv,
),
Expand All @@ -135,6 +129,17 @@ fn render_rows(
}
}

/// CSV as the clipboard carries it. Copied text goes as the user sees
/// it: the apostrophe an export puts before `-` or `@alice` would be
/// pasted into every other place the text goes too.
fn clipboard_csv(header_row: bool) -> CsvOptions {
CsvOptions {
header_row,
sanitize_formulas: false,
..CsvOptions::default()
}
}

fn render_in_clause(dialect: &dyn SqlDialect, values: &InClauseRows) -> Result<ClipboardPayload, CopyError> {
let InClauseRows { columns, rows, column } = values;
let column_type = columns
Expand Down
26 changes: 22 additions & 4 deletions linux/crates/app/src/ui/clipboard/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,10 +91,17 @@ fn copy_renderer_csv_matches_render_csv() {
CopyKind::Csv,
CsvOptions {
header_row: false,
sanitize_formulas: false,
..CsvOptions::default()
},
),
(
CopyKind::CsvWithHeaders,
CsvOptions {
sanitize_formulas: false,
..CsvOptions::default()
},
),
(CopyKind::CsvWithHeaders, CsvOptions::default()),
];
for (kind, options) in cases {
let payload = render(rows_request(&rows, kind)).expect("the copy");
Expand All @@ -106,6 +113,17 @@ fn copy_renderer_csv_matches_render_csv() {
}
}

#[test]
fn a_copy_keeps_text_that_starts_like_a_formula_as_it_is() {
let rows = vec![vec![Value::Text("-".into()), Value::Text("@alice".into())]];

let tsv = render(rows_request(&rows, CopyKind::Rows)).expect("the copy");
let csv = render(rows_request(&rows, CopyKind::Csv)).expect("the copy");

assert_eq!(tsv.text, "-\t@alice\n");
assert_eq!(csv.text, "-,@alice\n");
}

#[test]
fn copy_insert_uses_visible_columns_only() {
let request = CopyRequest::Insert(InsertCopy {
Expand Down Expand Up @@ -196,7 +214,7 @@ fn a_slower_earlier_copy_gives_way_to_a_later_one() {
finish_on_main(WAIT, fast)
.expect("the later copy to end")
.expect("the later copy");
assert_eq!(local_text(&clipboard).as_deref(), Some("-5\t'=cmd\n"));
assert_eq!(local_text(&clipboard).as_deref(), Some("-5\t=cmd\n"));
let landed = clipboard.content();
finish_on_main(WAIT, slow)
.expect("the earlier copy to end")
Expand Down Expand Up @@ -295,7 +313,7 @@ fn copy_as_csv_offers_text_csv_and_plain_text() {
assert!(formats.contains_type(String::static_type()), "{}", formats.to_str());
assert_eq!(
local_text(&clipboard).as_deref(),
Some("\"id\",\"name\"\n\"-5\",\"'=cmd\"\n\"7\",\"Ada, \"\"the first\"\"\"\n")
Some("id,name\n-5,=cmd\n7,\"Ada, \"\"the first\"\"\"\n")
);
assert_eq!(toasts, ["Copied 2 rows"]);
}
Expand All @@ -312,7 +330,7 @@ fn copy_rows_offers_tab_separated_values() {
);
assert_eq!(
local_text(&clipboard).as_deref(),
Some("-5\t'=cmd\n7\t\"Ada, \"\"the first\"\"\"\n")
Some("-5\t=cmd\n7\t\"Ada, \"\"the first\"\"\"\n")
);
assert_eq!(toasts, ["Copied 2 rows"]);
}
Expand Down
71 changes: 67 additions & 4 deletions linux/crates/app/src/ui/export/export_failure_text.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,7 @@ pub(crate) fn progress_text(rows: u64, expected: Option<ExpectedRows>) -> String
/// file.
pub(crate) fn failure_text(failure: &ExportFailure, file: Option<AbortOutcome>, name: &str) -> String {
let reason = match failure {
ExportFailure::Stream(ExportStreamFailure::Encode(EncodeError::Undecodable { column })) => gettext_f(
"The column “{column}” holds a value TablePro couldn't read, so it can't be written to the file.",
&[("column", column)],
),
ExportFailure::Stream(ExportStreamFailure::Encode(error)) => encode_text(error),
ExportFailure::Stream(ExportStreamFailure::MultipleResultSets) => {
gettext("This statement returned more than one result, so it can't be exported as one file.")
}
Expand Down Expand Up @@ -108,8 +105,27 @@ fn rerun_text(error: &OperationError) -> String {
}
}

/// Why a value could not go into the file, whichever format refused it.
fn encode_text(error: &EncodeError) -> String {
match error {
EncodeError::Undecodable { column } => gettext_f(
"The column “{column}” holds a value TablePro couldn't read, so it can't be written to the file.",
&[("column", column)],
),
EncodeError::NotXmlText { column } => gettext_f(
"The column “{column}” holds a control character XML can't carry. Export it as CSV or JSON instead.",
&[("column", column)],
),
}
}

fn document_text(error: &RenderError) -> String {
match error {
RenderError::Encode(error) => encode_text(error),
RenderError::TextTooLong { column, limit } => gettext_f(
"The column “{column}” holds a value longer than the {limit} characters an Excel cell holds. Export it as CSV instead.",
&[("column", column), ("limit", &format_thousands(*limit as u64))],
),
RenderError::TooManyRows { got, limit } => gettext_f(
"An Excel sheet holds {limit} rows, and this result has {got}.",
&[
Expand Down Expand Up @@ -273,4 +289,51 @@ mod tests {
"An Excel sheet holds 100 rows, and this result has more. Export it as CSV instead."
);
}

fn document(error: RenderError) -> String {
failure_text(
&ExportFailure::Stream(ExportStreamFailure::Document(error)),
None,
"rows.xlsx",
)
}

#[test]
fn a_sheet_names_the_column_it_could_not_write() {
let unreadable = document(RenderError::Encode(EncodeError::Undecodable {
column: "body".to_owned(),
}));
let long = document(RenderError::TextTooLong {
column: "body".to_owned(),
limit: 32_767,
});

assert_eq!(
unreadable,
"The column “body” holds a value TablePro couldn't read, so it can't be written to the file."
);
assert_eq!(
long,
format!(
"The column “body” holds a value longer than the {} characters an Excel cell holds. Export it as CSV instead.",
format_thousands(32_767)
)
);
}

#[test]
fn xml_names_the_column_with_a_control_character() {
let text = failure_text(
&ExportFailure::Stream(ExportStreamFailure::Encode(EncodeError::NotXmlText {
column: "note".to_owned(),
})),
None,
"rows.xml",
);

assert_eq!(
text,
"The column “note” holds a control character XML can't carry. Export it as CSV or JSON instead."
);
}
}
12 changes: 10 additions & 2 deletions linux/crates/app/src/ui/export/export_format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use tablepro_core::export::{
self, CsvEncoder, CsvOptions, HtmlEncoder, JsonArrayEncoder, MarkdownEncoder, RenderError, RowEncoder,
SqlInsertEncoder, XlsxSheetWriter, XmlEncoder,
};
use tablepro_core::meta::TableRef;

/// What an export writes.
#[derive(Debug, Clone)]
Expand Down Expand Up @@ -38,7 +39,12 @@ impl RowFormat {
Self::Markdown => Box::new(MarkdownEncoder::new(columns)),
Self::Html { title } => Box::new(HtmlEncoder::new(columns, title.clone())),
Self::Xml => Box::new(XmlEncoder::new(columns)),
Self::Sql(target) => Box::new(SqlInsertEncoder::new(target.dialect.clone(), &target.table, columns)),
Self::Sql(target) => Box::new(SqlInsertEncoder::new(
target.dialect.clone(),
&target.table,
columns,
&target.generated,
)),
}
}
}
Expand Down Expand Up @@ -88,6 +94,8 @@ impl DocumentWriter {
/// from. SQL is offered only with both.
#[derive(Debug, Clone)]
pub(crate) struct InsertTarget {
pub table: String,
pub table: TableRef,
/// The columns the server computes, which the statements leave out.
pub generated: Arc<[String]>,
pub dialect: Arc<dyn SqlDialect>,
}
3 changes: 2 additions & 1 deletion linux/crates/app/src/ui/export/export_sink_parts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ mod tests {
RowFormat::Html { title: "t".to_owned() },
RowFormat::Xml,
RowFormat::Sql(InsertTarget {
table: "t".to_owned(),
table: tablepro_core::meta::TableRef::unqualified("t"),
generated: Arc::from([]),
dialect: Arc::new(FakeDialect),
}),
];
Expand Down
6 changes: 5 additions & 1 deletion linux/crates/app/src/ui/export_dialog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use relm4::{adw, gtk};

use tablepro_core::dialect::SqlDialect;
use tablepro_core::export::{CsvDecimal, CsvDelimiter, CsvLineBreak, CsvOptions, CsvQuote, XLSX_MAX_DATA_ROWS};
use tablepro_core::meta::TableRef;
use tablepro_session::Session;
use uuid::Uuid;

Expand All @@ -24,7 +25,9 @@ use crate::ui::format::format_thousands;
/// several tables or none, so SQL is not offered for one.
#[derive(Debug, Clone)]
pub struct SqlTarget {
pub table: String,
pub table: TableRef,
/// The columns the server computes, which the statements leave out.
pub generated: Arc<[String]>,
}

#[derive(Clone, Copy, PartialEq, Eq)]
Expand Down Expand Up @@ -347,6 +350,7 @@ pub(crate) fn present(init: ExportDialogInit<'_>) {
} = offer;
let target = target.zip(dialect).map(|(target, dialect)| InsertTarget {
table: target.table,
generated: target.generated,
dialect,
});
let formats = Format::offered(target.as_ref());
Expand Down
Loading
Loading