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 }.
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/)

View file

@ -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;
}

View file

@ -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;
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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"<b>{addons[0]}</b>"

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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,
}
}
}

View file

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

View file

@ -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<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 {
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!("{}<br>{}", 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<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)]
@ -169,14 +186,14 @@ impl From<regex::Error> 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,

View file

@ -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"
}
}
}
}

View file

@ -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<ParsedTemplate>, Option<ParsedTemplate>)],
) -> 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(())

View file

@ -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()? {

View file

@ -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()? {

View file

@ -237,10 +237,7 @@ fn cram_config(deck_name: String, cram: &Cram) -> Result<FilteredDeck> {
};
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)]

View file

@ -59,19 +59,23 @@ impl SearchBuilder {
self.0.len()
}
pub fn and<N: Into<Node>>(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<SearchBuilder>) -> Self {
self.join_other(other.into(), Node::And)
}
pub fn or<N: Into<Node>>(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<SearchBuilder>) -> 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)
}

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";
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 },
),
);
</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
-->
<script lang="ts">
import { localizedNumber } from "../lib/i18n";
import { pageTheme } from "../sveltelib/theme";
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;
let stringValue: string;
$: stringValue = localizedNumber(value, 2);
$: stringValue = value.toFixed(2);
function update(this: HTMLInputElement): void {
value = Math.min(max, Math.max(min, parseFloat(this.value)));

View file

@ -126,7 +126,7 @@ if (isApplePlatform()) {
export function preventBuiltinShortcuts(editable: HTMLElement): void {
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;
}
function shortcut(element: HTMLElement): void {
registerShortcut(toggle, keyCombination, element);
function shortcut(target: HTMLElement): () => void {
return registerShortcut(toggle, keyCombination, { target });
}
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 {
registerShortcut(toggle, keyCombination, element);
function shortcut(target: HTMLElement): () => void {
return registerShortcut(toggle, keyCombination, { target });
}
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}
</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(
callback: (event: KeyboardEvent) => void,
keyCombinationString: string,
target: EventTarget | Document = document,
restParams: Partial<RegisterShortcutRestParams> = defaultRegisterShortcutRestParams,
): () => void {
const {
target = defaultRegisterShortcutRestParams.target,
event = defaultRegisterShortcutRestParams.event,
} = restParams;
const [check, ...restChecks] =
splitKeyCombinationString(keyCombinationString).map(keyCombinationToCheck);
@ -156,7 +173,7 @@ export function registerShortcut(
}
}
return on(target, "keydown", handler);
return on(target, event, handler);
}
registerPackage("anki/shortcuts", {

View file

@ -1,26 +1,23 @@
// Copyright: Ankitects Pty Ltd and contributors
// 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";
interface ShortcutParams {
action: (event: KeyboardEvent) => void;
keyCombination: string;
params?: RegisterShortcutRestParams;
}
export function shortcut(
_node: Node,
{
action,
keyCombination,
target,
}: {
action: (event: KeyboardEvent) => void;
keyCombination: string;
target?: EventTarget;
},
{ action, keyCombination, params }: ShortcutParams,
): { destroy: () => void } {
const deregister = registerShortcut(action, keyCombination, target ?? document);
const deregister = registerShortcut(action, keyCombination, params);
return {
destroy() {
deregister();
},
destroy: deregister,
};
}