Merge remote-tracking branch 'upstream/main' into apkg

This commit is contained in:
RumovZ 2022-03-29 16:52:41 +02:00
commit aab518d4d9
32 changed files with 282 additions and 148 deletions

View file

@ -7,7 +7,6 @@ errors-standard-popup =
If problems persist, please report the problem on our { -errors-support-site }. If problems persist, please report the problem on our { -errors-support-site }.
Please copy and paste the information below into your report. 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 = errors-addons-active-popup =
# Error # Error
@ -19,7 +18,7 @@ errors-addons-active-popup =
repeating until you discover the add-on that is causing the problem. 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 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: Debug info:
errors-accessing-db = errors-accessing-db =
@ -41,3 +40,7 @@ errors-unable-open-collection =
Debug info: 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. 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/)

View file

@ -5,6 +5,8 @@ syntax = "proto3";
package anki.backend; package anki.backend;
import "anki/links.proto";
/// while the protobuf descriptors expose the order services are defined in, /// 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 /// that information is not available in prost, so we define an enum to make
/// sure all clients agree on the service index /// sure all clients agree on the service index
@ -58,10 +60,14 @@ message BackendError {
SEARCH_ERROR = 14; SEARCH_ERROR = 14;
CUSTOM_STUDY_ERROR = 15; CUSTOM_STUDY_ERROR = 15;
IMPORT_ERROR = 16; IMPORT_ERROR = 16;
DELETED = 17;
CARD_TYPE_ERROR = 18;
} }
// localized error description suitable for displaying to the user // localized error description suitable for displaying to the user
string localized = 1; string localized = 1;
// the error subtype // the error subtype
Kind kind = 2; Kind kind = 2;
// optional page in the manual
optional links.HelpPageLinkRequest.HelpPage help_page = 3;
} }

View file

@ -31,6 +31,11 @@ message HelpPageLinkRequest {
DECK_OPTIONS = 15; DECK_OPTIONS = 15;
EDITING_FEATURES = 16; EDITING_FEATURES = 16;
FULL_SCREEN_ISSUE = 17; 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; HelpPage page = 1;
} }

View file

