Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions linux/changelog.d/3555.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Translatable error details for CSV import, cell edits, column types and defaults, the keyring and driver refusals.
23 changes: 17 additions & 6 deletions linux/crates/app/src/services/secret_save_report.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,16 @@ pub(crate) fn not_saved_message(error: &SecretError) -> String {
SecretError::Locked | SecretError::UnlockDismissed => crate::i18n::gettext(
"The keyring stayed locked, so the password was not saved. It will be asked for next time.",
),
SecretError::Backend { detail } => {
crate::i18n::gettext_f("The keyring did not save the password: {reason}", &[("reason", detail)])
SecretError::Backend {
service_message: Some(message),
..
} => crate::i18n::gettext_f(
"The keyring did not save the password: {reason}",
&[("reason", message)],
),
SecretError::Backend { .. } | SecretError::InvalidEncoding => {
crate::i18n::gettext("The keyring did not save the password.")
}
SecretError::InvalidEncoding => crate::i18n::gettext("The keyring did not save the password."),
}
}

Expand All @@ -28,12 +34,17 @@ mod tests {
fn the_message_says_what_happened_in_words_not_the_error_text() {
let locked = not_saved_message(&SecretError::UnlockDismissed);
let backend = not_saved_message(&SecretError::Backend {
detail: "org.freedesktop.Secret.Error.NoSuchObject".to_owned(),
detail: "DBus error service error org.freedesktop.Secret.Error.NoSuchObject".to_owned(),
service_message: Some("No such collection".to_owned()),
});
let silent = not_saved_message(&SecretError::Backend {
detail: "DBus error Item/Collection was deleted, can no longer be used".to_owned(),
service_message: None,
});

assert!(locked.contains("password was not saved"), "{locked}");
assert!(!locked.contains(&SecretError::UnlockDismissed.to_string()), "{locked}");
assert!(backend.contains("NoSuchObject"), "{backend}");
assert!(!backend.contains("the secret service reported"), "{backend}");
assert_eq!(backend, "The keyring did not save the password: No such collection");
assert_eq!(silent, "The keyring did not save the password.");
}
}
36 changes: 36 additions & 0 deletions linux/crates/app/src/test_support/log_lines.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
use std::sync::{Arc, Mutex, PoisonError};

/// What this thread logs, as text.
#[derive(Clone, Default)]
pub(crate) struct LogLines(Arc<Mutex<Vec<u8>>>);

impl LogLines {
/// A subscriber that writes here, for `tracing::subscriber::set_default`.
pub(crate) fn subscriber(&self, level: tracing::Level) -> impl tracing::Subscriber + Send + Sync + 'static {
let lines = self.clone();
tracing_subscriber::fmt()
.with_ansi(false)
.with_max_level(level)
.with_writer(move || lines.clone())
.finish()
}

pub(crate) fn text(&self) -> String {
let bytes = self.0.lock().unwrap_or_else(PoisonError::into_inner);
String::from_utf8_lossy(&bytes).into_owned()
}
}

impl std::io::Write for LogLines {
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
self.0
.lock()
.unwrap_or_else(PoisonError::into_inner)
.extend_from_slice(bytes);
Ok(bytes.len())
}

fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
2 changes: 2 additions & 0 deletions linux/crates/app/src/test_support/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ mod focus_thief;
mod icon_buttons;
mod key_injection_error;
mod key_injector;
mod log_lines;
mod main_loop;
mod memory_settings;
mod owned_window;
Expand All @@ -32,6 +33,7 @@ pub(crate) use icon_buttons::{
};
pub(crate) use key_injection_error::KeyInjectionError;
pub(crate) use key_injector::KeyInjector;
pub(crate) use log_lines::LogLines;
pub(crate) use main_loop::{after_next_frame, drain_main_context, finish_on_main, wait_until};
pub(crate) use memory_settings::MemorySettings;
pub(crate) use owned_window::{OwnedWindow, drop_focus};
Expand Down
37 changes: 2 additions & 35 deletions linux/crates/app/src/ui/browse_tab/tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ use crate::services::change_tracker::{RowKey, StatementSource};
use crate::services::tab_tracker_handle::TabTrackerHandle;
use crate::services::tracker_delta::TrackerDelta;
use crate::services::tracker_edit::TrackerEdit;
use crate::test_support::{FakeSession, MemorySettings, after_next_frame, drain_main_context, wait_until};
use crate::test_support::{FakeSession, LogLines, MemorySettings, after_next_frame, drain_main_context, wait_until};
use crate::ui::clipboard::InsertRow;
use crate::ui::grid::{GridCell, GridRowIndex, RowObject};

