Anki/rslib/src/err.rs
Damien Elmes d382b33585 rework filtered deck screen & search errors
- Filtered deck creation now happens as an atomic operation, and is
undoable.
- The logic for initial search text, normalizing searches and so on
has been pushed into the backend.
- Use protobuf to pass the filtered deck to the updated dialog, so
we don't need to deal with untyped JSON.
- Change the "revise your search?" prompt to be a simple info box -
user has access to cancel and build buttons, and doesn't need a separate
prompt. Tweak the wording so the 'show excluded' button should be more
obvious.
- Filtered decks have a time appended to them instead of a number,
primarily because it's easier to implement. No objections going back to
the old behaviour if someone wants to contribute a clean patch.
The standard de-duplication will happen if two decks are created in the
same minute with the same name.
- Tweak the default sort order, and start with two searches. The UI
will still hide the second search by default, but by starting with two,
the frontend doesn't need logic for creating the starting text.
- Search errors now have their own error type, instead of using
InvalidInput, as that was intended mainly for bad API calls. The markdown
conversion is done when the error is converted from the backend, allowing
errors to printed as a string without any special handling by the calling
code.

TODO: when building a new filtered deck, update_active() is clobbering
the undo log when the overview is refreshed
2021-03-24 22:04:35 +10:00

502 lines
17 KiB
Rust

// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use crate::i18n::{tr_args, tr_strs, I18n, TR};
pub use failure::{Error, Fail};
use nom::error::{ErrorKind as NomErrorKind, ParseError as NomParseError};
use reqwest::StatusCode;
use std::{io, num::ParseIntError, str::Utf8Error};
use tempfile::PathPersistError;
pub type Result<T, E = AnkiError> = std::result::Result<T, E>;
#[derive(Debug, Fail, PartialEq)]
pub enum AnkiError {
#[fail(display = "invalid input: {}", info)]
InvalidInput { info: String },
#[fail(display = "invalid card template: {}", info)]
TemplateError { info: String },
#[fail(display = "unable to save template {}", ordinal)]
TemplateSaveError { ordinal: usize },
#[fail(display = "I/O error: {}", info)]
IOError { info: String },
#[fail(display = "DB error: {}", info)]
DBError { info: String, kind: DBErrorKind },
#[fail(display = "Network error: {:?} {}", kind, info)]
NetworkError {
info: String,
kind: NetworkErrorKind,
},
#[fail(display = "Sync error: {:?}, {}", kind, info)]
SyncError { info: String, kind: SyncErrorKind },
#[fail(display = "JSON encode/decode error: {}", info)]
JSONError { info: String },
#[fail(display = "Protobuf encode/decode error: {}", info)]
ProtoError { info: String },
#[fail(display = "Unable to parse number")]
ParseNumError,
#[fail(display = "The user interrupted the operation.")]
Interrupted,
#[fail(display = "Operation requires an open collection.")]
CollectionNotOpen,
#[fail(display = "Close the existing collection first.")]
CollectionAlreadyOpen,
#[fail(display = "A requested item was not found.")]
NotFound,
#[fail(display = "The provided item already exists.")]
Existing,
#[fail(display = "Unable to place item in/under a filtered deck.")]
DeckIsFiltered,
#[fail(display = "Invalid search.")]
SearchError(SearchErrorKind),
#[fail(display = "Provided search(es) did not match any cards.")]
FilteredDeckEmpty,
}
// error helpers
impl AnkiError {
pub(crate) fn invalid_input<S: Into<String>>(s: S) -> AnkiError {
AnkiError::InvalidInput { info: s.into() }
}
pub(crate) fn server_message<S: Into<String>>(msg: S) -> AnkiError {
AnkiError::SyncError {
info: msg.into(),
kind: SyncErrorKind::ServerMessage,
}
}
pub(crate) fn sync_misc<S: Into<String>>(msg: S) -> AnkiError {
AnkiError::SyncError {
info: msg.into(),
kind: SyncErrorKind::Other,
}
}
pub fn localized_description(&self, i18n: &I18n) -> String {
match self {
AnkiError::SyncError { info, kind } => match kind {
SyncErrorKind::ServerMessage => info.into(),
SyncErrorKind::Other => info.into(),
SyncErrorKind::Conflict => i18n.tr(TR::SyncConflict),
SyncErrorKind::ServerError => i18n.tr(TR::SyncServerError),
SyncErrorKind::ClientTooOld => i18n.tr(TR::SyncClientTooOld),
SyncErrorKind::AuthFailed => i18n.tr(TR::SyncWrongPass),
SyncErrorKind::ResyncRequired => i18n.tr(TR::SyncResyncRequired),
SyncErrorKind::ClockIncorrect => i18n.tr(TR::SyncClockOff),
SyncErrorKind::DatabaseCheckRequired => i18n.tr(TR::SyncSanityCheckFailed),
// server message
SyncErrorKind::SyncNotStarted => "sync not started".into(),
}
.into(),
AnkiError::NetworkError { kind, info } => {
let summary = match kind {
NetworkErrorKind::Offline => i18n.tr(TR::NetworkOffline),
NetworkErrorKind::Timeout => i18n.tr(TR::NetworkTimeout),
NetworkErrorKind::ProxyAuth => i18n.tr(TR::NetworkProxyAuth),
NetworkErrorKind::Other => i18n.tr(TR::NetworkOther),
};
let details = i18n.trn(TR::NetworkDetails, tr_strs!["details"=>info]);
format!("{}\n\n{}", summary, details)
}
AnkiError::TemplateError { info } => {
// already localized
info.into()
}
AnkiError::TemplateSaveError { ordinal } => i18n.trn(
TR::CardTemplatesInvalidTemplateNumber,
tr_args!["number"=>ordinal+1],
),
AnkiError::DBError { info, kind } => match kind {
DBErrorKind::Corrupt => info.clone(),
DBErrorKind::Locked => "Anki already open, or media currently syncing.".into(),
_ => format!("{:?}", self),
},
AnkiError::SearchError(kind) => {
let reason = match kind {
SearchErrorKind::MisplacedAnd => i18n.tr(TR::SearchMisplacedAnd),
SearchErrorKind::MisplacedOr => i18n.tr(TR::SearchMisplacedOr),
SearchErrorKind::EmptyGroup => i18n.tr(TR::SearchEmptyGroup),
SearchErrorKind::UnopenedGroup => i18n.tr(TR::SearchUnopenedGroup),
SearchErrorKind::UnclosedGroup => i18n.tr(TR::SearchUnclosedGroup),
SearchErrorKind::EmptyQuote => i18n.tr(TR::SearchEmptyQuote),
SearchErrorKind::UnclosedQuote => i18n.tr(TR::SearchUnclosedQuote),
SearchErrorKind::MissingKey => i18n.tr(TR::SearchMissingKey),
SearchErrorKind::UnknownEscape(ctx) => i18n
.trn(
TR::SearchUnknownEscape,
tr_strs!["val"=>(ctx.replace('`', "'"))],
)
.into(),
SearchErrorKind::InvalidState(state) => i18n
.trn(
TR::SearchInvalidArgument,
tr_strs!("term" => "is:", "argument" => state.replace('`', "'")),
)
.into(),
SearchErrorKind::InvalidFlag => i18n.tr(TR::SearchInvalidFlag),
SearchErrorKind::InvalidPropProperty(prop) => i18n
.trn(
TR::SearchInvalidArgument,
tr_strs!("term" => "prop:", "argument" => prop.replace('`', "'")),
)
.into(),
SearchErrorKind::InvalidPropOperator(ctx) => i18n
.trn(TR::SearchInvalidPropOperator, tr_strs!["val"=>(ctx)])
.into(),
SearchErrorKind::Regex(text) => format!("<pre>`{}`</pre>", text.replace('`', "'")).into(),
SearchErrorKind::Other(Some(info)) => info.into(),
SearchErrorKind::Other(None) => i18n.tr(TR::SearchInvalidOther),
SearchErrorKind::InvalidNumber { provided, context } => i18n
.trn(
TR::SearchInvalidNumber,
tr_strs!["provided"=>provided.replace('`', "'"), "context"=>context.replace('`', "'")],
)
.into(),
SearchErrorKind::InvalidWholeNumber { provided, context } => i18n
.trn(
TR::SearchInvalidWholeNumber,
tr_strs!["provided"=>provided.replace('`', "'"), "context"=>context.replace('`', "'")],
)
.into(),
SearchErrorKind::InvalidPositiveWholeNumber { provided, context } => i18n
.trn(
TR::SearchInvalidPositiveWholeNumber,
tr_strs!["provided"=>provided.replace('`', "'"), "context"=>context.replace('`', "'")],
)
.into(),
SearchErrorKind::InvalidNegativeWholeNumber { provided, context } => i18n
.trn(
TR::SearchInvalidNegativeWholeNumber,
tr_strs!["provided"=>provided.replace('`', "'"), "context"=>context.replace('`', "'")],
)
.into(),
SearchErrorKind::InvalidAnswerButton { provided, context } => i18n
.trn(
TR::SearchInvalidAnswerButton,
tr_strs!["provided"=>provided.replace('`', "'"), "context"=>context.replace('`', "'")],
)
.into(),
};
i18n.trn(
TR::SearchInvalidSearch,
tr_args!("reason" => reason.into_owned()),
)
}
AnkiError::InvalidInput { info } => {
if info.is_empty() {
i18n.tr(TR::ErrorsInvalidInputEmpty).into()
} else {
i18n.trn(
TR::ErrorsInvalidInputDetails,
tr_args!("details" => info.to_owned()),
)
}
}
AnkiError::ParseNumError => i18n.tr(TR::ErrorsParseNumberFail).into(),
AnkiError::DeckIsFiltered => i18n.tr(TR::ErrorsFilteredParentDeck).into(),
AnkiError::FilteredDeckEmpty => i18n.tr(TR::DecksFilteredDeckSearchEmpty).into(),
_ => format!("{:?}", self),
}
}
}
#[derive(Debug, PartialEq)]
pub enum TemplateError {
NoClosingBrackets(String),
ConditionalNotClosed(String),
ConditionalNotOpen {
closed: String,
currently_open: Option<String>,
},
FieldNotFound {
filters: String,
field: String,
},
}
impl From<io::Error> for AnkiError {
fn from(err: io::Error) -> Self {
AnkiError::IOError {
info: format!("{:?}", err),
}
}
}
impl From<rusqlite::Error> for AnkiError {
fn from(err: rusqlite::Error) -> Self {
if let rusqlite::Error::SqliteFailure(error, Some(reason)) = &err {
if error.code == rusqlite::ErrorCode::DatabaseBusy {
return AnkiError::DBError {
info: "".to_string(),
kind: DBErrorKind::Locked,
};
}
if reason.contains("regex parse error") {
return AnkiError::SearchError(SearchErrorKind::Regex(reason.to_owned()));
}
}
AnkiError::DBError {
info: format!("{:?}", err),
kind: DBErrorKind::Other,
}
}
}
impl From<rusqlite::types::FromSqlError> for AnkiError {
fn from(err: rusqlite::types::FromSqlError) -> Self {
if let rusqlite::types::FromSqlError::Other(ref err) = err {
if let Some(_err) = err.downcast_ref::<Utf8Error>() {
return AnkiError::DBError {
info: "".to_string(),
kind: DBErrorKind::Utf8,
};
}
}
AnkiError::DBError {
info: format!("{:?}", err),
kind: DBErrorKind::Other,
}
}
}
#[derive(Debug, PartialEq)]
pub enum NetworkErrorKind {
Offline,
Timeout,
ProxyAuth,
Other,
}
impl From<reqwest::Error> for AnkiError {
fn from(err: reqwest::Error) -> Self {
let url = err.url().map(|url| url.as_str()).unwrap_or("");
let str_err = format!("{}", err);
// strip url from error to avoid exposing keys
let info = str_err.replace(url, "");
if err.is_timeout() {
AnkiError::NetworkError {
info,
kind: NetworkErrorKind::Timeout,
}
} else if err.is_status() {
error_for_status_code(info, err.status().unwrap())
} else {
guess_reqwest_error(info)
}
}
}
#[derive(Debug, PartialEq)]
pub enum SyncErrorKind {
Conflict,
ServerError,
ClientTooOld,
AuthFailed,
ServerMessage,
ClockIncorrect,
Other,
ResyncRequired,
DatabaseCheckRequired,
SyncNotStarted,
}
fn error_for_status_code(info: String, code: StatusCode) -> AnkiError {
use reqwest::StatusCode as S;
match code {
S::PROXY_AUTHENTICATION_REQUIRED => AnkiError::NetworkError {
info,
kind: NetworkErrorKind::ProxyAuth,
},
S::CONFLICT => AnkiError::SyncError {
info,
kind: SyncErrorKind::Conflict,
},
S::FORBIDDEN => AnkiError::SyncError {
info,
kind: SyncErrorKind::AuthFailed,
},
S::NOT_IMPLEMENTED => AnkiError::SyncError {
info,
kind: SyncErrorKind::ClientTooOld,
},
S::INTERNAL_SERVER_ERROR | S::BAD_GATEWAY | S::GATEWAY_TIMEOUT | S::SERVICE_UNAVAILABLE => {
AnkiError::SyncError {
info,
kind: SyncErrorKind::ServerError,
}
}
S::BAD_REQUEST => AnkiError::SyncError {
info,
kind: SyncErrorKind::DatabaseCheckRequired,
},
_ => AnkiError::NetworkError {
info,
kind: NetworkErrorKind::Other,
},
}
}
fn guess_reqwest_error(mut info: String) -> AnkiError {
if info.contains("dns error: cancelled") {
return AnkiError::Interrupted;
}
let kind = if info.contains("unreachable") || info.contains("dns") {
NetworkErrorKind::Offline
} else if info.contains("timed out") {
NetworkErrorKind::Timeout
} else {
if info.contains("invalid type") {
info = format!(
"{} {} {}\n\n{}",
"Please force a full sync in the Preferences screen to bring your devices into sync.",
"Then, please use the Check Database feature, and sync to your other devices.",
"If problems persist, please post on the support forum.",
info,
);
}
NetworkErrorKind::Other
};
AnkiError::NetworkError { info, kind }
}
impl From<zip::result::ZipError> for AnkiError {
fn from(err: zip::result::ZipError) -> Self {
AnkiError::sync_misc(err.to_string())
}
}
impl From<serde_json::Error> for AnkiError {
fn from(err: serde_json::Error) -> Self {
AnkiError::JSONError {
info: err.to_string(),
}
}
}
impl From<prost::EncodeError> for AnkiError {
fn from(err: prost::EncodeError) -> Self {
AnkiError::ProtoError {
info: err.to_string(),
}
}
}
impl From<prost::DecodeError> for AnkiError {
fn from(err: prost::DecodeError) -> Self {
AnkiError::ProtoError {
info: err.to_string(),
}
}
}
#[derive(Debug, PartialEq)]
pub enum DBErrorKind {
FileTooNew,
FileTooOld,
MissingEntity,
Corrupt,
Locked,
Utf8,
Other,
}
impl From<PathPersistError> for AnkiError {
fn from(e: PathPersistError) -> Self {
AnkiError::IOError {
info: e.to_string(),
}
}
}
#[derive(Debug, PartialEq)]
pub enum ParseError<'a> {
Anki(&'a str, SearchErrorKind),
Nom(&'a str, NomErrorKind),
}
#[derive(Debug, PartialEq)]
pub enum SearchErrorKind {
MisplacedAnd,
MisplacedOr,
EmptyGroup,
UnopenedGroup,
UnclosedGroup,
EmptyQuote,
UnclosedQuote,
MissingKey,
UnknownEscape(String),
InvalidState(String),
InvalidFlag,
InvalidPropProperty(String),
InvalidPropOperator(String),
InvalidNumber { provided: String, context: String },
InvalidWholeNumber { provided: String, context: String },
InvalidPositiveWholeNumber { provided: String, context: String },
InvalidNegativeWholeNumber { provided: String, context: String },
InvalidAnswerButton { provided: String, context: String },
Regex(String),
Other(Option<String>),
}
impl From<ParseError<'_>> for AnkiError {
fn from(err: ParseError) -> Self {
match err {
ParseError::Anki(_, kind) => AnkiError::SearchError(kind),
ParseError::Nom(_, _) => AnkiError::SearchError(SearchErrorKind::Other(None)),
}
}
}
impl From<nom::Err<ParseError<'_>>> for AnkiError {
fn from(err: nom::Err<ParseError<'_>>) -> Self {
match err {
nom::Err::Error(e) => e.into(),
nom::Err::Failure(e) => e.into(),
nom::Err::Incomplete(_) => AnkiError::SearchError(SearchErrorKind::Other(None)),
}
}
}
impl<'a> NomParseError<&'a str> for ParseError<'a> {
fn from_error_kind(input: &'a str, kind: NomErrorKind) -> Self {
ParseError::Nom(input, kind)
}
fn append(_: &str, _: NomErrorKind, other: Self) -> Self {
other
}
}
impl From<ParseIntError> for AnkiError {
fn from(_err: ParseIntError) -> Self {
AnkiError::ParseNumError
}
}
impl From<regex::Error> for AnkiError {
fn from(_err: regex::Error) -> Self {
AnkiError::InvalidInput {
info: "invalid regex".into(),
}
}
}