@ -5,7 +5,7 @@ from __future__ import annotations
import sys import sys
import traceback import traceback
from typing import Any, Sequence, Union from typing import Any, Sequence
from weakref import ref from weakref import ref
from markdown import markdown from markdown import markdown
@ -20,6 +20,7 @@ from anki.utils import from_json_bytes, to_json_bytes
from ..errors import ( from ..errors import (
BackendIOError, BackendIOError,
CardTypeError,
CustomStudyError, CustomStudyError,
DBError, DBError,
ExistsError, ExistsError,
@ -189,6 +190,9 @@ def backend_exception_to_pylib(err: backend_pb2.BackendError) -> Exception:
elif val == kind.DB_ERROR: elif val == kind.DB_ERROR:
return DBError(err.localized) return DBError(err.localized)
elif val == kind.CARD_TYPE_ERROR:
return CardTypeError(err.localized, err.help_page)
elif val == kind.TEMPLATE_PARSE: elif val == kind.TEMPLATE_PARSE:
return TemplateError(err.localized) return TemplateError(err.localized)

View file

@ -4,6 +4,10 @@
from __future__ import annotations from __future__ import annotations
from enum import Enum from enum import Enum
from typing import TYPE_CHECKING
if TYPE_CHECKING:
import anki.collection
class LocalizedError(Exception): class LocalizedError(Exception):
@ -17,6 +21,14 @@ class LocalizedError(Exception):
return self._localized 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): class Interrupted(Exception):
pass pass
@ -48,6 +60,10 @@ class DBError(LocalizedError):
pass pass
class CardTypeError(DocumentedError):
pass
class TemplateError(LocalizedError): class TemplateError(LocalizedError):
pass pass
@ -56,6 +72,10 @@ class NotFoundError(Exception):
pass pass
class DeletedError(LocalizedError):
pass
class ExistsError(Exception): class ExistsError(Exception):
pass pass

View file

@ -12,6 +12,7 @@ from collections import defaultdict
from concurrent.futures import Future from concurrent.futures import Future
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from pathlib import Path
from typing import IO, Any, Callable, Iterable, Union from typing import IO, Any, Callable, Iterable, Union
from urllib.parse import parse_qs, urlparse from urllib.parse import parse_qs, urlparse
from zipfile import ZipFile from zipfile import ZipFile
@ -20,7 +21,6 @@ import jsonschema
import markdown import markdown
from jsonschema.exceptions import ValidationError from jsonschema.exceptions import ValidationError
from markdown.extensions import md_in_html from markdown.extensions import md_in_html
from send2trash import send2trash
import anki import anki
import anki.utils import anki.utils
@ -42,6 +42,7 @@ from aqt.utils import (
restoreSplitter, restoreSplitter,
saveGeom, saveGeom,
saveSplitter, saveSplitter,
send_to_trash,
showInfo, showInfo,
showWarning, showWarning,
tooltip, tooltip,
@ -452,7 +453,7 @@ class AddonManager:
# true on success # true on success
def deleteAddon(self, module: str) -> bool: def deleteAddon(self, module: str) -> bool:
try: try:
send2trash(self.addonsFolder(module)) send_to_trash(Path(self.addonsFolder(module)))
return True return True
except OSError as e: except OSError as e:
showWarning( showWarning(

View file

@ -37,7 +37,7 @@ class Cell:
class CellRow: class CellRow:
is_deleted: bool = False is_disabled: bool = False
def __init__( def __init__(
self, self,
@ -69,9 +69,9 @@ class CellRow:
return CellRow.generic(length, "...") return CellRow.generic(length, "...")
@staticmethod @staticmethod
def deleted(length: int) -> CellRow: def disabled(length: int, cell_text: str) -> CellRow:
row = CellRow.generic(length, tr.browsing_row_deleted()) row = CellRow.generic(length, cell_text)
row.is_deleted = True row.is_disabled = True
return row return row

View file

@ -11,7 +11,7 @@ from anki.cards import Card, CardId
from anki.collection import BrowserColumns as Columns from anki.collection import BrowserColumns as Columns
from anki.collection import Collection from anki.collection import Collection
from anki.consts import * from anki.consts import *
from anki.errors import NotFoundError from anki.errors import LocalizedError, NotFoundError
from anki.notes import Note, NoteId from anki.notes import Note, NoteId
from aqt import gui_hooks from aqt import gui_hooks
from aqt.browser.table import Cell, CellRow, Column, ItemId, SearchContext 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 # row state has changed if existence of cached and fetched counterparts differ
# if the row was previously uncached, it is assumed to have existed # if the row was previously uncached, it is assumed to have existed
state_change = ( state_change = (
new_row.is_deleted new_row.is_disabled
if old_row is None 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: 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 self._rows[item] = new_row
if state_change: 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] return self._rows[item]
def _fetch_row_from_backend(self, item: ItemId) -> CellRow: def _fetch_row_from_backend(self, item: ItemId) -> CellRow:
try: try:
row = CellRow(*self.col.browser_row_for_id(item)) row = CellRow(*self.col.browser_row_for_id(item))
except NotFoundError: except LocalizedError as e:
return CellRow.deleted(self.len_columns()) return CellRow.disabled(self.len_columns(), str(e))
except Exception as 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: except BaseException as e:
# fatal error like a panic in the backend - dump it to the # fatal error like a panic in the backend - dump it to the
# console so it gets picked up by the error handler # 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.""" """Try to return the indicated, possibly deleted card."""
if not index.isValid(): if not index.isValid():
return None return None
try: # The browser code will be calling .note() on the returned card.
return self._state.get_card(self.get_item(index)) # This implicitly ensures both the card and its note exist.
except NotFoundError: if self.get_row(index).is_disabled:
return None return None
return self._state.get_card(self.get_item(index))
def get_note(self, index: QModelIndex) -> Note | None: def get_note(self, index: QModelIndex) -> Note | None:
"""Try to return the indicated, possibly deleted note.""" """Try to return the indicated, possibly deleted note."""
@ -341,7 +344,7 @@ class DataModel(QAbstractTableModel):
def flags(self, index: QModelIndex) -> Qt.ItemFlag: def flags(self, index: QModelIndex) -> Qt.ItemFlag:
# shortcut for large selections (Ctrl+A) to avoid fetching large numbers of rows at once # shortcut for large selections (Ctrl+A) to avoid fetching large numbers of rows at once
if row := self.get_cached_row(index): if row := self.get_cached_row(index):
if row.is_deleted: if row.is_disabled:
return Qt.ItemFlag(Qt.ItemFlag.NoItemFlags) return Qt.ItemFlag(Qt.ItemFlag.NoItemFlags)
return Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable return Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable

View file

@ -225,7 +225,7 @@ class Table:
bottom = max(r.row() for r in self._selected()) + 1 bottom = max(r.row() for r in self._selected()) + 1
for row in range(bottom, self.len()): for row in range(bottom, self.len()):
index = self._model.index(row, 0) index = self._model.index(row, 0)
if self._model.get_row(index).is_deleted: if self._model.get_row(index).is_disabled:
continue continue
if self._model.get_note_id(index) in nids: if self._model.get_note_id(index) in nids:
continue continue
@ -235,7 +235,7 @@ class Table:
top = min(r.row() for r in self._selected()) - 1 top = min(r.row() for r in self._selected()) - 1
for row in range(top, -1, -1): for row in range(top, -1, -1):
index = self._model.index(row, 0) index = self._model.index(row, 0)
if self._model.get_row(index).is_deleted: if self._model.get_row(index).is_disabled:
continue continue
if self._model.get_note_id(index) in nids: if self._model.get_note_id(index) in nids:
continue continue

View file

@ -1,18 +1,41 @@
# Copyright: Ankitects Pty Ltd and contributors # Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
from __future__ import annotations
import html import html
import re import re
import sys import sys
import traceback import traceback
from typing import Optional, TextIO, cast from typing import TYPE_CHECKING, Optional, TextIO, cast
from markdown import markdown from markdown import markdown
from aqt import mw import aqt
from aqt.main import AnkiQt from anki.errors import DocumentedError, LocalizedError
from aqt.qt import * from aqt.qt import *
from aqt.utils import showText, showWarning, supportText, tr 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"): if not os.environ.get("DEBUG"):
def excepthook(etype, val, tb) -> None: # type: ignore def excepthook(etype, val, tb) -> None: # type: ignore
@ -98,7 +121,12 @@ class ErrorHandler(QObject):
) )
error = f"{supportText() + self._addonText(error)}\n{error}" error = f"{supportText() + self._addonText(error)}\n{error}"
elif self.mw.addonManager.dirty: 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}" error = f"{supportText() + self._addonText(error)}\n{error}"
else: else:
txt = markdown(tr.errors_standard_popup()) txt = markdown(tr.errors_standard_popup())
@ -116,7 +144,7 @@ class ErrorHandler(QObject):
return "" return ""
# reverse to list most likely suspect first, dict to deduplicate: # reverse to list most likely suspect first, dict to deduplicate:
addons = [ 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: # highlight importance of first add-on:
addons[0] = f"<b>{addons[0]}</b>" addons[0] = f"<b>{addons[0]}</b>"

View file

@ -15,6 +15,7 @@ from anki import hooks
from anki.cards import CardId from anki.cards import CardId
from anki.decks import DeckId from anki.decks import DeckId
from anki.exporting import Exporter, exporters from anki.exporting import Exporter, exporters
from aqt.errors import show_exception
from aqt.qt import * from aqt.qt import *
from aqt.utils import ( from aqt.utils import (
checkInvalidFilename, checkInvalidFilename,
@ -174,9 +175,10 @@ class ExportDialog(QDialog):
try: try:
# raises if exporter failed # raises if exporter failed
future.result() future.result()
except Exception as e: except Exception as exc:
traceback.print_exc(file=sys.stdout) show_exception(parent=self.mw, exception=exc)
showWarning(str(e)) self.on_export_failed()
else:
self.on_export_finished() self.on_export_finished()
self.mw.progress.start() self.mw.progress.start()
@ -195,3 +197,8 @@ class ExportDialog(QDialog):
msg = tr.exporting_card_exported(count=self.exporter.count) msg = tr.exporting_card_exported(count=self.exporter.count)
tooltip(msg, period=3000) tooltip(msg, period=3000)
QDialog.reject(self) QDialog.reject(self)
def on_export_failed(self) -> None:
if self.isVerbatim:
self.mw.reopen()
QDialog.reject(self)

View file

@ -15,6 +15,7 @@ from anki.collection import (
OpChangesWithCount, OpChangesWithCount,
OpChangesWithId, OpChangesWithId,
) )
from aqt.errors import show_exception
from aqt.qt import QWidget from aqt.qt import QWidget
from aqt.utils import showWarning from aqt.utils import showWarning
@ -101,7 +102,7 @@ class CollectionOp(Generic[ResultWithChanges]):
if self._failure: if self._failure:
self._failure(exception) self._failure(exception)
else: else:
showWarning(str(exception), self._parent) show_exception(parent=self._parent, exception=exception)
return return
else: else:
# BaseException like SystemExit; rethrow it # BaseException like SystemExit; rethrow it

View file

@ -9,10 +9,9 @@ import random
import shutil import shutil
import traceback import traceback
from enum import Enum from enum import Enum
from pathlib import Path
from typing import Any from typing import Any
from send2trash import send2trash
import anki.lang import anki.lang
import aqt.forms import aqt.forms
import aqt.sound 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 import appHelpSite
from aqt.qt import * from aqt.qt import *
from aqt.theme import Theme 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 # Profile handling
########################################################################## ##########################################################################
@ -233,16 +232,14 @@ class ProfileManager:
self.db.commit() self.db.commit()
def remove(self, name: str) -> None: def remove(self, name: str) -> None:
p = self.profileFolder() path = self.profileFolder(create=False)
if os.path.exists(p): send_to_trash(Path(path))
send2trash(p)
self.db.execute("delete from profiles where name = ?", name) self.db.execute("delete from profiles where name = ?", name)
self.db.commit() self.db.commit()
def trashCollection(self) -> None: def trashCollection(self) -> None:
p = self.collectionPath() path = self.collectionPath()
if os.path.exists(p): send_to_trash(Path(path))
send2trash(p)
def rename(self, name: str) -> None: def rename(self, name: str) -> None:
oldName = self.name oldName = self.name

View file

@ -4,11 +4,15 @@ from __future__ import annotations
import os import os
import re import re
import shutil
import subprocess import subprocess
import sys import sys
from functools import wraps from functools import wraps
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal, Sequence, no_type_check from typing import TYPE_CHECKING, Any, Literal, Sequence, no_type_check
from send2trash import send2trash
import aqt import aqt
from anki._legacy import DeprecatedNamesMixinForModule from anki._legacy import DeprecatedNamesMixinForModule
from anki.collection import Collection, HelpPage from anki.collection import Collection, HelpPage
@ -24,7 +28,7 @@ from aqt.qt import *
from aqt.theme import theme_manager from aqt.theme import theme_manager
if TYPE_CHECKING: if TYPE_CHECKING:
TextFormat = Union[Literal["plain", "rich"]] TextFormat = Literal["plain", "rich"]
def aqt_data_folder() -> str: def aqt_data_folder() -> str:
@ -69,7 +73,7 @@ def openLink(link: str | QUrl) -> None:
def showWarning( def showWarning(
text: str, text: str,
parent: QWidget | None = None, parent: QWidget | None = None,
help: HelpPageArgument = "", help: HelpPageArgument | None = None,
title: str = "Anki", title: str = "Anki",
textFormat: TextFormat | None = None, textFormat: TextFormat | None = None,
) -> int: ) -> int:
@ -91,7 +95,7 @@ def showCritical(
def showInfo( def showInfo(
text: str, text: str,
parent: QWidget | None = None, parent: QWidget | None = None,
help: HelpPageArgument = "", help: HelpPageArgument | None = None,
type: str = "info", type: str = "info",
title: str = "Anki", title: str = "Anki",
textFormat: TextFormat | None = None, textFormat: TextFormat | None = None,
@ -129,7 +133,7 @@ def showInfo(
else: else:
b = mb.addButton(QMessageBox.StandardButton.Ok) b = mb.addButton(QMessageBox.StandardButton.Ok)
b.setDefault(True) b.setDefault(True)
if help: if help is not None:
b = mb.addButton(QMessageBox.StandardButton.Help) b = mb.addButton(QMessageBox.StandardButton.Help)
qconnect(b.clicked, lambda: openHelp(help)) qconnect(b.clicked, lambda: openHelp(help))
b.setAutoDefault(False) b.setAutoDefault(False)
@ -703,6 +707,21 @@ def current_window() -> QWidget | None:
return 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 # Tooltips
###################################################################### ######################################################################

View file

@ -115,12 +115,12 @@ def register_repos():
################ ################
core_i18n_repo = "anki-core-i18n" core_i18n_repo = "anki-core-i18n"
core_i18n_commit = "790e9c4d1730e4773b70640be84dab2d7e1aa993" core_i18n_commit = "bd995b3d74f37975554ebd03d3add4ea82bf663f"
core_i18n_zip_csum = "f3aaf9f7b33cab6a1677b3979514e480c7e45955459a62a4f2ed3811c8652a97" core_i18n_zip_csum = "ace985f858958321d5919731981bce2b9356ea3e8fb43b0232a1dc6f55673f3d"
qtftl_i18n_repo = "anki-desktop-ftl" qtftl_i18n_repo = "anki-desktop-ftl"
qtftl_i18n_commit = "12549835bd13c5a7565d01f118c05a12126471e0" qtftl_i18n_commit = "5045d3604a20b0ae8ce14be2d3597d72c03ccad8"
qtftl_i18n_zip_csum = "9bd1a418b3bc92551e6543d68b639aa17b26048b735a2ee7b2becf36fd6003a4" qtftl_i18n_zip_csum = "45058ea33cb0e5d142cae8d4e926f5eb3dab4d207e7af0baeafda2b92f765806"
i18n_build_content = """ i18n_build_content = """
filegroup( filegroup(

View file

@ -11,6 +11,7 @@ use crate::{
impl AnkiError { impl AnkiError {
pub(super) fn into_protobuf(self, tr: &I18n) -> pb::BackendError { pub(super) fn into_protobuf(self, tr: &I18n) -> pb::BackendError {
let localized = self.localized_description(tr); let localized = self.localized_description(tr);
let help_page = self.help_page().map(|page| page as i32);
let kind = match self { let kind = match self {
AnkiError::InvalidInput(_) => Kind::InvalidInput, AnkiError::InvalidInput(_) => Kind::InvalidInput,
AnkiError::TemplateError(_) => Kind::TemplateParse, AnkiError::TemplateError(_) => Kind::TemplateParse,
@ -24,10 +25,11 @@ impl AnkiError {
AnkiError::JsonError(_) => Kind::JsonError, AnkiError::JsonError(_) => Kind::JsonError,
AnkiError::ProtoError(_) => Kind::ProtoError, AnkiError::ProtoError(_) => Kind::ProtoError,
AnkiError::NotFound => Kind::NotFoundError, AnkiError::NotFound => Kind::NotFoundError,
AnkiError::Deleted => Kind::Deleted,
AnkiError::Existing => Kind::Exists, AnkiError::Existing => Kind::Exists,
AnkiError::FilteredDeckError(_) => Kind::FilteredDeckError, AnkiError::FilteredDeckError(_) => Kind::FilteredDeckError,
AnkiError::SearchError(_) => Kind::SearchError, AnkiError::SearchError(_) => Kind::SearchError,
AnkiError::TemplateSaveError(_) => Kind::TemplateParse, AnkiError::CardTypeError(_) => Kind::CardTypeError,
AnkiError::ParseNumError => Kind::InvalidInput, AnkiError::ParseNumError => Kind::InvalidInput,
AnkiError::InvalidRegex(_) => Kind::InvalidInput, AnkiError::InvalidRegex(_) => Kind::InvalidInput,
AnkiError::UndoEmpty => Kind::UndoEmpty, AnkiError::UndoEmpty => Kind::UndoEmpty,
@ -42,6 +44,7 @@ impl AnkiError {
pb::BackendError { pb::BackendError {
kind: kind as i32, kind: kind as i32,
localized, localized,
help_page,
} }
} }
} }

View file

@ -290,7 +290,15 @@ impl RowContext {
let cards; let cards;
let note; let note;
if notes_mode { 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)?; cards = col.storage.all_cards_of_note(note.id)?;
if cards.is_empty() { if cards.is_empty() {
return Err(AnkiError::DatabaseCheckRequired); return Err(AnkiError::DatabaseCheckRequired);
@ -299,7 +307,7 @@ impl RowContext {
cards = vec![col cards = vec![col
.storage .storage
.get_card(CardId(id))? .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)?; note = col.get_note_maybe_with_fields(cards[0].note_id, with_card_render)?;
} }
let notetype = col let notetype = col

View file

@ -14,7 +14,7 @@ pub use network::{NetworkError, NetworkErrorKind, SyncError, SyncErrorKind};
pub use search::{ParseError, SearchErrorKind}; pub use search::{ParseError, SearchErrorKind};
use tempfile::PathPersistError; use tempfile::PathPersistError;
use crate::i18n::I18n; use crate::{i18n::I18n, links::HelpPage};
pub type Result<T, E = AnkiError> = std::result::Result<T, E>; pub type Result<T, E = AnkiError> = std::result::Result<T, E>;
@ -22,7 +22,7 @@ pub type Result<T, E = AnkiError> = std::result::Result<T, E>;
pub enum AnkiError { pub enum AnkiError {
InvalidInput(String), InvalidInput(String),
TemplateError(String), TemplateError(String),
TemplateSaveError(TemplateSaveError), CardTypeError(CardTypeError),
IoError(String), IoError(String),
FileIoError(FileIoError), FileIoError(FileIoError),
DbError(DbError), DbError(DbError),
@ -35,6 +35,10 @@ pub enum AnkiError {
CollectionNotOpen, CollectionNotOpen,
CollectionAlreadyOpen, CollectionAlreadyOpen,
NotFound, 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, Existing,
FilteredDeckError(FilteredDeckError), FilteredDeckError(FilteredDeckError),
SearchError(SearchErrorKind), SearchError(SearchErrorKind),
@ -67,20 +71,17 @@ impl AnkiError {
// already localized // already localized
info.into() info.into()
} }
AnkiError::TemplateSaveError(err) => { AnkiError::CardTypeError(err) => {
let header = let header =
tr.card_templates_invalid_template_number(err.ordinal + 1, &err.notetype); tr.card_templates_invalid_template_number(err.ordinal + 1, &err.notetype);
let details = match err.details { let details = match err.details {
TemplateSaveErrorDetails::TemplateError CardTypeErrorDetails::TemplateError | CardTypeErrorDetails::NoSuchField => {
| TemplateSaveErrorDetails::NoSuchField => tr.card_templates_see_preview(), 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::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!("{}<br>{}", header, details) format!("{}<br>{}", header, details)
} }
@ -101,6 +102,7 @@ impl AnkiError {
AnkiError::MediaCheckRequired => tr.errors_please_check_media().into(), AnkiError::MediaCheckRequired => tr.errors_please_check_media().into(),
AnkiError::CustomStudyError(err) => err.localized_description(tr), AnkiError::CustomStudyError(err) => err.localized_description(tr),
AnkiError::ImportError(err) => err.localized_description(tr), AnkiError::ImportError(err) => err.localized_description(tr),
AnkiError::Deleted => tr.browsing_row_deleted().into(),
AnkiError::IoError(_) AnkiError::IoError(_)
| AnkiError::JsonError(_) | AnkiError::JsonError(_)
| AnkiError::ProtoError(_) | AnkiError::ProtoError(_)
@ -115,6 +117,21 @@ impl AnkiError {
} }
} }
} }
pub fn help_page(&self) -> Option<HelpPage> {
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)] #[derive(Debug, PartialEq)]
@ -169,14 +186,14 @@ impl From<regex::Error> for AnkiError {
} }
#[derive(Debug, PartialEq)] #[derive(Debug, PartialEq)]
pub struct TemplateSaveError { pub struct CardTypeError {
pub notetype: String, pub notetype: String,
pub ordinal: usize, pub ordinal: usize,
pub details: TemplateSaveErrorDetails, pub details: CardTypeErrorDetails,
} }
#[derive(Debug, PartialEq)] #[derive(Debug, PartialEq)]
pub enum TemplateSaveErrorDetails { pub enum CardTypeErrorDetails {
TemplateError, TemplateError,
Duplicate(usize), Duplicate(usize),
NoFrontField, NoFrontField,

View file

@ -30,6 +30,17 @@ impl HelpPage {
HelpPage::DeckOptions => "deck-options.html", HelpPage::DeckOptions => "deck-options.html",
HelpPage::EditingFeatures => "editing.html#editing-features", HelpPage::EditingFeatures => "editing.html#editing-features",
HelpPage::FullScreenIssue => "platform/windows/display-issues.html#full-screen", 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"
}
} }
} }
} }

View file

@ -42,7 +42,7 @@ pub use crate::backend_proto::{
}; };
use crate::{ use crate::{
define_newtype, define_newtype,
error::{TemplateSaveError, TemplateSaveErrorDetails}, error::{CardTypeError, CardTypeErrorDetails},
prelude::*, prelude::*,
search::{Node, SearchNode}, search::{Node, SearchNode},
storage::comma_separated_ids, storage::comma_separated_ids,
@ -341,10 +341,10 @@ impl Notetype {
for (index, card) in self.templates.iter().enumerate() { for (index, card) in self.templates.iter().enumerate() {
if let Some(old_index) = map.insert(&card.config.q_format, index) { if let Some(old_index) = map.insert(&card.config.q_format, index) {
if !CARD_TAG.is_match(&card.config.q_format) { if !CARD_TAG.is_match(&card.config.q_format) {
return Err(AnkiError::TemplateSaveError(TemplateSaveError { return Err(AnkiError::CardTypeError(CardTypeError {
notetype: self.name.clone(), notetype: self.name.clone(),
ordinal: index, 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 { if let (Some(q), Some(a)) = sides {
let q_fields = q.fields(); let q_fields = q.fields();
if q_fields.is_empty() { if q_fields.is_empty() {
Some((index, TemplateSaveErrorDetails::NoFrontField)) Some((index, CardTypeErrorDetails::NoFrontField))
} else if self.unknown_field_name(q_fields.union(&a.fields())) { } else if self.unknown_field_name(q_fields.union(&a.fields())) {
Some((index, TemplateSaveErrorDetails::NoSuchField)) Some((index, CardTypeErrorDetails::NoSuchField))
} else { } else {
None None
} }
} else { } else {
Some((index, TemplateSaveErrorDetails::TemplateError)) Some((index, CardTypeErrorDetails::TemplateError))
} }
}) })
{ {
Err(AnkiError::TemplateSaveError(TemplateSaveError { Err(AnkiError::CardTypeError(CardTypeError {
notetype: self.name.clone(), notetype: self.name.clone(),
ordinal: invalid_index, ordinal: invalid_index,
details, details,
@ -405,10 +405,10 @@ impl Notetype {
parsed_templates: &[(Option<ParsedTemplate>, Option<ParsedTemplate>)], parsed_templates: &[(Option<ParsedTemplate>, Option<ParsedTemplate>)],
) -> Result<()> { ) -> Result<()> {
if self.is_cloze() && missing_cloze_filter(parsed_templates) { if self.is_cloze() && missing_cloze_filter(parsed_templates) {
return Err(AnkiError::TemplateSaveError(TemplateSaveError { return Err(AnkiError::CardTypeError(CardTypeError {
notetype: self.name.clone(), notetype: self.name.clone(),
ordinal: 0, ordinal: 0,
details: TemplateSaveErrorDetails::MissingCloze, details: CardTypeErrorDetails::MissingCloze,
})); }));
} }
Ok(()) Ok(())

View file

@ -295,7 +295,7 @@ impl Collection {
let ords = let ords =
SearchBuilder::any(map.removed.iter().map(|o| TemplateKind::Ordinal(*o as u16))); SearchBuilder::any(map.removed.iter().map(|o| TemplateKind::Ordinal(*o as u16)));
self.search_cards_into_table( self.search_cards_into_table(
SearchBuilder::from(nids).and_join(&mut ords.group()), SearchBuilder::from(nids).and(ords.group()),
SortMode::NoOrder, SortMode::NoOrder,
)?; )?;
for card in self.storage.all_searched_cards()? { for card in self.storage.all_searched_cards()? {
@ -320,7 +320,7 @@ impl Collection {
.map(|o| TemplateKind::Ordinal(*o as u16)), .map(|o| TemplateKind::Ordinal(*o as u16)),
); );
self.search_cards_into_table( self.search_cards_into_table(
SearchBuilder::from(nids).and_join(&mut ords.group()), SearchBuilder::from(nids).and(ords.group()),
SortMode::NoOrder, SortMode::NoOrder,
)?; )?;
for mut card in self.storage.all_searched_cards()? { for mut card in self.storage.all_searched_cards()? {

View file

@ -143,10 +143,9 @@ impl Collection {
// remove any cards where the template was deleted // remove any cards where the template was deleted
if !changes.removed.is_empty() { if !changes.removed.is_empty() {
let mut ords = let ords = SearchBuilder::any(changes.removed.into_iter().map(TemplateKind::Ordinal));
SearchBuilder::any(changes.removed.into_iter().map(TemplateKind::Ordinal));
self.search_cards_into_table( self.search_cards_into_table(
SearchBuilder::from(nt.id).and_join(&mut ords), SearchBuilder::from(nt.id).and(ords.group()),
SortMode::NoOrder, SortMode::NoOrder,
)?; )?;
for card in self.storage.all_searched_cards()? { for card in self.storage.all_searched_cards()? {
@ -157,10 +156,9 @@ impl Collection {
// update ordinals for cards with a repositioned template // update ordinals for cards with a repositioned template
if !changes.moved.is_empty() { if !changes.moved.is_empty() {
let mut ords = let ords = SearchBuilder::any(changes.moved.keys().cloned().map(TemplateKind::Ordinal));
SearchBuilder::any(changes.moved.keys().cloned().map(TemplateKind::Ordinal));
self.search_cards_into_table( self.search_cards_into_table(
SearchBuilder::from(nt.id).and_join(&mut ords), SearchBuilder::from(nt.id).and(ords.group()),
SortMode::NoOrder, SortMode::NoOrder,
)?; )?;
for mut card in self.storage.all_searched_cards()? { for mut card in self.storage.all_searched_cards()? {

View file

@ -237,10 +237,7 @@ fn cram_config(deck_name: String, cram: &Cram) -> Result<FilteredDeck> {
}; };
let search = nodes let search = nodes
.and_join(&mut tags_to_nodes( .and(tags_to_nodes(&cram.tags_to_include, &cram.tags_to_exclude))
&cram.tags_to_include,
&cram.tags_to_exclude,
))
.and(SearchNode::from_deck_name(&deck_name)) .and(SearchNode::from_deck_name(&deck_name))
.write(); .write();
@ -258,13 +255,13 @@ fn tags_to_nodes(tags_to_include: &[String], tags_to_exclude: &[String]) -> Sear
.iter() .iter()
.map(|tag| SearchNode::from_tag_name(tag)), .map(|tag| SearchNode::from_tag_name(tag)),
); );
let mut exclude_nodes = SearchBuilder::all( let exclude_nodes = SearchBuilder::all(
tags_to_exclude tags_to_exclude
.iter() .iter()
.map(|tag| SearchNode::from_tag_name(tag).negated()), .map(|tag| SearchNode::from_tag_name(tag).negated()),
); );
include_nodes.group().and_join(&mut exclude_nodes) include_nodes.group().and(exclude_nodes)
} }
#[cfg(test)] #[cfg(test)]

View file

@ -59,19 +59,23 @@ impl SearchBuilder {
self.0.len() self.0.len()
} }
pub fn and<N: Into<Node>>(mut self, node: N) -> Self { /// Concatenates the two sets of [Node]s, inserting [Node::And] if appropriate.
if !self.is_empty() { /// No implicit grouping is done.
self.0.push(Node::And) pub fn and(self, other: impl Into<SearchBuilder>) -> Self {
} self.join_other(other.into(), Node::And)
self.0.push(node.into());
self
} }
pub fn or<N: Into<Node>>(mut self, node: N) -> Self { /// Concatenates the two sets of [Node]s, inserting [Node::Or] if appropriate.
if !self.is_empty() { /// No implicit grouping is done.
self.0.push(Node::Or) pub fn or(self, other: impl Into<SearchBuilder>) -> Self {
self.join_other(other.into(), Node::Or)
} }
self.0.push(node.into());
fn join_other(mut self, mut other: Self, joiner: Node) -> Self {
if !(self.is_empty() || other.is_empty()) {
self.0.push(joiner);
}
self.0.append(&mut other.0);
self self
} }
@ -83,26 +87,6 @@ impl SearchBuilder {
self 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 { pub fn write(&self) -> String {
write_nodes(&self.0) write_nodes(&self.0)
} }

View file

@ -9,13 +9,18 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { registerShortcut } from "../lib/shortcuts"; import { registerShortcut } from "../lib/shortcuts";
export let keyCombination: string; export let keyCombination: string;
export let event: "keydown" | "keyup" | undefined = undefined;
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
onMount(() => onMount(() =>
registerShortcut((event: KeyboardEvent) => { registerShortcut(
(event: KeyboardEvent) => {
preventDefault(event); preventDefault(event);
dispatch("action", { originalEvent: event }); dispatch("action", { originalEvent: event });
}, keyCombination), },
keyCombination,
{ event },
),
); );
</script> </script>

View file

@ -3,7 +3,6 @@ Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
--> -->
<script lang="ts"> <script lang="ts">
import { localizedNumber } from "../lib/i18n";
import { pageTheme } from "../sveltelib/theme"; import { pageTheme } from "../sveltelib/theme";
export let value: number; export let value: number;
@ -11,7 +10,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export let max = 9999; export let max = 9999;
let stringValue: string; let stringValue: string;
$: stringValue = localizedNumber(value, 2); $: stringValue = value.toFixed(2);
function update(this: HTMLInputElement): void { function update(this: HTMLInputElement): void {
value = Math.min(max, Math.max(min, parseFloat(this.value))); value = Math.min(max, Math.max(min, parseFloat(this.value)));

View file

@ -126,7 +126,7 @@ if (isApplePlatform()) {
export function preventBuiltinShortcuts(editable: HTMLElement): void { export function preventBuiltinShortcuts(editable: HTMLElement): void {
for (const keyCombination of ["Control+B", "Control+U", "Control+I"]) { for (const keyCombination of ["Control+B", "Control+U", "Control+I"]) {
registerShortcut(preventDefault, keyCombination, editable); registerShortcut(preventDefault, keyCombination, { target: editable });
} }
} }

View file

@ -22,8 +22,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
off = !off; off = !off;
} }
function shortcut(element: HTMLElement): void { function shortcut(target: HTMLElement): () => void {
registerShortcut(toggle, keyCombination, element); return registerShortcut(toggle, keyCombination, { target });
} }
onMount(() => editorField.element.then(shortcut)); onMount(() => editorField.element.then(shortcut));

View file

@ -27,8 +27,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
}); });
} }
function shortcut(element: HTMLElement): void { function shortcut(target: HTMLElement): () => void {
registerShortcut(toggle, keyCombination, element); return registerShortcut(toggle, keyCombination, { target });
} }
onMount(() => editorField.element.then(shortcut)); onMount(() => editorField.element.then(shortcut));

View file

@ -63,4 +63,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
{@html ellipseIcon} {@html ellipseIcon}
</IconButton> </IconButton>
<Shortcut {keyCombination} on:action={(event) => onCloze(event.detail.originalEvent)} /> <Shortcut
{keyCombination}
event="keyup"
on:action={(event) => onCloze(event.detail.originalEvent)}
/>

View file

@ -142,11 +142,28 @@ function innerShortcut(
} }
} }
export interface RegisterShortcutRestParams {
target: EventTarget;
/// There might be no good reason to use `keyup` other
/// than to circumvent Qt bugs
event: "keydown" | "keyup";
}
const defaultRegisterShortcutRestParams = {
target: document,
event: "keydown" as const,
};
export function registerShortcut( export function registerShortcut(
callback: (event: KeyboardEvent) => void, callback: (event: KeyboardEvent) => void,
keyCombinationString: string, keyCombinationString: string,
target: EventTarget | Document = document, restParams: Partial<RegisterShortcutRestParams> = defaultRegisterShortcutRestParams,
): () => void { ): () => void {
const {
target = defaultRegisterShortcutRestParams.target,
event = defaultRegisterShortcutRestParams.event,
} = restParams;
const [check, ...restChecks] = const [check, ...restChecks] =
splitKeyCombinationString(keyCombinationString).map(keyCombinationToCheck); splitKeyCombinationString(keyCombinationString).map(keyCombinationToCheck);
@ -156,7 +173,7 @@ export function registerShortcut(
} }
} }
return on(target, "keydown", handler); return on(target, event, handler);
} }
registerPackage("anki/shortcuts", { registerPackage("anki/shortcuts", {

View file

@ -1,26 +1,23 @@
// Copyright: Ankitects Pty Ltd and contributors // Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import type { RegisterShortcutRestParams } from "../lib/shortcuts";
import { registerShortcut } from "../lib/shortcuts"; import { registerShortcut } from "../lib/shortcuts";
interface ShortcutParams {
action: (event: KeyboardEvent) => void;
keyCombination: string;
params?: RegisterShortcutRestParams;
}
export function shortcut( export function shortcut(
_node: Node, _node: Node,
{ { action, keyCombination, params }: ShortcutParams,
action,
keyCombination,
target,
}: {
action: (event: KeyboardEvent) => void;
keyCombination: string;
target?: EventTarget;
},
): { destroy: () => void } { ): { destroy: () => void } {
const deregister = registerShortcut(action, keyCombination, target ?? document); const deregister = registerShortcut(action, keyCombination, params);
return { return {
destroy() { destroy: deregister,
deregister();
},
}; };
} }