mirror of
https://github.com/ankitects/anki.git
synced 2025-09-25 01:06:35 -04:00
Merge remote-tracking branch 'upstream/main' into apkg
This commit is contained in:
commit
aab518d4d9
32 changed files with 282 additions and 148 deletions
|
@ -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/)
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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>"
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
######################################################################
|
######################################################################
|
||||||
|
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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(())
|
||||||
|
|
|
@ -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()? {
|
||||||
|
|
|
@ -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()? {
|
||||||
|
|
|
@ -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)]
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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)));
|
||||||
|
|
|
@ -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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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));
|
||||||
|
|
|
@ -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));
|
||||||
|
|
|
@ -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)}
|
||||||
|
/>
|
||||||
|
|
|
@ -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", {
|
||||||
|
|
|
@ -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();
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue