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 -->