diff --git a/ftl/qt/errors.ftl b/ftl/qt/errors.ftl
index c3bca0475..8990a65fc 100644
--- a/ftl/qt/errors.ftl
+++ b/ftl/qt/errors.ftl
@@ -7,7 +7,6 @@ errors-standard-popup =
If problems persist, please report the problem on our { -errors-support-site }.
Please copy and paste the information below into your report.
--errors-addon-support-site = [add-on support site](https://help.ankiweb.net/discussions/add-ons/)
errors-addons-active-popup =
# Error
@@ -19,7 +18,7 @@ errors-addons-active-popup =
repeating until you discover the add-on that is causing the problem.
When you've discovered the add-on that is causing the problem, please
- report the issue on the { -errors-addon-support-site }.
+ report the issue to the add-on author.
Debug info:
errors-accessing-db =
@@ -41,3 +40,7 @@ errors-unable-open-collection =
Debug info:
errors-windows-tts-runtime-error = The TTS service failed. Please ensure Windows updates are installed, try restarting your computer, and try using a different voice.
+
+## OBSOLETE; you do not need to translate this
+
+-errors-addon-support-site = [add-on support site](https://help.ankiweb.net/discussions/add-ons/)
diff --git a/proto/anki/backend.proto b/proto/anki/backend.proto
index 74675f4bd..e24c63992 100644
--- a/proto/anki/backend.proto
+++ b/proto/anki/backend.proto
@@ -5,6 +5,8 @@ syntax = "proto3";
package anki.backend;
+import "anki/links.proto";
+
/// while the protobuf descriptors expose the order services are defined in,
/// that information is not available in prost, so we define an enum to make
/// sure all clients agree on the service index
@@ -58,10 +60,14 @@ message BackendError {
SEARCH_ERROR = 14;
CUSTOM_STUDY_ERROR = 15;
IMPORT_ERROR = 16;
+ DELETED = 17;
+ CARD_TYPE_ERROR = 18;
}
// localized error description suitable for displaying to the user
string localized = 1;
// the error subtype
Kind kind = 2;
+ // optional page in the manual
+ optional links.HelpPageLinkRequest.HelpPage help_page = 3;
}
diff --git a/proto/anki/links.proto b/proto/anki/links.proto
index f74bdea21..7566f91ee 100644
--- a/proto/anki/links.proto
+++ b/proto/anki/links.proto
@@ -31,6 +31,11 @@ message HelpPageLinkRequest {
DECK_OPTIONS = 15;
EDITING_FEATURES = 16;
FULL_SCREEN_ISSUE = 17;
+ CARD_TYPE_DUPLICATE = 18;
+ CARD_TYPE_NO_FRONT_FIELD = 19;
+ CARD_TYPE_MISSING_CLOZE = 20;
+ CARD_TYPE_EXTRANEOUS_CLOZE = 21;
+ CARD_TYPE_TEMPLATE_ERROR = 22;
}
HelpPage page = 1;
}
diff --git a/pylib/anki/_backend/__init__.py b/pylib/anki/_backend/__init__.py
index e0084a326..48eeac94f 100644
--- a/pylib/anki/_backend/__init__.py
+++ b/pylib/anki/_backend/__init__.py
@@ -5,7 +5,7 @@ from __future__ import annotations
import sys
import traceback
-from typing import Any, Sequence, Union
+from typing import Any, Sequence
from weakref import ref
from markdown import markdown
@@ -20,6 +20,7 @@ from anki.utils import from_json_bytes, to_json_bytes
from ..errors import (
BackendIOError,
+ CardTypeError,
CustomStudyError,
DBError,
ExistsError,
@@ -189,6 +190,9 @@ def backend_exception_to_pylib(err: backend_pb2.BackendError) -> Exception:
elif val == kind.DB_ERROR:
return DBError(err.localized)
+ elif val == kind.CARD_TYPE_ERROR:
+ return CardTypeError(err.localized, err.help_page)
+
elif val == kind.TEMPLATE_PARSE:
return TemplateError(err.localized)
diff --git a/pylib/anki/errors.py b/pylib/anki/errors.py
index 07e26cc9e..35fb6d013 100644
--- a/pylib/anki/errors.py
+++ b/pylib/anki/errors.py
@@ -4,6 +4,10 @@
from __future__ import annotations
from enum import Enum
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ import anki.collection
class LocalizedError(Exception):
@@ -17,6 +21,14 @@ class LocalizedError(Exception):
return self._localized
+class DocumentedError(LocalizedError):
+ """A localized error described in the manual."""
+
+ def __init__(self, localized: str, help_page: anki.collection.HelpPage.V) -> None:
+ self.help_page = help_page
+ super().__init__(localized)
+
+
class Interrupted(Exception):
pass
@@ -48,6 +60,10 @@ class DBError(LocalizedError):
pass
+class CardTypeError(DocumentedError):
+ pass
+
+
class TemplateError(LocalizedError):
pass
@@ -56,6 +72,10 @@ class NotFoundError(Exception):
pass
+class DeletedError(LocalizedError):
+ pass
+
+
class ExistsError(Exception):
pass
diff --git a/qt/aqt/addons.py b/qt/aqt/addons.py
index fcc7c8159..b05778111 100644
--- a/qt/aqt/addons.py
+++ b/qt/aqt/addons.py
@@ -12,6 +12,7 @@ from collections import defaultdict
from concurrent.futures import Future
from dataclasses import dataclass
from datetime import datetime
+from pathlib import Path
from typing import IO, Any, Callable, Iterable, Union
from urllib.parse import parse_qs, urlparse
from zipfile import ZipFile
@@ -20,7 +21,6 @@ import jsonschema
import markdown
from jsonschema.exceptions import ValidationError
from markdown.extensions import md_in_html
-from send2trash import send2trash
import anki
import anki.utils
@@ -42,6 +42,7 @@ from aqt.utils import (
restoreSplitter,
saveGeom,
saveSplitter,
+ send_to_trash,
showInfo,
showWarning,
tooltip,
@@ -452,7 +453,7 @@ class AddonManager:
# true on success
def deleteAddon(self, module: str) -> bool:
try:
- send2trash(self.addonsFolder(module))
+ send_to_trash(Path(self.addonsFolder(module)))
return True
except OSError as e:
showWarning(
diff --git a/qt/aqt/browser/table/__init__.py b/qt/aqt/browser/table/__init__.py
index 6df0d130b..2cfd96b46 100644
--- a/qt/aqt/browser/table/__init__.py
+++ b/qt/aqt/browser/table/__init__.py
@@ -37,7 +37,7 @@ class Cell:
class CellRow:
- is_deleted: bool = False
+ is_disabled: bool = False
def __init__(
self,
@@ -69,9 +69,9 @@ class CellRow:
return CellRow.generic(length, "...")
@staticmethod
- def deleted(length: int) -> CellRow:
- row = CellRow.generic(length, tr.browsing_row_deleted())
- row.is_deleted = True
+ def disabled(length: int, cell_text: str) -> CellRow:
+ row = CellRow.generic(length, cell_text)
+ row.is_disabled = True
return row
diff --git a/qt/aqt/browser/table/model.py b/qt/aqt/browser/table/model.py
index ecedb1580..e1ff290a6 100644
--- a/qt/aqt/browser/table/model.py
+++ b/qt/aqt/browser/table/model.py
@@ -11,7 +11,7 @@ from anki.cards import Card, CardId
from anki.collection import BrowserColumns as Columns
from anki.collection import Collection
from anki.consts import *
-from anki.errors import NotFoundError
+from anki.errors import LocalizedError, NotFoundError
from anki.notes import Note, NoteId
from aqt import gui_hooks
from aqt.browser.table import Cell, CellRow, Column, ItemId, SearchContext
@@ -87,24 +87,26 @@ class DataModel(QAbstractTableModel):
# row state has changed if existence of cached and fetched counterparts differ
# if the row was previously uncached, it is assumed to have existed
state_change = (
- new_row.is_deleted
+ new_row.is_disabled
if old_row is None
- else old_row.is_deleted != new_row.is_deleted
+ else old_row.is_disabled != new_row.is_disabled
)
if state_change:
- self._on_row_state_will_change(index, not new_row.is_deleted)
+ self._on_row_state_will_change(index, not new_row.is_disabled)
self._rows[item] = new_row
if state_change:
- self._on_row_state_changed(index, not new_row.is_deleted)
+ self._on_row_state_changed(index, not new_row.is_disabled)
return self._rows[item]
def _fetch_row_from_backend(self, item: ItemId) -> CellRow:
try:
row = CellRow(*self.col.browser_row_for_id(item))
- except NotFoundError:
- return CellRow.deleted(self.len_columns())
+ except LocalizedError as e:
+ return CellRow.disabled(self.len_columns(), str(e))
except Exception as e:
- return CellRow.generic(self.len_columns(), str(e))
+ return CellRow.disabled(
+ self.len_columns(), tr.errors_please_check_database()
+ )
except BaseException as e:
# fatal error like a panic in the backend - dump it to the
# console so it gets picked up by the error handler
@@ -214,10 +216,11 @@ class DataModel(QAbstractTableModel):
"""Try to return the indicated, possibly deleted card."""
if not index.isValid():
return None
- try:
- return self._state.get_card(self.get_item(index))
- except NotFoundError:
+ # The browser code will be calling .note() on the returned card.
+ # This implicitly ensures both the card and its note exist.
+ if self.get_row(index).is_disabled:
return None
+ return self._state.get_card(self.get_item(index))
def get_note(self, index: QModelIndex) -> Note | None:
"""Try to return the indicated, possibly deleted note."""
@@ -341,7 +344,7 @@ class DataModel(QAbstractTableModel):
def flags(self, index: QModelIndex) -> Qt.ItemFlag:
# shortcut for large selections (Ctrl+A) to avoid fetching large numbers of rows at once
if row := self.get_cached_row(index):
- if row.is_deleted:
+ if row.is_disabled:
return Qt.ItemFlag(Qt.ItemFlag.NoItemFlags)
return Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable
diff --git a/qt/aqt/browser/table/table.py b/qt/aqt/browser/table/table.py
index 4d1940ee3..0bf2cd548 100644
--- a/qt/aqt/browser/table/table.py
+++ b/qt/aqt/browser/table/table.py
@@ -225,7 +225,7 @@ class Table:
bottom = max(r.row() for r in self._selected()) + 1
for row in range(bottom, self.len()):
index = self._model.index(row, 0)
- if self._model.get_row(index).is_deleted:
+ if self._model.get_row(index).is_disabled:
continue
if self._model.get_note_id(index) in nids:
continue
@@ -235,7 +235,7 @@ class Table:
top = min(r.row() for r in self._selected()) - 1
for row in range(top, -1, -1):
index = self._model.index(row, 0)
- if self._model.get_row(index).is_deleted:
+ if self._model.get_row(index).is_disabled:
continue
if self._model.get_note_id(index) in nids:
continue
diff --git a/qt/aqt/errors.py b/qt/aqt/errors.py
index 31805a03a..618549e1a 100644
--- a/qt/aqt/errors.py
+++ b/qt/aqt/errors.py
@@ -1,18 +1,41 @@
# Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
+
+from __future__ import annotations
+
import html
import re
import sys
import traceback
-from typing import Optional, TextIO, cast
+from typing import TYPE_CHECKING, Optional, TextIO, cast
from markdown import markdown
-from aqt import mw
-from aqt.main import AnkiQt
+import aqt
+from anki.errors import DocumentedError, LocalizedError
from aqt.qt import *
from aqt.utils import showText, showWarning, supportText, tr
+if TYPE_CHECKING:
+ from aqt.main import AnkiQt
+
+
+def show_exception(*, parent: QWidget, exception: Exception) -> None:
+ "Present a caught exception to the user using a pop-up."
+ if isinstance(exception, InterruptedError):
+ # nothing to do
+ return
+ help_page = exception.help_page if isinstance(exception, DocumentedError) else None
+ if not isinstance(exception, LocalizedError):
+ # if the error is not originating from the backend, dump
+ # a traceback to the console to aid in debugging
+ traceback.print_exception(
+ None, exception, exception.__traceback__, file=sys.stdout
+ )
+
+ showWarning(str(exception), parent=parent, help=help_page)
+
+
if not os.environ.get("DEBUG"):
def excepthook(etype, val, tb) -> None: # type: ignore
@@ -98,7 +121,12 @@ class ErrorHandler(QObject):
)
error = f"{supportText() + self._addonText(error)}\n{error}"
elif self.mw.addonManager.dirty:
- txt = markdown(tr.errors_addons_active_popup())
+ # Older translations include a link to the old discussions site; rewrite it to a newer one
+ message = tr.errors_addons_active_popup().replace(
+ "https://help.ankiweb.net/discussions/add-ons/",
+ "https://forums.ankiweb.net/c/add-ons/11",
+ )
+ txt = markdown(message)
error = f"{supportText() + self._addonText(error)}\n{error}"
else:
txt = markdown(tr.errors_standard_popup())
@@ -116,7 +144,7 @@ class ErrorHandler(QObject):
return ""
# reverse to list most likely suspect first, dict to deduplicate:
addons = [
- mw.addonManager.addonName(i) for i in dict.fromkeys(reversed(matches))
+ aqt.mw.addonManager.addonName(i) for i in dict.fromkeys(reversed(matches))
]
# highlight importance of first add-on:
addons[0] = f"{addons[0]}"
diff --git a/qt/aqt/exporting.py b/qt/aqt/exporting.py
index 4866b6926..de8009760 100644
--- a/qt/aqt/exporting.py
+++ b/qt/aqt/exporting.py
@@ -15,6 +15,7 @@ from anki import hooks
from anki.cards import CardId
from anki.decks import DeckId
from anki.exporting import Exporter, exporters
+from aqt.errors import show_exception
from aqt.qt import *
from aqt.utils import (
checkInvalidFilename,
@@ -174,10 +175,11 @@ class ExportDialog(QDialog):
try:
# raises if exporter failed
future.result()
- except Exception as e:
- traceback.print_exc(file=sys.stdout)
- showWarning(str(e))
- self.on_export_finished()
+ except Exception as exc:
+ show_exception(parent=self.mw, exception=exc)
+ self.on_export_failed()
+ else:
+ self.on_export_finished()
self.mw.progress.start()
hooks.media_files_did_export.append(exported_media)
@@ -195,3 +197,8 @@ class ExportDialog(QDialog):
msg = tr.exporting_card_exported(count=self.exporter.count)
tooltip(msg, period=3000)
QDialog.reject(self)
+
+ def on_export_failed(self) -> None:
+ if self.isVerbatim:
+ self.mw.reopen()
+ QDialog.reject(self)
diff --git a/qt/aqt/operations/__init__.py b/qt/aqt/operations/__init__.py
index a2d3eb7db..0fd4dbad1 100644
--- a/qt/aqt/operations/__init__.py
+++ b/qt/aqt/operations/__init__.py
@@ -15,6 +15,7 @@ from anki.collection import (
OpChangesWithCount,
OpChangesWithId,
)
+from aqt.errors import show_exception
from aqt.qt import QWidget
from aqt.utils import showWarning
@@ -101,7 +102,7 @@ class CollectionOp(Generic[ResultWithChanges]):
if self._failure:
self._failure(exception)
else:
- showWarning(str(exception), self._parent)
+ show_exception(parent=self._parent, exception=exception)
return
else:
# BaseException like SystemExit; rethrow it
diff --git a/qt/aqt/profiles.py b/qt/aqt/profiles.py
index 2ef8821d4..8f09c3253 100644
--- a/qt/aqt/profiles.py
+++ b/qt/aqt/profiles.py
@@ -9,10 +9,9 @@ import random
import shutil
import traceback
from enum import Enum
+from pathlib import Path
from typing import Any
-from send2trash import send2trash
-
import anki.lang
import aqt.forms
import aqt.sound
@@ -24,7 +23,7 @@ from anki.utils import int_time, is_mac, is_win, point_version
from aqt import appHelpSite
from aqt.qt import *
from aqt.theme import Theme
-from aqt.utils import disable_help_button, showWarning, tr
+from aqt.utils import disable_help_button, send_to_trash, showWarning, tr
# Profile handling
##########################################################################
@@ -233,16 +232,14 @@ class ProfileManager:
self.db.commit()
def remove(self, name: str) -> None:
- p = self.profileFolder()
- if os.path.exists(p):
- send2trash(p)
+ path = self.profileFolder(create=False)
+ send_to_trash(Path(path))
self.db.execute("delete from profiles where name = ?", name)
self.db.commit()
def trashCollection(self) -> None:
- p = self.collectionPath()
- if os.path.exists(p):
- send2trash(p)
+ path = self.collectionPath()
+ send_to_trash(Path(path))
def rename(self, name: str) -> None:
oldName = self.name
diff --git a/qt/aqt/utils.py b/qt/aqt/utils.py
index 88dfc5764..3c4ed09b3 100644
--- a/qt/aqt/utils.py
+++ b/qt/aqt/utils.py
@@ -4,11 +4,15 @@ from __future__ import annotations
import os
import re
+import shutil
import subprocess
import sys
from functools import wraps
+from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal, Sequence, no_type_check
+from send2trash import send2trash
+
import aqt
from anki._legacy import DeprecatedNamesMixinForModule
from anki.collection import Collection, HelpPage
@@ -24,7 +28,7 @@ from aqt.qt import *
from aqt.theme import theme_manager
if TYPE_CHECKING:
- TextFormat = Union[Literal["plain", "rich"]]
+ TextFormat = Literal["plain", "rich"]
def aqt_data_folder() -> str:
@@ -69,7 +73,7 @@ def openLink(link: str | QUrl) -> None:
def showWarning(
text: str,
parent: QWidget | None = None,
- help: HelpPageArgument = "",
+ help: HelpPageArgument | None = None,
title: str = "Anki",
textFormat: TextFormat | None = None,
) -> int:
@@ -91,7 +95,7 @@ def showCritical(
def showInfo(
text: str,
parent: QWidget | None = None,
- help: HelpPageArgument = "",
+ help: HelpPageArgument | None = None,
type: str = "info",
title: str = "Anki",
textFormat: TextFormat | None = None,
@@ -129,7 +133,7 @@ def showInfo(
else:
b = mb.addButton(QMessageBox.StandardButton.Ok)
b.setDefault(True)
- if help:
+ if help is not None:
b = mb.addButton(QMessageBox.StandardButton.Help)
qconnect(b.clicked, lambda: openHelp(help))
b.setAutoDefault(False)
@@ -703,6 +707,21 @@ def current_window() -> QWidget | None:
return None
+def send_to_trash(path: Path) -> None:
+ "Place file/folder in recyling bin, or delete permanently on failure."
+ if not path.exists():
+ return
+ try:
+ send2trash(path)
+ except Exception as exc:
+ # Linux users may not have a trash folder set up
+ print("trash failure:", path, exc)
+ if path.is_dir:
+ shutil.rmtree(path)
+ else:
+ path.unlink()
+
+
# Tooltips
######################################################################
diff --git a/repos.bzl b/repos.bzl
index 937d0df86..39dfeaa6e 100644
--- a/repos.bzl
+++ b/repos.bzl
@@ -115,12 +115,12 @@ def register_repos():
################
core_i18n_repo = "anki-core-i18n"
- core_i18n_commit = "790e9c4d1730e4773b70640be84dab2d7e1aa993"
- core_i18n_zip_csum = "f3aaf9f7b33cab6a1677b3979514e480c7e45955459a62a4f2ed3811c8652a97"
+ core_i18n_commit = "bd995b3d74f37975554ebd03d3add4ea82bf663f"
+ core_i18n_zip_csum = "ace985f858958321d5919731981bce2b9356ea3e8fb43b0232a1dc6f55673f3d"
qtftl_i18n_repo = "anki-desktop-ftl"
- qtftl_i18n_commit = "12549835bd13c5a7565d01f118c05a12126471e0"
- qtftl_i18n_zip_csum = "9bd1a418b3bc92551e6543d68b639aa17b26048b735a2ee7b2becf36fd6003a4"
+ qtftl_i18n_commit = "5045d3604a20b0ae8ce14be2d3597d72c03ccad8"
+ qtftl_i18n_zip_csum = "45058ea33cb0e5d142cae8d4e926f5eb3dab4d207e7af0baeafda2b92f765806"
i18n_build_content = """
filegroup(
diff --git a/rslib/src/backend/error.rs b/rslib/src/backend/error.rs
index 165e1c7fb..85ed1ed90 100644
--- a/rslib/src/backend/error.rs
+++ b/rslib/src/backend/error.rs
@@ -11,6 +11,7 @@ use crate::{
impl AnkiError {
pub(super) fn into_protobuf(self, tr: &I18n) -> pb::BackendError {
let localized = self.localized_description(tr);
+ let help_page = self.help_page().map(|page| page as i32);
let kind = match self {
AnkiError::InvalidInput(_) => Kind::InvalidInput,
AnkiError::TemplateError(_) => Kind::TemplateParse,
@@ -24,10 +25,11 @@ impl AnkiError {
AnkiError::JsonError(_) => Kind::JsonError,
AnkiError::ProtoError(_) => Kind::ProtoError,
AnkiError::NotFound => Kind::NotFoundError,
+ AnkiError::Deleted => Kind::Deleted,
AnkiError::Existing => Kind::Exists,
AnkiError::FilteredDeckError(_) => Kind::FilteredDeckError,
AnkiError::SearchError(_) => Kind::SearchError,
- AnkiError::TemplateSaveError(_) => Kind::TemplateParse,
+ AnkiError::CardTypeError(_) => Kind::CardTypeError,
AnkiError::ParseNumError => Kind::InvalidInput,
AnkiError::InvalidRegex(_) => Kind::InvalidInput,
AnkiError::UndoEmpty => Kind::UndoEmpty,
@@ -42,6 +44,7 @@ impl AnkiError {
pb::BackendError {
kind: kind as i32,
localized,
+ help_page,
}
}
}
diff --git a/rslib/src/browser_table.rs b/rslib/src/browser_table.rs
index 9fefe4bf3..5bcdfb6fa 100644
--- a/rslib/src/browser_table.rs
+++ b/rslib/src/browser_table.rs
@@ -290,7 +290,15 @@ impl RowContext {
let cards;
let note;
if notes_mode {
- note = col.get_note_maybe_with_fields(NoteId(id), with_card_render)?;
+ note = col
+ .get_note_maybe_with_fields(NoteId(id), with_card_render)
+ .map_err(|e| {
+ if e == AnkiError::NotFound {
+ AnkiError::Deleted
+ } else {
+ e
+ }
+ })?;
cards = col.storage.all_cards_of_note(note.id)?;
if cards.is_empty() {
return Err(AnkiError::DatabaseCheckRequired);
@@ -299,7 +307,7 @@ impl RowContext {
cards = vec![col
.storage
.get_card(CardId(id))?
- .ok_or(AnkiError::NotFound)?];
+ .ok_or(AnkiError::Deleted)?];
note = col.get_note_maybe_with_fields(cards[0].note_id, with_card_render)?;
}
let notetype = col
diff --git a/rslib/src/error/mod.rs b/rslib/src/error/mod.rs
index 1f1464013..4c1eae0a7 100644
--- a/rslib/src/error/mod.rs
+++ b/rslib/src/error/mod.rs
@@ -14,7 +14,7 @@ pub use network::{NetworkError, NetworkErrorKind, SyncError, SyncErrorKind};
pub use search::{ParseError, SearchErrorKind};
use tempfile::PathPersistError;
-use crate::i18n::I18n;
+use crate::{i18n::I18n, links::HelpPage};
pub type Result = std::result::Result;
@@ -22,7 +22,7 @@ pub type Result = std::result::Result;
pub enum AnkiError {
InvalidInput(String),
TemplateError(String),
- TemplateSaveError(TemplateSaveError),
+ CardTypeError(CardTypeError),
IoError(String),
FileIoError(FileIoError),
DbError(DbError),
@@ -35,6 +35,10 @@ pub enum AnkiError {
CollectionNotOpen,
CollectionAlreadyOpen,
NotFound,
+ /// Indicates an absent card or note, but (unlike [AnkiError::NotFound]) in
+ /// a non-critical context like the browser table, where deleted ids are
+ /// deliberately not removed.
+ Deleted,
Existing,
FilteredDeckError(FilteredDeckError),
SearchError(SearchErrorKind),
@@ -67,20 +71,17 @@ impl AnkiError {
// already localized
info.into()
}
- AnkiError::TemplateSaveError(err) => {
+ AnkiError::CardTypeError(err) => {
let header =
tr.card_templates_invalid_template_number(err.ordinal + 1, &err.notetype);
let details = match err.details {
- TemplateSaveErrorDetails::TemplateError
- | TemplateSaveErrorDetails::NoSuchField => tr.card_templates_see_preview(),
- TemplateSaveErrorDetails::NoFrontField => tr.card_templates_no_front_field(),
- TemplateSaveErrorDetails::Duplicate(i) => {
- tr.card_templates_identical_front(i + 1)
- }
- TemplateSaveErrorDetails::MissingCloze => tr.card_templates_missing_cloze(),
- TemplateSaveErrorDetails::ExtraneousCloze => {
- tr.card_templates_extraneous_cloze()
+ CardTypeErrorDetails::TemplateError | CardTypeErrorDetails::NoSuchField => {
+ tr.card_templates_see_preview()
}
+ CardTypeErrorDetails::NoFrontField => tr.card_templates_no_front_field(),
+ CardTypeErrorDetails::Duplicate(i) => tr.card_templates_identical_front(i + 1),
+ CardTypeErrorDetails::MissingCloze => tr.card_templates_missing_cloze(),
+ CardTypeErrorDetails::ExtraneousCloze => tr.card_templates_extraneous_cloze(),
};
format!("{}
{}", header, details)
}
@@ -101,6 +102,7 @@ impl AnkiError {
AnkiError::MediaCheckRequired => tr.errors_please_check_media().into(),
AnkiError::CustomStudyError(err) => err.localized_description(tr),
AnkiError::ImportError(err) => err.localized_description(tr),
+ AnkiError::Deleted => tr.browsing_row_deleted().into(),
AnkiError::IoError(_)
| AnkiError::JsonError(_)
| AnkiError::ProtoError(_)
@@ -115,6 +117,21 @@ impl AnkiError {
}
}
}
+
+ pub fn help_page(&self) -> Option {
+ match self {
+ Self::CardTypeError(CardTypeError { details, .. }) => Some(match details {
+ CardTypeErrorDetails::TemplateError | CardTypeErrorDetails::NoSuchField => {
+ HelpPage::CardTypeTemplateError
+ }
+ CardTypeErrorDetails::Duplicate(_) => HelpPage::CardTypeDuplicate,
+ CardTypeErrorDetails::NoFrontField => HelpPage::CardTypeNoFrontField,
+ CardTypeErrorDetails::MissingCloze => HelpPage::CardTypeMissingCloze,
+ CardTypeErrorDetails::ExtraneousCloze => HelpPage::CardTypeExtraneousCloze,
+ }),
+ _ => None,
+ }
+ }
}
#[derive(Debug, PartialEq)]
@@ -169,14 +186,14 @@ impl From for AnkiError {
}
#[derive(Debug, PartialEq)]
-pub struct TemplateSaveError {
+pub struct CardTypeError {
pub notetype: String,
pub ordinal: usize,
- pub details: TemplateSaveErrorDetails,
+ pub details: CardTypeErrorDetails,
}
#[derive(Debug, PartialEq)]
-pub enum TemplateSaveErrorDetails {
+pub enum CardTypeErrorDetails {
TemplateError,
Duplicate(usize),
NoFrontField,
diff --git a/rslib/src/links.rs b/rslib/src/links.rs
index 739bc2ee4..670dc0178 100644
--- a/rslib/src/links.rs
+++ b/rslib/src/links.rs
@@ -30,6 +30,17 @@ impl HelpPage {
HelpPage::DeckOptions => "deck-options.html",
HelpPage::EditingFeatures => "editing.html#editing-features",
HelpPage::FullScreenIssue => "platform/windows/display-issues.html#full-screen",
+ HelpPage::CardTypeTemplateError => "templates/errors.html#template-syntax-error",
+ HelpPage::CardTypeDuplicate => "templates/errors.html#identical-front-sides",
+ HelpPage::CardTypeNoFrontField => {
+ "templates/errors.html#no-field-replacement-on-front-side"
+ }
+ HelpPage::CardTypeMissingCloze => {
+ "templates/errors.html#no-cloze-filter-on-cloze-notetype"
+ }
+ HelpPage::CardTypeExtraneousCloze => {
+ "templates/errors.html#cloze-filter-outside-cloze-notetype"
+ }
}
}
}
diff --git a/rslib/src/notetype/mod.rs b/rslib/src/notetype/mod.rs
index 233722f18..eabf38b98 100644
--- a/rslib/src/notetype/mod.rs
+++ b/rslib/src/notetype/mod.rs
@@ -42,7 +42,7 @@ pub use crate::backend_proto::{
};
use crate::{
define_newtype,
- error::{TemplateSaveError, TemplateSaveErrorDetails},
+ error::{CardTypeError, CardTypeErrorDetails},
prelude::*,
search::{Node, SearchNode},
storage::comma_separated_ids,
@@ -341,10 +341,10 @@ impl Notetype {
for (index, card) in self.templates.iter().enumerate() {
if let Some(old_index) = map.insert(&card.config.q_format, index) {
if !CARD_TAG.is_match(&card.config.q_format) {
- return Err(AnkiError::TemplateSaveError(TemplateSaveError {
+ return Err(AnkiError::CardTypeError(CardTypeError {
notetype: self.name.clone(),
ordinal: index,
- details: TemplateSaveErrorDetails::Duplicate(old_index),
+ details: CardTypeErrorDetails::Duplicate(old_index),
}));
}
}
@@ -364,18 +364,18 @@ impl Notetype {
if let (Some(q), Some(a)) = sides {
let q_fields = q.fields();
if q_fields.is_empty() {
- Some((index, TemplateSaveErrorDetails::NoFrontField))
+ Some((index, CardTypeErrorDetails::NoFrontField))
} else if self.unknown_field_name(q_fields.union(&a.fields())) {
- Some((index, TemplateSaveErrorDetails::NoSuchField))
+ Some((index, CardTypeErrorDetails::NoSuchField))
} else {
None
}
} else {
- Some((index, TemplateSaveErrorDetails::TemplateError))
+ Some((index, CardTypeErrorDetails::TemplateError))
}
})
{
- Err(AnkiError::TemplateSaveError(TemplateSaveError {
+ Err(AnkiError::CardTypeError(CardTypeError {
notetype: self.name.clone(),
ordinal: invalid_index,
details,
@@ -405,10 +405,10 @@ impl Notetype {
parsed_templates: &[(Option, Option)],
) -> Result<()> {
if self.is_cloze() && missing_cloze_filter(parsed_templates) {
- return Err(AnkiError::TemplateSaveError(TemplateSaveError {
+ return Err(AnkiError::CardTypeError(CardTypeError {
notetype: self.name.clone(),
ordinal: 0,
- details: TemplateSaveErrorDetails::MissingCloze,
+ details: CardTypeErrorDetails::MissingCloze,
}));
}
Ok(())
diff --git a/rslib/src/notetype/notetypechange.rs b/rslib/src/notetype/notetypechange.rs
index 000030744..35c796b99 100644
--- a/rslib/src/notetype/notetypechange.rs
+++ b/rslib/src/notetype/notetypechange.rs
@@ -295,7 +295,7 @@ impl Collection {
let ords =
SearchBuilder::any(map.removed.iter().map(|o| TemplateKind::Ordinal(*o as u16)));
self.search_cards_into_table(
- SearchBuilder::from(nids).and_join(&mut ords.group()),
+ SearchBuilder::from(nids).and(ords.group()),
SortMode::NoOrder,
)?;
for card in self.storage.all_searched_cards()? {
@@ -320,7 +320,7 @@ impl Collection {
.map(|o| TemplateKind::Ordinal(*o as u16)),
);
self.search_cards_into_table(
- SearchBuilder::from(nids).and_join(&mut ords.group()),
+ SearchBuilder::from(nids).and(ords.group()),
SortMode::NoOrder,
)?;
for mut card in self.storage.all_searched_cards()? {
diff --git a/rslib/src/notetype/schemachange.rs b/rslib/src/notetype/schemachange.rs
index 630712833..cb68f69f2 100644
--- a/rslib/src/notetype/schemachange.rs
+++ b/rslib/src/notetype/schemachange.rs
@@ -143,10 +143,9 @@ impl Collection {
// remove any cards where the template was deleted
if !changes.removed.is_empty() {
- let mut ords =
- SearchBuilder::any(changes.removed.into_iter().map(TemplateKind::Ordinal));
+ let ords = SearchBuilder::any(changes.removed.into_iter().map(TemplateKind::Ordinal));
self.search_cards_into_table(
- SearchBuilder::from(nt.id).and_join(&mut ords),
+ SearchBuilder::from(nt.id).and(ords.group()),
SortMode::NoOrder,
)?;
for card in self.storage.all_searched_cards()? {
@@ -157,10 +156,9 @@ impl Collection {
// update ordinals for cards with a repositioned template
if !changes.moved.is_empty() {
- let mut ords =
- SearchBuilder::any(changes.moved.keys().cloned().map(TemplateKind::Ordinal));
+ let ords = SearchBuilder::any(changes.moved.keys().cloned().map(TemplateKind::Ordinal));
self.search_cards_into_table(
- SearchBuilder::from(nt.id).and_join(&mut ords),
+ SearchBuilder::from(nt.id).and(ords.group()),
SortMode::NoOrder,
)?;
for mut card in self.storage.all_searched_cards()? {
diff --git a/rslib/src/scheduler/filtered/custom_study.rs b/rslib/src/scheduler/filtered/custom_study.rs
index c224c44dd..1ab390bdc 100644
--- a/rslib/src/scheduler/filtered/custom_study.rs
+++ b/rslib/src/scheduler/filtered/custom_study.rs
@@ -237,10 +237,7 @@ fn cram_config(deck_name: String, cram: &Cram) -> Result {
};
let search = nodes
- .and_join(&mut tags_to_nodes(
- &cram.tags_to_include,
- &cram.tags_to_exclude,
- ))
+ .and(tags_to_nodes(&cram.tags_to_include, &cram.tags_to_exclude))
.and(SearchNode::from_deck_name(&deck_name))
.write();
@@ -258,13 +255,13 @@ fn tags_to_nodes(tags_to_include: &[String], tags_to_exclude: &[String]) -> Sear
.iter()
.map(|tag| SearchNode::from_tag_name(tag)),
);
- let mut exclude_nodes = SearchBuilder::all(
+ let exclude_nodes = SearchBuilder::all(
tags_to_exclude
.iter()
.map(|tag| SearchNode::from_tag_name(tag).negated()),
);
- include_nodes.group().and_join(&mut exclude_nodes)
+ include_nodes.group().and(exclude_nodes)
}
#[cfg(test)]
diff --git a/rslib/src/search/builder.rs b/rslib/src/search/builder.rs
index e5c4e6067..a234b27a5 100644
--- a/rslib/src/search/builder.rs
+++ b/rslib/src/search/builder.rs
@@ -59,19 +59,23 @@ impl SearchBuilder {
self.0.len()
}
- pub fn and>(mut self, node: N) -> Self {
- if !self.is_empty() {
- self.0.push(Node::And)
- }
- self.0.push(node.into());
- self
+ /// Concatenates the two sets of [Node]s, inserting [Node::And] if appropriate.
+ /// No implicit grouping is done.
+ pub fn and(self, other: impl Into) -> Self {
+ self.join_other(other.into(), Node::And)
}
- pub fn or>(mut self, node: N) -> Self {
- if !self.is_empty() {
- self.0.push(Node::Or)
+ /// Concatenates the two sets of [Node]s, inserting [Node::Or] if appropriate.
+ /// No implicit grouping is done.
+ pub fn or(self, other: impl Into) -> Self {
+ self.join_other(other.into(), Node::Or)
+ }
+
+ fn join_other(mut self, mut other: Self, joiner: Node) -> Self {
+ if !(self.is_empty() || other.is_empty()) {
+ self.0.push(joiner);
}
- self.0.push(node.into());
+ self.0.append(&mut other.0);
self
}
@@ -83,26 +87,6 @@ impl SearchBuilder {
self
}
- /// Concatenate [Node]s of `other`, inserting [Node::And] if appropriate.
- /// No implicit grouping is done.
- pub fn and_join(mut self, other: &mut Self) -> Self {
- if !(self.is_empty() || other.is_empty()) {
- self.0.push(Node::And);
- }
- self.0.append(&mut other.0);
- self
- }
-
- /// Concatenate [Node]s of `other`, inserting [Node::Or] if appropriate.
- /// No implicit grouping is done.
- pub fn or_join(mut self, other: &mut Self) -> Self {
- if !(self.is_empty() || other.is_empty()) {
- self.0.push(Node::And);
- }
- self.0.append(&mut other.0);
- self
- }
-
pub fn write(&self) -> String {
write_nodes(&self.0)
}
diff --git a/ts/components/Shortcut.svelte b/ts/components/Shortcut.svelte
index 29d63d098..0c8e77bfe 100644
--- a/ts/components/Shortcut.svelte
+++ b/ts/components/Shortcut.svelte
@@ -9,13 +9,18 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { registerShortcut } from "../lib/shortcuts";
export let keyCombination: string;
+ export let event: "keydown" | "keyup" | undefined = undefined;
const dispatch = createEventDispatcher();
onMount(() =>
- registerShortcut((event: KeyboardEvent) => {
- preventDefault(event);
- dispatch("action", { originalEvent: event });
- }, keyCombination),
+ registerShortcut(
+ (event: KeyboardEvent) => {
+ preventDefault(event);
+ dispatch("action", { originalEvent: event });
+ },
+ keyCombination,
+ { event },
+ ),
);
diff --git a/ts/deck-options/SpinBoxFloat.svelte b/ts/deck-options/SpinBoxFloat.svelte
index 88acb76d4..2a3d9cd3a 100644
--- a/ts/deck-options/SpinBoxFloat.svelte
+++ b/ts/deck-options/SpinBoxFloat.svelte
@@ -3,7 +3,6 @@ Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->