Expand Down Expand Up @@ -2842,43 +2842,10 @@ fn a_bulk_delete_waiting_while_a_column_is_added_marks_its_rows_under_the_new_co
);
}

/// What this thread logs, as text.
#[derive(Clone, Default)]
pub(super) struct LogLines(Arc<std::sync::Mutex<Vec<u8>>>);

impl LogLines {
pub(super) fn text(&self) -> String {
let bytes = self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
String::from_utf8_lossy(&bytes).into_owned()
}
}

impl std::io::Write for LogLines {
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
self.0
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.extend_from_slice(bytes);
Ok(bytes.len())
}

fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}

#[gtk4::test]
fn a_failed_row_count_logs_its_category_and_not_the_server_message() {
let lines = LogLines::default();
let subscriber = tracing_subscriber::fmt()
.with_ansi(false)
.with_max_level(tracing::Level::DEBUG)
.with_writer({
let lines = lines.clone();
move || lines.clone()
})
.finish();
let _logging = tracing::subscriber::set_default(subscriber);
let _logging = tracing::subscriber::set_default(lines.subscriber(tracing::Level::DEBUG));
let mut script = serving_table();
script.query_bound.always(FakeReply::ok(rows(&["Ada"])));
script.count_rows.always(FakeReply::err(DriverError::reported(
Expand Down
13 changes: 3 additions & 10 deletions linux/crates/app/src/ui/browse_tab/tests/total.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ use tablepro_test_support::{CallCounters, ConnectionScript, FakeReply};

use super::super::{PagingInput, SaveInput};
use super::{
Harness, LogLines, WAIT, estimate, full_page, not_null, not_null_name, paging, rows, rows_from, serving_table, text,
Harness, WAIT, estimate, full_page, not_null, not_null_name, paging, rows, rows_from, serving_table, text,
};
use crate::services::change_origins::ChangeOrigins;
use crate::test_support::{drain_main_context, wait_until};
use crate::test_support::{LogLines, drain_main_context, wait_until};

fn paginator(harness: &Harness) -> String {
harness.tab().imp().pager_bar.imp().paginator_label.label().to_string()
Expand Down Expand Up @@ -75,14 +75,7 @@ fn unknown_estimate_offers_count_without_counting() {
#[gtk4::test]
fn estimate_error_offers_count_and_logs() {
let lines = LogLines::default();
let subscriber = tracing_subscriber::fmt()
.with_ansi(false)
.with_writer({
let lines = lines.clone();
move || lines.clone()
})
.finish();
let _logging = tracing::subscriber::set_default(subscriber);
let _logging = tracing::subscriber::set_default(lines.subscriber(tracing::Level::INFO));
let mut script = estimated(RowCountEstimate::Unknown);
script.table_estimate = Default::default();
script
Expand Down
3 changes: 2 additions & 1 deletion linux/crates/app/src/ui/connect_dialog/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -506,10 +506,11 @@ impl ConnectDialog {
let weak = self.downgrade();
glib::spawn_future_local(async move {
let result = check.await.unwrap_or_else(|failure| {
tracing::warn!(%failure, "the certificate check did not finish");
Err(ConfigError::CaFile {
path: fallback_path,
problem: tablepro_core::CertificateFileProblem::Unreadable {
detail: failure.to_string(),
kind: std::io::ErrorKind::Other,
},
})
});
Expand Down
9 changes: 5 additions & 4 deletions linux/crates/app/src/ui/error_text/ddl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

use tablepro_core::ddl::{BuildDdlError, DdlOperation, DraftProblem, RefusalReason};

use super::syntax::{literal_message, type_syntax_message};
use crate::services::pending_structure::SaveBlocker;

/// Why a control is off, for its tooltip.
Expand Down Expand Up @@ -101,12 +102,12 @@ pub fn build_error_text(error: &BuildDdlError) -> String {
),
BuildDdlError::Unsupported(_) => crate::i18n::gettext("This database can't make this change."),
BuildDdlError::InvalidType { column, error } => crate::i18n::gettext_f(
"Column {column} has a type this database doesn't accept: {error}",
&[("column", column), ("error", &error.to_string())],
"The type of column {column} can't be used. {reason}",
&[("column", column), ("reason", &type_syntax_message(error))],
),
BuildDdlError::Literal { column, error } => crate::i18n::gettext_f(
"The default of column {column} can't be written as SQL: {error}",
&[("column", column), ("error", &error.to_string())],
"The default of column {column} can't be written as SQL. {reason}",
&[("column", column), ("reason", &literal_message(error))],
),
BuildDdlError::MissingCatalogDeclaration(column) => crate::i18n::gettext_f(
"The database didn't report the type of column {column}.",
Expand Down
56 changes: 27 additions & 29 deletions linux/crates/app/src/ui/error_text/driver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ use tablepro_core::{
TransportError,
};

use super::files::{certificate_problem_text, file_io_message};
use super::ssh::ssh_failure_message;
use super::tls::tls_message;
use super::unsupported::unsupported_message;

pub fn driver_message(error: &DriverError) -> String {
// An SSH failure carries its own category and its own next step.
Expand All @@ -16,13 +18,13 @@ pub fn driver_message(error: &DriverError) -> String {
ErrorCategory::Network => network_message(error),
ErrorCategory::Tls => match error {
DriverError::Tls { failure, detail } => tls_message(*failure, detail),
other => detail_sentence(crate::i18n::gettext("The secure connection failed."), other),
_ => crate::i18n::gettext("The secure connection failed."),
},
ErrorCategory::Authentication => authentication_message(error),
ErrorCategory::ReadOnly => read_only_message(error),
ErrorCategory::Server => match error {
DriverError::Server(diagnostics) => server_message(diagnostics),
other => detail_sentence(crate::i18n::gettext("The server refused the request."), other),
_ => crate::i18n::gettext("The server refused the request."),
},
ErrorCategory::Interrupted => crate::i18n::gettext("Stopped."),
ErrorCategory::Timeout => timeout_message(error),
Expand All @@ -39,19 +41,11 @@ pub fn driver_message(error: &DriverError) -> String {
}
}

/// A category sentence followed by what the error itself says, for a
/// variant this build has no sentence of its own for.
fn detail_sentence(sentence: String, error: &DriverError) -> String {
format!("{sentence} {error}")
}

fn configuration_message(error: &DriverError) -> String {
match error {
DriverError::Config(config) => config_message(config),
DriverError::Unsupported { feature } => {
crate::i18n::gettext_f("This database has no {feature}.", &[("feature", feature)])
}
other => detail_sentence(crate::i18n::gettext("The connection settings cannot be used."), other),
DriverError::Unsupported { feature } => unsupported_message(*feature),
_ => crate::i18n::gettext("The connection settings cannot be used."),
}
}

Expand All @@ -73,11 +67,17 @@ pub fn config_message(error: &ConfigError) -> String {
}
ConfigError::CaFile { path, problem } => crate::i18n::gettext_f(
"The CA certificate {path} cannot be used: {problem}",
&[("path", &path.display().to_string()), ("problem", &problem.to_string())],
&[
("path", &path.display().to_string()),
("problem", &certificate_problem_text(problem)),
],
),
ConfigError::ClientIdentityFile { path, problem } => crate::i18n::gettext_f(
"The client certificate {path} cannot be used: {problem}",
&[("path", &path.display().to_string()), ("problem", &problem.to_string())],
&[
("path", &path.display().to_string()),
("problem", &certificate_problem_text(problem)),
],
),
ConfigError::InvalidServerName => {
crate::i18n::gettext("The TLS server name is not a valid host name or IP address.")
Expand Down Expand Up @@ -105,7 +105,7 @@ fn network_message(error: &DriverError) -> String {
"Could not reach {host}:{port}.",
&[("host", endpoint.host()), ("port", &endpoint.port().to_string())],
),
other => detail_sentence(crate::i18n::gettext("The server could not be reached."), other),
_ => crate::i18n::gettext("The server could not be reached."),
}
}

Expand Down Expand Up @@ -182,7 +182,7 @@ fn timeout_message(error: &DriverError) -> String {
server_cancelled,
} => (*phase, *server_cancelled),
DriverError::Transport(TransportError::Timeout { phase }) => (*phase, false),
other => return detail_sentence(crate::i18n::gettext("The server took too long to answer."), other),
_ => return crate::i18n::gettext("The server took too long to answer."),
};
match phase {
TimeoutPhase::Connect => crate::i18n::gettext("The server did not answer the connection in time."),
Expand Down Expand Up @@ -253,27 +253,25 @@ fn file_message(error: &DriverError) -> String {
"{path} is not a database file.",
&[("path", &path.display().to_string())],
),
DriverError::FileIo { path, kind } => crate::i18n::gettext_f(
"Could not write {path}: {reason}",
&[("path", &path.display().to_string()), ("reason", &kind.to_string())],
),
other => detail_sentence(crate::i18n::gettext("The database file cannot be used."), other),
DriverError::FileIo { path, kind } => file_io_message(path, *kind),
_ => crate::i18n::gettext("The database file cannot be used."),
}
}

/// The detail of an internal error is the driver's or its client
/// library's English, about our side rather than anything the user can
/// change, so it goes to the log at the wire-detail level instead.
fn internal_message(error: &DriverError) -> String {
tracing::trace!(%error, "shown as an internal driver error");
match error {
DriverError::GuardUnsupported { .. } => crate::i18n::gettext(
"This database does not report how many rows a statement changed, so the save cannot be checked. Use a table with a primary key.",
),
DriverError::Decode { column, detail } => crate::i18n::gettext_f(
"Could not read column {column}: {detail}",
&[("column", column), ("detail", detail)],
),
DriverError::Protocol(detail) => crate::i18n::gettext_f(
"The driver could not read the server's reply: {detail}",
&[("detail", detail)],
DriverError::Decode { column, .. } => crate::i18n::gettext_f(
"The driver could not read a value in column {column}.",
&[("column", column)],
),
other => detail_sentence(crate::i18n::gettext("The driver hit an internal error."), other),
DriverError::Protocol(_) => crate::i18n::gettext("The driver could not read the server's reply."),
_ => crate::i18n::gettext("The driver hit an internal error."),
}
}
13 changes: 10 additions & 3 deletions linux/crates/app/src/ui/error_text/edit.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use tablepro_core::column::IntegerKind;
use tablepro_core::edit::{EditParseError, ReadOnlyReason};
use tablepro_core::value::JsonTextError;

use crate::i18n::{gettext, gettext_f, ngettext_f};

Expand Down Expand Up @@ -33,7 +34,11 @@ pub fn edit_parse_message(error: &EditParseError) -> String {
),
EditParseError::InvalidBoolean => gettext("Enter true or false"),
EditParseError::InvalidUuid => gettext("Enter a UUID"),
EditParseError::InvalidJson(detail) => gettext_f("Enter valid JSON: {detail}", &[("detail", detail)]),
EditParseError::InvalidJson(JsonTextError::Incomplete) => gettext("Enter valid JSON: it ends too early"),
EditParseError::InvalidJson(JsonTextError::Invalid { line, column }) => gettext_f(
"Enter valid JSON: check line {line}, column {column}",
&[("line", &line.to_string()), ("column", &column.to_string())],
),
EditParseError::InvalidDate => gettext("Enter a date as YYYY-MM-DD"),
EditParseError::InvalidTime => gettext("Enter a time as HH:MM:SS"),
EditParseError::TimeOutOfRange => gettext("This time is outside the range the column can hold"),
Expand Down Expand Up @@ -84,7 +89,7 @@ mod tests {

use tablepro_core::column::{IntegerKind, IntegerWidth};
use tablepro_core::edit::{EditParseError, ReadOnlyReason};
use tablepro_core::value::DecimalParseError;
use tablepro_core::value::{DecimalParseError, JsonTextError};

use super::{edit_parse_message, integer_range};

Expand All @@ -104,7 +109,8 @@ mod tests {
EditParseError::ScaleExceeded { scale: 2 },
EditParseError::InvalidBoolean,
EditParseError::InvalidUuid,
EditParseError::InvalidJson("expected value at line 1 column 1".to_owned()),
EditParseError::InvalidJson(JsonTextError::Incomplete),
EditParseError::InvalidJson(JsonTextError::Invalid { line: 2, column: 7 }),
EditParseError::InvalidDate,
EditParseError::InvalidTime,
EditParseError::TimeOutOfRange,
Expand All @@ -123,6 +129,7 @@ mod tests {
let distinct: HashSet<&String> = messages.iter().collect();
assert_eq!(distinct.len(), messages.len(), "{messages:?}");
assert_eq!(messages[2], "Enter a whole number from -32768 to 32767");
assert_eq!(messages[10], "Enter valid JSON: check line 2, column 7");
}

#[test]
Expand Down
Loading
Loading