diff --git a/ftl/core/browsing.ftl b/ftl/core/browsing.ftl
index cb676d53c..f84bb3890 100644
--- a/ftl/core/browsing.ftl
+++ b/ftl/core/browsing.ftl
@@ -140,3 +140,14 @@ browsing-edited-today = Edited
browsing-sidebar-due-today = Due
browsing-sidebar-untagged = Untagged
browsing-sidebar-overdue = Overdue
+browsing-row-deleted = (deleted)
+browsing-removed-unused-tags-count =
+ { $count ->
+ [one] Removed { $count } unused tag.
+ *[other] Removed { $count } unused tags.
+ }
+browsing-changed-new-position =
+ { $count ->
+ [one] Changed position of { $count } new card.
+ *[other] Changed position of { $count } new cards.
+ }
diff --git a/ftl/core/undo.ftl b/ftl/core/undo.ftl
index c2e95f1e9..89c92c6b5 100644
--- a/ftl/core/undo.ftl
+++ b/ftl/core/undo.ftl
@@ -18,3 +18,6 @@ undo-update-note = Update Note
undo-update-card = Update Card
undo-update-deck = Update Deck
undo-forget-card = Forget Card
+undo-set-flag = Set Flag
+# when dragging/dropping tags and decks in the sidebar
+undo-reparent = Change Parent
diff --git a/ftl/qt/qt-misc.ftl b/ftl/qt/qt-misc.ftl
index e1de29fbe..1da297bb2 100644
--- a/ftl/qt/qt-misc.ftl
+++ b/ftl/qt/qt-misc.ftl
@@ -36,7 +36,6 @@ qt-misc-please-select-a-deck = Please select a deck.
qt-misc-please-use-fileimport-to-import-this = Please use File>Import to import this file.
qt-misc-processing = Processing...
qt-misc-replace-your-collection-with-an-earlier = Replace your collection with an earlier backup?
-qt-misc-resume-now = Resume Now
qt-misc-revert-to-backup = Revert to backup
qt-misc-reverted-to-state-prior-to = Reverted to state prior to '{ $val }'.
qt-misc-segoe-ui = "Segoe UI"
@@ -56,7 +55,6 @@ qt-misc-unable-to-move-existing-file-to = Unable to move existing file to trash
qt-misc-undo = Undo
qt-misc-undo2 = Undo { $val }
qt-misc-unexpected-response-code = Unexpected response code: { $val }
-qt-misc-waiting-for-editing-to-finish = Waiting for editing to finish.
qt-misc-would-you-like-to-download-it = Would you like to download it now?
qt-misc-your-collection-file-appears-to-be = Your collection file appears to be corrupt. This can happen when the file is copied or moved while Anki is open, or when the collection is stored on a network or cloud drive. If problems persist after restarting your computer, please open an automatic backup from the profile screen.
qt-misc-your-computers-storage-may-be-full = Your computer's storage may be full. Please delete some unneeded files, then try again.
diff --git a/pip/pyqt5/install_pyqt5.py b/pip/pyqt5/install_pyqt5.py
index 13f3234af..05186160f 100644
--- a/pip/pyqt5/install_pyqt5.py
+++ b/pip/pyqt5/install_pyqt5.py
@@ -91,7 +91,17 @@ def copy_and_fix_pyi(source, dest):
with open(source) as input_file:
with open(dest, "w") as output_file:
for line in input_file.readlines():
+ # assigning to None is a syntax error
line = fix_none.sub(r"\1_ =", line)
+ # inheriting from the missing sip.sipwrapper definition
+ # causes missing attributes not to be detected, as it's treating
+ # the class as inheriting from Any
+ line = line.replace("sip.simplewrapper", "object")
+ line = line.replace("sip.wrapper", "object")
+ # remove blanket getattr in QObject which also causes missing
+ # attributes not to be detected
+ if "def __getattr__(self, name: str) -> typing.Any" in line:
+ continue
output_file.write(line)
diff --git a/pylib/anki/cards.py b/pylib/anki/cards.py
index e08bb46f3..7d9a4a20a 100644
--- a/pylib/anki/cards.py
+++ b/pylib/anki/cards.py
@@ -196,7 +196,8 @@ class Card:
return self.flags & 0b111
def set_user_flag(self, flag: int) -> None:
- assert 0 <= flag <= 7
+ print("use col.set_user_flag_for_cards() instead")
+ assert 0 <= flag <= 4
self.flags = (self.flags & ~0b111) | flag
# legacy
diff --git a/pylib/anki/collection.py b/pylib/anki/collection.py
index 098b69854..01b0fb3c4 100644
--- a/pylib/anki/collection.py
+++ b/pylib/anki/collection.py
@@ -3,6 +3,22 @@
from __future__ import annotations
+from typing import Any, List, Literal, Optional, Sequence, Tuple, Union
+
+import anki._backend.backend_pb2 as _pb
+
+# protobuf we publicly export - listed first to avoid circular imports
+SearchNode = _pb.SearchNode
+Progress = _pb.Progress
+EmptyCardsReport = _pb.EmptyCardsReport
+GraphPreferences = _pb.GraphPreferences
+BuiltinSort = _pb.SortOrder.Builtin
+Preferences = _pb.Preferences
+UndoStatus = _pb.UndoStatus
+OpChanges = _pb.OpChanges
+OpChangesWithCount = _pb.OpChangesWithCount
+DefaultsForAdding = _pb.DeckAndNotetype
+
import copy
import os
import pprint
@@ -12,12 +28,8 @@ import time
import traceback
import weakref
from dataclasses import dataclass, field
-from typing import Any, List, Literal, Optional, Sequence, Tuple, Union
-import anki._backend.backend_pb2 as _pb
-import anki.find
-import anki.latex # sets up hook
-import anki.template
+import anki.latex
from anki import hooks
from anki._backend import RustBackend
from anki.cards import Card
@@ -45,16 +57,10 @@ from anki.utils import (
stripHTMLMedia,
)
-# public exports
-SearchNode = _pb.SearchNode
+anki.latex.setup_hook()
+
+
SearchJoiner = Literal["AND", "OR"]
-Progress = _pb.Progress
-EmptyCardsReport = _pb.EmptyCardsReport
-GraphPreferences = _pb.GraphPreferences
-BuiltinSort = _pb.SortOrder.Builtin
-Preferences = _pb.Preferences
-UndoStatus = _pb.UndoStatus
-DefaultsForAdding = _pb.DeckAndNotetype
@dataclass
@@ -195,23 +201,17 @@ class Collection:
flush = setMod
- def modified_after_begin(self) -> bool:
+ def modified_by_backend(self) -> bool:
# Until we can move away from long-running transactions, the Python
- # code needs to know if transaction should be committed, so we need
+ # code needs to know if the transaction should be committed, so we need
# to check if the backend updated the modification time.
return self.db.last_begin_at != self.mod
def save(self, name: Optional[str] = None, trx: bool = True) -> None:
"Flush, commit DB, and take out another write lock if trx=True."
# commit needed?
- if self.db.modified_in_python or self.modified_after_begin():
- if self.db.modified_in_python:
- self.db.execute("update col set mod = ?", intTime(1000))
- self.db.modified_in_python = False
- else:
- # modifications made by the backend will have already bumped
- # mtime
- pass
+ if self.db.modified_in_python or self.modified_by_backend():
+ self.db.modified_in_python = False
self.db.commit()
if trx:
self.db.begin()
@@ -328,10 +328,12 @@ class Collection:
def get_note(self, id: int) -> Note:
return Note(self, id=id)
- def update_note(self, note: Note) -> None:
+ def update_note(self, note: Note) -> OpChanges:
"""Save note changes to database, and add an undo entry.
Unlike note.flush(), this will invalidate any current checkpoint."""
- self._backend.update_note(note=note._to_backend_note(), skip_undo_entry=False)
+ return self._backend.update_note(
+ note=note._to_backend_note(), skip_undo_entry=False
+ )
getCard = get_card
getNote = get_note
@@ -366,12 +368,14 @@ class Collection:
def new_note(self, notetype: NoteType) -> Note:
return Note(self, notetype)
- def add_note(self, note: Note, deck_id: int) -> None:
- note.id = self._backend.add_note(note=note._to_backend_note(), deck_id=deck_id)
+ def add_note(self, note: Note, deck_id: int) -> OpChanges:
+ out = self._backend.add_note(note=note._to_backend_note(), deck_id=deck_id)
+ note.id = out.note_id
+ return out.changes
- def remove_notes(self, note_ids: Sequence[int]) -> None:
+ def remove_notes(self, note_ids: Sequence[int]) -> OpChanges:
hooks.notes_will_be_deleted(self, note_ids)
- self._backend.remove_notes(note_ids=note_ids, card_ids=[])
+ return self._backend.remove_notes(note_ids=note_ids, card_ids=[])
def remove_notes_by_card(self, card_ids: List[int]) -> None:
if hooks.notes_will_be_deleted.count():
@@ -445,8 +449,8 @@ class Collection:
"You probably want .remove_notes_by_card() instead."
self._backend.remove_cards(card_ids=card_ids)
- def set_deck(self, card_ids: List[int], deck_id: int) -> None:
- self._backend.set_deck(card_ids=card_ids, deck_id=deck_id)
+ def set_deck(self, card_ids: Sequence[int], deck_id: int) -> OpChanges:
+ return self._backend.set_deck(card_ids=card_ids, deck_id=deck_id)
def get_empty_cards(self) -> EmptyCardsReport:
return self._backend.get_empty_cards()
@@ -536,14 +540,26 @@ class Collection:
def find_and_replace(
self,
- nids: List[int],
- src: str,
- dst: str,
- regex: Optional[bool] = None,
- field: Optional[str] = None,
- fold: bool = True,
- ) -> int:
- return anki.find.findReplace(self, nids, src, dst, regex, field, fold)
+ *,
+ note_ids: Sequence[int],
+ search: str,
+ replacement: str,
+ regex: bool = False,
+ field_name: Optional[str] = None,
+ match_case: bool = False,
+ ) -> OpChangesWithCount:
+ "Find and replace fields in a note. Returns changed note count."
+ return self._backend.find_and_replace(
+ nids=note_ids,
+ search=search,
+ replacement=replacement,
+ regex=regex,
+ match_case=match_case,
+ field_name=field_name or "",
+ )
+
+ def field_names_for_note_ids(self, nids: Sequence[int]) -> Sequence[str]:
+ return self._backend.field_names_for_notes(nids)
# returns array of ("dupestr", [nids])
def findDupes(self, fieldName: str, search: str = "") -> List[Tuple[Any, list]]:
@@ -788,8 +804,6 @@ table.review-log {{ {revlog_style} }}
assert_exhaustive(self._undo)
assert False
- return status
-
def clear_python_undo(self) -> None:
"""Clear the Python undo state.
The backend will automatically clear backend undo state when
@@ -817,6 +831,18 @@ table.review-log {{ {revlog_style} }}
assert_exhaustive(self._undo)
assert False
+ def op_affects_study_queue(self, changes: OpChanges) -> bool:
+ if changes.kind == changes.SET_CARD_FLAG:
+ return False
+ return changes.card or changes.deck or changes.preference
+
+ def op_made_changes(self, changes: OpChanges) -> bool:
+ for field in changes.DESCRIPTOR.fields:
+ if field.name != "kind":
+ if getattr(changes, field.name, False):
+ return True
+ return False
+
def _check_backend_undo_status(self) -> Optional[UndoStatus]:
"""Return undo status if undo available on backend.
If backend has undo available, clear the Python undo state."""
@@ -986,21 +1012,10 @@ table.review-log {{ {revlog_style} }}
self._logHnd.close()
self._logHnd = None
- # Card Flags
##########################################################################
- def set_user_flag_for_cards(self, flag: int, cids: List[int]) -> None:
- assert 0 <= flag <= 7
- self.db.execute(
- "update cards set flags = (flags & ~?) | ?, usn=?, mod=? where id in %s"
- % ids2str(cids),
- 0b111,
- flag,
- self.usn(),
- intTime(),
- )
-
- ##########################################################################
+ def set_user_flag_for_cards(self, flag: int, cids: Sequence[int]) -> OpChanges:
+ return self._backend.set_flag(card_ids=cids, flag=flag)
def set_wants_abort(self) -> None:
self._backend.set_wants_abort()
diff --git a/pylib/anki/decks.py b/pylib/anki/decks.py
index 853ba991a..16a1a60c1 100644
--- a/pylib/anki/decks.py
+++ b/pylib/anki/decks.py
@@ -11,6 +11,7 @@ from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple, Union
import anki # pylint: disable=unused-import
import anki._backend.backend_pb2 as _pb
+from anki.collection import OpChangesWithCount
from anki.consts import *
from anki.errors import NotFoundError
from anki.utils import from_json_bytes, ids2str, intTime, legacy_func, to_json_bytes
@@ -138,7 +139,7 @@ class DeckManager:
assert cardsToo and childrenToo
self.remove([did])
- def remove(self, dids: List[int]) -> int:
+ def remove(self, dids: Sequence[int]) -> OpChangesWithCount:
return self.col._backend.remove_decks(dids)
def all_names_and_ids(
diff --git a/pylib/anki/find.py b/pylib/anki/find.py
index af829cb7d..14692531a 100644
--- a/pylib/anki/find.py
+++ b/pylib/anki/find.py
@@ -37,18 +37,19 @@ def findReplace(
fold: bool = True,
) -> int:
"Find and replace fields in a note. Returns changed note count."
- return col._backend.find_and_replace(
- nids=nids,
+ print("use col.find_and_replace() instead of findReplace()")
+ return col.find_and_replace(
+ note_ids=nids,
search=src,
replacement=dst,
regex=regex,
match_case=not fold,
field_name=field,
- )
+ ).count
def fieldNamesForNotes(col: Collection, nids: List[int]) -> List[str]:
- return list(col._backend.field_names_for_notes(nids))
+ return list(col.field_names_for_note_ids(nids))
# Find duplicates
diff --git a/pylib/anki/latex.py b/pylib/anki/latex.py
index cfbf6734b..e7bab9dce 100644
--- a/pylib/anki/latex.py
+++ b/pylib/anki/latex.py
@@ -178,4 +178,5 @@ def _errMsg(col: anki.collection.Collection, type: str, texpath: str) -> Any:
return msg
-hooks.card_did_render.append(on_card_did_render)
+def setup_hook() -> None:
+ hooks.card_did_render.append(on_card_did_render)
diff --git a/pylib/anki/scheduler/base.py b/pylib/anki/scheduler/base.py
index f8c05392b..4b4af388f 100644
--- a/pylib/anki/scheduler/base.py
+++ b/pylib/anki/scheduler/base.py
@@ -5,6 +5,7 @@ from __future__ import annotations
import anki
import anki._backend.backend_pb2 as _pb
+from anki.collection import OpChanges, OpChangesWithCount
from anki.config import Config
SchedTimingToday = _pb.SchedTimingTodayOut
@@ -96,11 +97,11 @@ select id from cards where did in %s and queue = {QUEUE_TYPE_REV} and due <= ? l
# Suspending & burying
##########################################################################
- def unsuspend_cards(self, ids: List[int]) -> None:
- self.col._backend.restore_buried_and_suspended_cards(ids)
+ def unsuspend_cards(self, ids: Sequence[int]) -> OpChanges:
+ return self.col._backend.restore_buried_and_suspended_cards(ids)
- def unbury_cards(self, ids: List[int]) -> None:
- self.col._backend.restore_buried_and_suspended_cards(ids)
+ def unbury_cards(self, ids: List[int]) -> OpChanges:
+ return self.col._backend.restore_buried_and_suspended_cards(ids)
def unbury_cards_in_current_deck(
self,
@@ -108,17 +109,17 @@ select id from cards where did in %s and queue = {QUEUE_TYPE_REV} and due <= ? l
) -> None:
self.col._backend.unbury_cards_in_current_deck(mode)
- def suspend_cards(self, ids: Sequence[int]) -> None:
- self.col._backend.bury_or_suspend_cards(
+ def suspend_cards(self, ids: Sequence[int]) -> OpChanges:
+ return self.col._backend.bury_or_suspend_cards(
card_ids=ids, mode=BuryOrSuspend.SUSPEND
)
- def bury_cards(self, ids: Sequence[int], manual: bool = True) -> None:
+ def bury_cards(self, ids: Sequence[int], manual: bool = True) -> OpChanges:
if manual:
mode = BuryOrSuspend.BURY_USER
else:
mode = BuryOrSuspend.BURY_SCHED
- self.col._backend.bury_or_suspend_cards(card_ids=ids, mode=mode)
+ return self.col._backend.bury_or_suspend_cards(card_ids=ids, mode=mode)
def bury_note(self, note: Note) -> None:
self.bury_cards(note.card_ids())
@@ -126,16 +127,16 @@ select id from cards where did in %s and queue = {QUEUE_TYPE_REV} and due <= ? l
# Resetting/rescheduling
##########################################################################
- def schedule_cards_as_new(self, card_ids: List[int]) -> None:
+ def schedule_cards_as_new(self, card_ids: List[int]) -> OpChanges:
"Put cards at the end of the new queue."
- self.col._backend.schedule_cards_as_new(card_ids=card_ids, log=True)
+ return self.col._backend.schedule_cards_as_new(card_ids=card_ids, log=True)
def set_due_date(
self,
card_ids: List[int],
days: str,
config_key: Optional[Config.String.Key.V] = None,
- ) -> None:
+ ) -> OpChanges:
"""Set cards to be due in `days`, turning them into review cards if necessary.
`days` can be of the form '5' or '5..7'
If `config_key` is provided, provided days will be remembered in config."""
@@ -143,7 +144,9 @@ select id from cards where did in %s and queue = {QUEUE_TYPE_REV} and due <= ? l
key = Config.String(key=config_key)
else:
key = None
- self.col._backend.set_due_date(card_ids=card_ids, days=days, config_key=key)
+ return self.col._backend.set_due_date(
+ card_ids=card_ids, days=days, config_key=key
+ )
def resetCards(self, ids: List[int]) -> None:
"Completely reset cards for export."
@@ -164,20 +167,20 @@ select id from cards where did in %s and queue = {QUEUE_TYPE_REV} and due <= ? l
# Repositioning new cards
##########################################################################
- def sortCards(
+ def reposition_new_cards(
self,
- cids: List[int],
- start: int = 1,
- step: int = 1,
- shuffle: bool = False,
- shift: bool = False,
- ) -> None:
- self.col._backend.sort_cards(
- card_ids=cids,
- starting_from=start,
- step_size=step,
- randomize=shuffle,
- shift_existing=shift,
+ card_ids: Sequence[int],
+ starting_from: int,
+ step_size: int,
+ randomize: bool,
+ shift_existing: bool,
+ ) -> OpChangesWithCount:
+ return self.col._backend.sort_cards(
+ card_ids=card_ids,
+ starting_from=starting_from,
+ step_size=step_size,
+ randomize=randomize,
+ shift_existing=shift_existing,
)
def randomizeCards(self, did: int) -> None:
@@ -201,3 +204,14 @@ select id from cards where did in %s and queue = {QUEUE_TYPE_REV} and due <= ? l
# in order due?
if conf["new"]["order"] == NEW_CARDS_RANDOM:
self.randomizeCards(did)
+
+ # legacy
+ def sortCards(
+ self,
+ cids: List[int],
+ start: int = 1,
+ step: int = 1,
+ shuffle: bool = False,
+ shift: bool = False,
+ ) -> None:
+ self.reposition_new_cards(cids, start, step, shuffle, shift)
diff --git a/pylib/anki/tags.py b/pylib/anki/tags.py
index 5e2ae27ce..d139d9f23 100644
--- a/pylib/anki/tags.py
+++ b/pylib/anki/tags.py
@@ -18,10 +18,12 @@ from typing import Collection, List, Match, Optional, Sequence
import anki # pylint: disable=unused-import
import anki._backend.backend_pb2 as _pb
import anki.collection
+from anki.collection import OpChangesWithCount
from anki.utils import ids2str
# public exports
TagTreeNode = _pb.TagTreeNode
+MARKED_TAG = "marked"
class TagManager:
@@ -43,17 +45,8 @@ class TagManager:
# Registering and fetching tags
#############################################################
- def register(
- self, tags: Collection[str], usn: Optional[int] = None, clear: bool = False
- ) -> None:
- print("tags.register() is deprecated and no longer works")
-
- def registerNotes(self, nids: Optional[List[int]] = None) -> None:
- "Clear unused tags and add any missing tags from notes to the tag list."
- self.clear_unused_tags()
-
- def clear_unused_tags(self) -> None:
- self.col._backend.clear_unused_tags()
+ def clear_unused_tags(self) -> OpChangesWithCount:
+ return self.col._backend.clear_unused_tags()
def byDeck(self, did: int, children: bool = False) -> List[str]:
basequery = "select n.tags from cards c, notes n WHERE c.nid = n.id"
@@ -72,52 +65,53 @@ class TagManager:
"Set browser expansion state for tag, registering the tag if missing."
self.col._backend.set_tag_expanded(name=tag, expanded=expanded)
- # Bulk addition/removal from notes
+ # Bulk addition/removal from specific notes
#############################################################
- def bulk_add(self, nids: List[int], tags: str) -> int:
+ def bulk_add(self, note_ids: Sequence[int], tags: str) -> OpChangesWithCount:
"""Add space-separate tags to provided notes, returning changed count."""
- return self.col._backend.add_note_tags(nids=nids, tags=tags)
+ return self.col._backend.add_note_tags(note_ids=note_ids, tags=tags)
- def bulk_update(
- self, nids: Sequence[int], tags: str, replacement: str, regex: bool
- ) -> int:
- """Replace space-separated tags, returning changed count.
- Tags replaced with an empty string will be removed."""
- return self.col._backend.update_note_tags(
- nids=nids, tags=tags, replacement=replacement, regex=regex
+ def bulk_remove(self, note_ids: Sequence[int], tags: str) -> OpChangesWithCount:
+ return self.col._backend.remove_note_tags(note_ids=note_ids, tags=tags)
+
+ # Find&replace
+ #############################################################
+
+ def find_and_replace(
+ self,
+ note_ids: Sequence[int],
+ search: str,
+ replacement: str,
+ regex: bool,
+ match_case: bool,
+ ) -> OpChangesWithCount:
+ """Replace instances of 'search' with 'replacement' in tags.
+ Each tag is matched separately. If the replacement results in an empty string,
+ the tag will be removed."""
+ return self.col._backend.find_and_replace_tag(
+ note_ids=note_ids,
+ search=search,
+ replacement=replacement,
+ regex=regex,
+ match_case=match_case,
)
- def bulk_remove(self, nids: Sequence[int], tags: str) -> int:
- return self.bulk_update(nids, tags, "", False)
+ # Bulk addition/removal based on tag
+ #############################################################
- def rename(self, old: str, new: str) -> int:
- "Rename provided tag, returning number of changed notes."
- nids = self.col.find_notes(anki.collection.SearchNode(tag=old))
- if not nids:
- return 0
- escaped_name = re.sub(r"[*_\\]", r"\\\g<0>", old)
- return self.bulk_update(nids, escaped_name, new, False)
+ def rename(self, old: str, new: str) -> OpChangesWithCount:
+ "Rename provided tag and its children, returning number of changed notes."
+ return self.col._backend.rename_tags(current_prefix=old, new_prefix=new)
- def remove(self, tag: str) -> None:
- self.col._backend.clear_tag(tag)
+ def remove(self, space_separated_tags: str) -> OpChangesWithCount:
+ "Remove the provided tag(s) and their children from notes and the tag list."
+ return self.col._backend.remove_tags(val=space_separated_tags)
- def drag_drop(self, source_tags: List[str], target_tag: str) -> None:
- """Rename one or more source tags that were dropped on `target_tag`.
- If target_tag is "", tags will be placed at the top level."""
- self.col._backend.drag_drop_tags(source_tags=source_tags, target_tag=target_tag)
-
- # legacy routines
-
- def bulkAdd(self, ids: List[int], tags: str, add: bool = True) -> None:
- "Add tags in bulk. TAGS is space-separated."
- if add:
- self.bulk_add(ids, tags)
- else:
- self.bulk_update(ids, tags, "", False)
-
- def bulkRem(self, ids: List[int], tags: str) -> None:
- self.bulkAdd(ids, tags, False)
+ def reparent(self, tags: Sequence[str], new_parent: str) -> OpChangesWithCount:
+ """Change the parent of the provided tags.
+ If new_parent is empty, tags will be reparented to the top-level."""
+ return self.col._backend.reparent_tags(tags=tags, new_parent=new_parent)
# String-based utilities
##########################################################################
@@ -169,3 +163,24 @@ class TagManager:
def inList(self, tag: str, tags: List[str]) -> bool:
"True if TAG is in TAGS. Ignore case."
return tag.lower() in [t.lower() for t in tags]
+
+ # legacy
+ ##########################################################################
+
+ def registerNotes(self, nids: Optional[List[int]] = None) -> None:
+ self.clear_unused_tags()
+
+ def register(
+ self, tags: Collection[str], usn: Optional[int] = None, clear: bool = False
+ ) -> None:
+ print("tags.register() is deprecated and no longer works")
+
+ def bulkAdd(self, ids: List[int], tags: str, add: bool = True) -> None:
+ "Add tags in bulk. TAGS is space-separated."
+ if add:
+ self.bulk_add(ids, tags)
+ else:
+ self.bulk_remove(ids, tags)
+
+ def bulkRem(self, ids: List[int], tags: str) -> None:
+ self.bulkAdd(ids, tags, False)
diff --git a/pylib/tests/test_find.py b/pylib/tests/test_find.py
index eb5eca906..91d7f1bb0 100644
--- a/pylib/tests/test_find.py
+++ b/pylib/tests/test_find.py
@@ -243,24 +243,40 @@ def test_findReplace():
col.addNote(note2)
nids = [note.id, note2.id]
# should do nothing
- assert col.findReplace(nids, "abc", "123") == 0
+ assert (
+ col.find_and_replace(note_ids=nids, search="abc", replacement="123").count == 0
+ )
# global replace
- assert col.findReplace(nids, "foo", "qux") == 2
+ assert (
+ col.find_and_replace(note_ids=nids, search="foo", replacement="qux").count == 2
+ )
note.load()
assert note["Front"] == "qux"
note2.load()
assert note2["Back"] == "qux"
# single field replace
- assert col.findReplace(nids, "qux", "foo", field="Front") == 1
+ assert (
+ col.find_and_replace(
+ note_ids=nids, search="qux", replacement="foo", field_name="Front"
+ ).count
+ == 1
+ )
note.load()
assert note["Front"] == "foo"
note2.load()
assert note2["Back"] == "qux"
# regex replace
- assert col.findReplace(nids, "B.r", "reg") == 0
+ assert (
+ col.find_and_replace(note_ids=nids, search="B.r", replacement="reg").count == 0
+ )
note.load()
assert note["Back"] != "reg"
- assert col.findReplace(nids, "B.r", "reg", regex=True) == 1
+ assert (
+ col.find_and_replace(
+ note_ids=nids, search="B.r", replacement="reg", regex=True
+ ).count
+ == 1
+ )
note.load()
assert note["Back"] == "reg"
diff --git a/pylib/tests/test_schedv1.py b/pylib/tests/test_schedv1.py
index 6504147b9..4a02066da 100644
--- a/pylib/tests/test_schedv1.py
+++ b/pylib/tests/test_schedv1.py
@@ -1023,62 +1023,6 @@ def test_deckFlow():
col.sched.answerCard(c, 2)
-def test_reorder():
- col = getEmptyCol()
- # add a note with default deck
- note = col.newNote()
- note["Front"] = "one"
- col.addNote(note)
- note2 = col.newNote()
- note2["Front"] = "two"
- col.addNote(note2)
- assert note2.cards()[0].due == 2
- found = False
- # 50/50 chance of being reordered
- for i in range(20):
- col.sched.randomizeCards(1)
- if note.cards()[0].due != note.id:
- found = True
- break
- assert found
- col.sched.orderCards(1)
- assert note.cards()[0].due == 1
- # shifting
- note3 = col.newNote()
- note3["Front"] = "three"
- col.addNote(note3)
- note4 = col.newNote()
- note4["Front"] = "four"
- col.addNote(note4)
- assert note.cards()[0].due == 1
- assert note2.cards()[0].due == 2
- assert note3.cards()[0].due == 3
- assert note4.cards()[0].due == 4
- col.sched.sortCards([note3.cards()[0].id, note4.cards()[0].id], start=1, shift=True)
- assert note.cards()[0].due == 3
- assert note2.cards()[0].due == 4
- assert note3.cards()[0].due == 1
- assert note4.cards()[0].due == 2
-
-
-def test_forget():
- col = getEmptyCol()
- note = col.newNote()
- note["Front"] = "one"
- col.addNote(note)
- c = note.cards()[0]
- c.queue = QUEUE_TYPE_REV
- c.type = CARD_TYPE_REV
- c.ivl = 100
- c.due = 0
- c.flush()
- col.reset()
- assert col.sched.counts() == (0, 0, 1)
- col.sched.forgetCards([c.id])
- col.reset()
- assert col.sched.counts() == (1, 0, 0)
-
-
def test_norelearn():
col = getEmptyCol()
# add a note
diff --git a/pylib/tests/test_schedv2.py b/pylib/tests/test_schedv2.py
index d9876c562..5f9a46aae 100644
--- a/pylib/tests/test_schedv2.py
+++ b/pylib/tests/test_schedv2.py
@@ -1211,7 +1211,13 @@ def test_reorder():
assert note2.cards()[0].due == 2
assert note3.cards()[0].due == 3
assert note4.cards()[0].due == 4
- col.sched.sortCards([note3.cards()[0].id, note4.cards()[0].id], start=1, shift=True)
+ col.sched.reposition_new_cards(
+ [note3.cards()[0].id, note4.cards()[0].id],
+ starting_from=1,
+ shift_existing=True,
+ step_size=1,
+ randomize=False,
+ )
assert note.cards()[0].due == 3
assert note2.cards()[0].due == 4
assert note3.cards()[0].due == 1
diff --git a/qt/.pylintrc b/qt/.pylintrc
index 3507ed84c..4a701207b 100644
--- a/qt/.pylintrc
+++ b/qt/.pylintrc
@@ -8,6 +8,7 @@ ignored-modules=win32file,pywintypes,socket,win32pipe,winrt,pyaudio
ignored-classes=
SearchNode,
Config,
+ OpChanges
[REPORTS]
output-format=colorized
diff --git a/qt/aqt/__init__.py b/qt/aqt/__init__.py
index e503c712b..95fd829c4 100644
--- a/qt/aqt/__init__.py
+++ b/qt/aqt/__init__.py
@@ -10,7 +10,7 @@ import os
import sys
import tempfile
import traceback
-from typing import Any, Callable, Dict, List, Optional, Tuple, Union
+from typing import Any, Callable, Dict, List, Optional, Tuple, Union, cast
import anki.lang
from anki import version as _version
@@ -299,7 +299,7 @@ class AnkiApp(QApplication):
if not sock.waitForReadyRead(self.TMOUT):
sys.stderr.write(sock.errorString())
return
- path = bytes(sock.readAll()).decode("utf8")
+ path = bytes(cast(bytes, sock.readAll())).decode("utf8")
self.appMsg.emit(path) # type: ignore
sock.disconnectFromServer()
diff --git a/qt/aqt/addcards.py b/qt/aqt/addcards.py
index 6c38f16e3..0b1c7f3c2 100644
--- a/qt/aqt/addcards.py
+++ b/qt/aqt/addcards.py
@@ -6,12 +6,12 @@ from typing import Callable, List, Optional
import aqt.deckchooser
import aqt.editor
import aqt.forms
-from anki.collection import SearchNode
+from anki.collection import OpChanges, SearchNode
from anki.consts import MODEL_CLOZE
from anki.notes import DuplicateOrEmptyResult, Note
from anki.utils import htmlToTextLine, isMac
from aqt import AnkiQt, gui_hooks
-from aqt.main import ResetReason
+from aqt.note_ops import add_note
from aqt.notetypechooser import NoteTypeChooser
from aqt.qt import *
from aqt.sound import av_player
@@ -104,10 +104,10 @@ class AddCards(QDialog):
self.historyButton = b
def setAndFocusNote(self, note: Note) -> None:
- self.editor.setNote(note, focusTo=0)
+ self.editor.set_note(note, focusTo=0)
def show_notetype_selector(self) -> None:
- self.editor.saveNow(self.notetype_chooser.choose_notetype)
+ self.editor.call_after_note_saved(self.notetype_chooser.choose_notetype)
def on_notetype_change(self, notetype_id: int) -> None:
# need to adjust current deck?
@@ -182,7 +182,7 @@ class AddCards(QDialog):
aqt.dialogs.open("Browser", self.mw, search=(SearchNode(nid=nid),))
def add_current_note(self) -> None:
- self.editor.saveNow(self._add_current_note)
+ self.editor.call_after_note_saved(self._add_current_note)
def _add_current_note(self) -> None:
note = self.editor.note
@@ -191,23 +191,24 @@ class AddCards(QDialog):
return
target_deck_id = self.deck_chooser.selected_deck_id
- self.mw.col.add_note(note, target_deck_id)
- # only used for detecting changed sticky fields on close
- self._last_added_note = note
+ def on_success(changes: OpChanges) -> None:
+ # only used for detecting changed sticky fields on close
+ self._last_added_note = note
- self.addHistory(note)
- self.mw.requireReset(reason=ResetReason.AddCardsAddNote, context=self)
+ self.addHistory(note)
- # workaround for PyQt focus bug
- self.editor.hideCompleters()
+ # workaround for PyQt focus bug
+ self.editor.hideCompleters()
- tooltip(tr(TR.ADDING_ADDED), period=500)
- av_player.stop_and_clear_queue()
- self._load_new_note(sticky_fields_from=note)
- self.mw.col.autosave() # fixme:
+ tooltip(tr(TR.ADDING_ADDED), period=500)
+ av_player.stop_and_clear_queue()
+ self._load_new_note(sticky_fields_from=note)
+ gui_hooks.add_cards_did_add_note(note)
- gui_hooks.add_cards_did_add_note(note)
+ add_note(
+ mw=self.mw, note=note, target_deck_id=target_deck_id, success=on_success
+ )
def _note_can_be_added(self, note: Note) -> bool:
result = note.duplicate_or_empty()
@@ -258,7 +259,7 @@ class AddCards(QDialog):
if ok:
onOk()
- self.editor.saveNow(afterSave)
+ self.editor.call_after_note_saved(afterSave)
def closeWithCallback(self, cb: Callable[[], None]) -> None:
def doClose() -> None:
diff --git a/qt/aqt/addons.py b/qt/aqt/addons.py
index e50ac35db..258227996 100644
--- a/qt/aqt/addons.py
+++ b/qt/aqt/addons.py
@@ -715,7 +715,7 @@ class AddonsDialog(QDialog):
gui_hooks.addons_dialog_will_show(self)
self.show()
- def dragEnterEvent(self, event: QEvent) -> None:
+ def dragEnterEvent(self, event: QDragEnterEvent) -> None:
mime = event.mimeData()
if not mime.hasUrls():
return None
@@ -724,7 +724,7 @@ class AddonsDialog(QDialog):
if all(url.toLocalFile().endswith(ext) for url in urls):
event.acceptProposedAction()
- def dropEvent(self, event: QEvent) -> None:
+ def dropEvent(self, event: QDropEvent) -> None:
mime = event.mimeData()
paths = []
for url in mime.urls():
@@ -908,7 +908,7 @@ class AddonsDialog(QDialog):
class GetAddons(QDialog):
- def __init__(self, dlg: QDialog) -> None:
+ def __init__(self, dlg: AddonsDialog) -> None:
QDialog.__init__(self, dlg)
self.addonsDlg = dlg
self.mgr = dlg.mgr
@@ -1079,7 +1079,9 @@ class DownloaderInstaller(QObject):
self.on_done = on_done
- self.mgr.mw.progress.start(immediate=True, parent=self.parent())
+ parent = self.parent()
+ assert isinstance(parent, QWidget)
+ self.mgr.mw.progress.start(immediate=True, parent=parent)
self.mgr.mw.taskman.run_in_background(self._download_all, self._download_done)
def _progress_callback(self, up: int, down: int) -> None:
@@ -1438,7 +1440,7 @@ def prompt_to_update(
class ConfigEditor(QDialog):
- def __init__(self, dlg: QDialog, addon: str, conf: Dict) -> None:
+ def __init__(self, dlg: AddonsDialog, addon: str, conf: Dict) -> None:
super().__init__(dlg)
self.addon = addon
self.conf = conf
@@ -1506,7 +1508,7 @@ class ConfigEditor(QDialog):
txt = gui_hooks.addon_config_editor_will_save_json(txt)
try:
new_conf = json.loads(txt)
- jsonschema.validate(new_conf, self.parent().mgr._addon_schema(self.addon))
+ jsonschema.validate(new_conf, self.mgr._addon_schema(self.addon))
except ValidationError as e:
# The user did edit the configuration and entered a value
# which can not be interpreted.
diff --git a/qt/aqt/browser.py b/qt/aqt/browser.py
index 0ff9db88f..d4f25c774 100644
--- a/qt/aqt/browser.py
+++ b/qt/aqt/browser.py
@@ -1,53 +1,65 @@
# 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 time
-from concurrent.futures import Future
-from dataclasses import dataclass
+from dataclasses import dataclass, field
from operator import itemgetter
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union, cast
import aqt
import aqt.forms
from anki.cards import Card
-from anki.collection import Collection, Config, SearchNode
+from anki.collection import Collection, Config, OpChanges, SearchNode
from anki.consts import *
-from anki.errors import InvalidInput
+from anki.errors import InvalidInput, NotFoundError
from anki.lang import without_unicode_isolation
from anki.models import NoteType
-from anki.notes import Note
from anki.stats import CardStats
+from anki.tags import MARKED_TAG
from anki.utils import htmlToTextLine, ids2str, isMac, isWin
from aqt import AnkiQt, colors, gui_hooks
+from aqt.card_ops import set_card_deck, set_card_flag
from aqt.editor import Editor
from aqt.exporting import ExportDialog
+from aqt.find_and_replace import FindAndReplaceDialog
from aqt.main import ResetReason
+from aqt.note_ops import remove_notes
from aqt.previewer import BrowserPreviewer as PreviewDialog
from aqt.previewer import Previewer
from aqt.qt import *
-from aqt.scheduling import forget_cards, set_due_date_dialog
-from aqt.sidebar import SidebarSearchBar, SidebarToolbar, SidebarTreeView
+from aqt.scheduling_ops import (
+ forget_cards,
+ reposition_new_cards_dialog,
+ set_due_date_dialog,
+ suspend_cards,
+ unsuspend_cards,
+)
+from aqt.sidebar import SidebarTreeView
+from aqt.tag_ops import add_tags, clear_unused_tags, remove_tags_for_notes
from aqt.theme import theme_manager
from aqt.utils import (
TR,
HelpPage,
+ KeyboardModifiersPressed,
askUser,
+ current_top_level_widget,
disable_help_button,
+ ensure_editor_saved,
+ ensure_editor_saved_on_trigger,
getTag,
openHelp,
qtMenuShortcutWorkaround,
restore_combo_history,
restore_combo_index_for_session,
- restore_is_checked,
restoreGeom,
restoreHeader,
restoreSplitter,
restoreState,
save_combo_history,
save_combo_index_for_session,
- save_is_checked,
saveGeom,
saveHeader,
saveSplitter,
@@ -79,6 +91,25 @@ class SearchContext:
# Data model
##########################################################################
+# temporary cache to avoid hitting the database on redraw
+@dataclass
+class Cell:
+ text: str = ""
+ font: Optional[Tuple[str, int]] = None
+ is_rtl: bool = False
+
+
+@dataclass
+class CellRow:
+ columns: List[Cell]
+ refreshed_at: float = field(default_factory=time.time)
+ card_flag: int = 0
+ marked: bool = False
+ suspended: bool = False
+
+ def is_stale(self, threshold: float) -> bool:
+ return self.refreshed_at < threshold
+
class DataModel(QAbstractTableModel):
def __init__(self, browser: Browser) -> None:
@@ -91,21 +122,72 @@ class DataModel(QAbstractTableModel):
)
self.cards: Sequence[int] = []
self.cardObjs: Dict[int, Card] = {}
+ self._row_cache: Dict[int, CellRow] = {}
+ self._last_refresh = 0.0
+ # serve stale content to avoid hitting the DB?
+ self.block_updates = False
- def getCard(self, index: QModelIndex) -> Card:
- id = self.cards[index.row()]
+ def getCard(self, index: QModelIndex) -> Optional[Card]:
+ return self._get_card_by_row(index.row())
+
+ def _get_card_by_row(self, row: int) -> Optional[Card]:
+ "None if card is not in DB."
+ id = self.cards[row]
if not id in self.cardObjs:
- self.cardObjs[id] = self.col.getCard(id)
+ try:
+ card = self.col.getCard(id)
+ except NotFoundError:
+ # deleted
+ card = None
+ self.cardObjs[id] = card
return self.cardObjs[id]
- def refreshNote(self, note: Note) -> None:
- refresh = False
- for c in note.cards():
- if c.id in self.cardObjs:
- del self.cardObjs[c.id]
- refresh = True
- if refresh:
- self.layoutChanged.emit() # type: ignore
+ # Card and cell data cache
+ ######################################################################
+ # Stopgap until we can fetch this data a row at a time from Rust.
+
+ def get_cell(self, index: QModelIndex) -> Cell:
+ row = self.get_row(index.row())
+ return row.columns[index.column()]
+
+ def get_row(self, row: int) -> CellRow:
+ if entry := self._row_cache.get(row):
+ if not self.block_updates and entry.is_stale(self._last_refresh):
+ # need to refresh
+ entry = self._build_cell_row(row)
+ self._row_cache[row] = entry
+ return entry
+ else:
+ # return entry, even if it's stale
+ return entry
+ elif self.block_updates:
+ # blank entry until we unblock
+ return CellRow(columns=[Cell(text="...")] * len(self.activeCols))
+ else:
+ # missing entry, need to build
+ entry = self._build_cell_row(row)
+ self._row_cache[row] = entry
+ return entry
+
+ def _build_cell_row(self, row: int) -> CellRow:
+ if not (card := self._get_card_by_row(row)):
+ cell = Cell(text=tr(TR.BROWSING_ROW_DELETED))
+ return CellRow(columns=[cell] * len(self.activeCols))
+
+ return CellRow(
+ columns=[
+ Cell(
+ text=self._column_data(card, column_type),
+ font=self._font(card, column_type),
+ is_rtl=self._is_rtl(card, column_type),
+ )
+ for column_type in self.activeCols
+ ],
+ # should probably make these an enum instead?
+ card_flag=card.user_flag(),
+ marked=card.note().has_tag(MARKED_TAG),
+ suspended=card.queue == QUEUE_TYPE_SUSPENDED,
+ )
# Model interface
######################################################################
@@ -124,16 +206,13 @@ class DataModel(QAbstractTableModel):
if not index.isValid():
return
if role == Qt.FontRole:
- if self.activeCols[index.column()] not in ("question", "answer", "noteFld"):
- return
- c = self.getCard(index)
- t = c.template()
- if not t.get("bfont"):
- return
- f = QFont()
- f.setFamily(cast(str, t.get("bfont", "arial")))
- f.setPixelSize(cast(int, t.get("bsize", 12)))
- return f
+ if font := self.get_cell(index).font:
+ qfont = QFont()
+ qfont.setFamily(font[0])
+ qfont.setPixelSize(font[1])
+ return qfont
+ else:
+ return None
elif role == Qt.TextAlignmentRole:
align: Union[Qt.AlignmentFlag, int] = Qt.AlignVCenter
@@ -149,7 +228,7 @@ class DataModel(QAbstractTableModel):
align |= Qt.AlignHCenter
return align
elif role == Qt.DisplayRole or role == Qt.EditRole:
- return self.columnData(index)
+ return self.get_cell(index).text
else:
return
@@ -193,17 +272,27 @@ class DataModel(QAbstractTableModel):
finally:
self.endReset()
+ def redraw_cells(self) -> None:
+ "Update cell contents, without changing search count/columns/sorting."
+ if not self.cards:
+ return
+ top_left = self.index(0, 0)
+ bottom_right = self.index(len(self.cards) - 1, len(self.activeCols) - 1)
+ self._last_refresh = time.time()
+ self.dataChanged.emit(top_left, bottom_right) # type: ignore
+
def reset(self) -> None:
self.beginReset()
self.endReset()
# caller must have called editor.saveNow() before calling this or .reset()
def beginReset(self) -> None:
- self.browser.editor.setNote(None, hide=False)
+ self.browser.editor.set_note(None, hide=False)
self.browser.mw.progress.start()
self.saveSelection()
self.beginResetModel()
self.cardObjs = {}
+ self._row_cache = {}
def endReset(self) -> None:
self.endResetModel()
@@ -211,7 +300,7 @@ class DataModel(QAbstractTableModel):
self.browser.mw.progress.finish()
def reverse(self) -> None:
- self.browser.editor.saveNow(self._reverse)
+ self.browser.editor.call_after_note_saved(self._reverse)
def _reverse(self) -> None:
self.beginReset()
@@ -219,7 +308,7 @@ class DataModel(QAbstractTableModel):
self.endReset()
def saveSelection(self) -> None:
- cards = self.browser.selectedCards()
+ cards = self.browser.selected_cards()
self.selectedCards = {id: True for id in cards}
if getattr(self.browser, "card", None):
self.focusedCard = self.browser.card.id
@@ -274,6 +363,21 @@ class DataModel(QAbstractTableModel):
else:
tv.selectRow(0)
+ def op_executed(self, op: OpChanges, focused: bool) -> None:
+ print("op executed")
+ if op.card or op.note or op.deck or op.notetype:
+ # clear card cache
+ self.cardObjs = {}
+ if focused:
+ self.redraw_cells()
+
+ def begin_blocking(self) -> None:
+ self.block_updates = True
+
+ def end_blocking(self) -> None:
+ self.block_updates = False
+ self.redraw_cells()
+
# Column data
######################################################################
@@ -283,64 +387,87 @@ class DataModel(QAbstractTableModel):
def time_format(self) -> str:
return "%Y-%m-%d"
+ def _font(self, card: Card, column_type: str) -> Optional[Tuple[str, int]]:
+ if column_type not in ("question", "answer", "noteFld"):
+ return None
+
+ template = card.template()
+ if not template.get("bfont"):
+ return None
+
+ return (
+ cast(str, template.get("bfont", "arial")),
+ cast(int, template.get("bsize", 12)),
+ )
+
+ # legacy
def columnData(self, index: QModelIndex) -> str:
col = index.column()
type = self.columnType(col)
c = self.getCard(index)
+ if not c:
+ return tr(TR.BROWSING_ROW_DELETED)
+ else:
+ return self._column_data(c, type)
+
+ def _column_data(self, card: Card, column_type: str) -> str:
+ type = column_type
if type == "question":
- return self.question(c)
+ return self.question(card)
elif type == "answer":
- return self.answer(c)
+ return self.answer(card)
elif type == "noteFld":
- f = c.note()
+ f = card.note()
return htmlToTextLine(f.fields[self.col.models.sortIdx(f.model())])
elif type == "template":
- t = c.template()["name"]
- if c.model()["type"] == MODEL_CLOZE:
- t = f"{t} {c.ord + 1}"
+ t = card.template()["name"]
+ if card.model()["type"] == MODEL_CLOZE:
+ t = f"{t} {card.ord + 1}"
return cast(str, t)
elif type == "cardDue":
# catch invalid dates
try:
- t = self.nextDue(c, index)
+ t = self._next_due(card)
except:
t = ""
- if c.queue < 0:
+ if card.queue < 0:
t = f"({t})"
return t
elif type == "noteCrt":
- return time.strftime(self.time_format(), time.localtime(c.note().id / 1000))
+ return time.strftime(
+ self.time_format(), time.localtime(card.note().id / 1000)
+ )
elif type == "noteMod":
- return time.strftime(self.time_format(), time.localtime(c.note().mod))
+ return time.strftime(self.time_format(), time.localtime(card.note().mod))
elif type == "cardMod":
- return time.strftime(self.time_format(), time.localtime(c.mod))
+ return time.strftime(self.time_format(), time.localtime(card.mod))
elif type == "cardReps":
- return str(c.reps)
+ return str(card.reps)
elif type == "cardLapses":
- return str(c.lapses)
+ return str(card.lapses)
elif type == "noteTags":
- return " ".join(c.note().tags)
+ return " ".join(card.note().tags)
elif type == "note":
- return c.model()["name"]
+ return card.model()["name"]
elif type == "cardIvl":
- if c.type == CARD_TYPE_NEW:
+ if card.type == CARD_TYPE_NEW:
return tr(TR.BROWSING_NEW)
- elif c.type == CARD_TYPE_LRN:
+ elif card.type == CARD_TYPE_LRN:
return tr(TR.BROWSING_LEARNING)
- return self.col.format_timespan(c.ivl * 86400)
+ return self.col.format_timespan(card.ivl * 86400)
elif type == "cardEase":
- if c.type == CARD_TYPE_NEW:
+ if card.type == CARD_TYPE_NEW:
return tr(TR.BROWSING_NEW)
- return "%d%%" % (c.factor / 10)
+ return "%d%%" % (card.factor / 10)
elif type == "deck":
- if c.odid:
+ if card.odid:
# in a cram deck
return "%s (%s)" % (
- self.browser.mw.col.decks.name(c.did),
- self.browser.mw.col.decks.name(c.odid),
+ self.browser.mw.col.decks.name(card.did),
+ self.browser.mw.col.decks.name(card.odid),
)
# normal deck
- return self.browser.mw.col.decks.name(c.did)
+ return self.browser.mw.col.decks.name(card.did)
else:
return ""
@@ -359,30 +486,38 @@ class DataModel(QAbstractTableModel):
return a[len(q) :].strip()
return a
+ # legacy
def nextDue(self, c: Card, index: QModelIndex) -> str:
+ return self._next_due(c)
+
+ def _next_due(self, card: Card) -> str:
date: float
- if c.odid:
+ if card.odid:
return tr(TR.BROWSING_FILTERED)
- elif c.queue == QUEUE_TYPE_LRN:
- date = c.due
- elif c.queue == QUEUE_TYPE_NEW or c.type == CARD_TYPE_NEW:
- return tr(TR.STATISTICS_DUE_FOR_NEW_CARD, number=c.due)
- elif c.queue in (QUEUE_TYPE_REV, QUEUE_TYPE_DAY_LEARN_RELEARN) or (
- c.type == CARD_TYPE_REV and c.queue < 0
+ elif card.queue == QUEUE_TYPE_LRN:
+ date = card.due
+ elif card.queue == QUEUE_TYPE_NEW or card.type == CARD_TYPE_NEW:
+ return tr(TR.STATISTICS_DUE_FOR_NEW_CARD, number=card.due)
+ elif card.queue in (QUEUE_TYPE_REV, QUEUE_TYPE_DAY_LEARN_RELEARN) or (
+ card.type == CARD_TYPE_REV and card.queue < 0
):
- date = time.time() + ((c.due - self.col.sched.today) * 86400)
+ date = time.time() + ((card.due - self.col.sched.today) * 86400)
else:
return ""
return time.strftime(self.time_format(), time.localtime(date))
+ # legacy
def isRTL(self, index: QModelIndex) -> bool:
col = index.column()
type = self.columnType(col)
- if type != "noteFld":
+ c = self.getCard(index)
+ return self._is_rtl(c, type)
+
+ def _is_rtl(self, card: Card, column_type: str) -> bool:
+ if column_type != "noteFld":
return False
- c = self.getCard(index)
- nt = c.note().model()
+ nt = card.note().model()
return nt["flds"][self.col.models.sortIdx(nt)]["rtl"]
@@ -399,25 +534,23 @@ class StatusDelegate(QItemDelegate):
def paint(
self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex
) -> None:
- try:
- c = self.model.getCard(index)
- except:
- # in the the middle of a reset; return nothing so this row is not
- # rendered until we have a chance to reset the model
- return
+ row = self.model.get_row(index.row())
+ cell = row.columns[index.column()]
- if self.model.isRTL(index):
+ if cell.is_rtl:
option.direction = Qt.RightToLeft
- col = None
- if c.user_flag() > 0:
- col = getattr(colors, f"FLAG{c.user_flag()}_BG")
- elif c.note().has_tag("Marked"):
- col = colors.MARKED_BG
- elif c.queue == QUEUE_TYPE_SUSPENDED:
- col = colors.SUSPENDED_BG
- if col:
- brush = QBrush(theme_manager.qcolor(col))
+ if row.card_flag:
+ color = getattr(colors, f"FLAG{row.card_flag}_BG")
+ elif row.marked:
+ color = colors.MARKED_BG
+ elif row.suspended:
+ color = colors.SUSPENDED_BG
+ else:
+ color = None
+
+ if color:
+ brush = QBrush(theme_manager.qcolor(color))
painter.save()
painter.fillRect(option.rect, brush)
painter.restore()
@@ -428,8 +561,6 @@ class StatusDelegate(QItemDelegate):
# Browser window
######################################################################
-# fixme: respond to reset+edit hooks
-
class Browser(QMainWindow):
model: DataModel
@@ -475,43 +606,38 @@ class Browser(QMainWindow):
gui_hooks.browser_will_show(self)
self.show()
- def perform_op(
- self,
- op: Callable,
- on_done: Callable[[Future], None],
- *,
- reset_model: bool = True,
- ) -> None:
- """Run the provided operation on a background thread.
- - Ensures any changes in the editor have been saved.
- - Shows progress popup for the duration of the op.
- - Ensures the browser doesn't try to redraw during the operation, which can lead
- to a frozen UI
- - Updates undo state at the end of the operation
- - If `reset_model` is true, calls beginReset()/endReset(), which will
- refresh the displayed data, and update the editor's note. If the current search
- has changed results, you will need to call .search() yourself in `on_done`.
+ def on_backend_will_block(self) -> None:
+ # make sure the card list doesn't try to refresh itself during the operation,
+ # as that will block the UI
+ self.model.begin_blocking()
- Caller must run fut.result() in the on_done() callback to check for errors;
- if the operation returned a value, it will be returned by .result()
- """
+ def on_backend_did_block(self) -> None:
+ self.model.end_blocking()
- def wrapped_op() -> None:
- if reset_model:
- self.model.beginReset()
- self.setUpdatesEnabled(False)
- op()
+ def on_operation_did_execute(self, changes: OpChanges) -> None:
+ focused = current_top_level_widget() == self
+ self.model.op_executed(changes, focused)
+ self.sidebar.op_executed(changes, focused)
+ if changes.note or changes.notetype:
+ if not self.editor.is_updating_note():
+ # fixme: this will leave the splitter shown, but with no current
+ # note being edited
+ note = self.editor.note
+ if note:
+ try:
+ note.load()
+ except NotFoundError:
+ self.editor.set_note(None)
+ return
+ self.editor.set_note(note)
- def wrapped_done(fut: Future) -> None:
+ self._renderPreview()
+
+ def on_focus_change(self, new: Optional[QWidget], old: Optional[QWidget]) -> None:
+ if current_top_level_widget() == self:
self.setUpdatesEnabled(True)
- on_done(fut)
- if reset_model:
- self.model.endReset()
- self.mw.update_undo_actions()
-
- self.editor.saveNow(
- lambda: self.mw.taskman.with_progress(wrapped_op, wrapped_done)
- )
+ self.model.redraw_cells()
+ self.sidebar.refresh_if_needed()
def setupMenus(self) -> None:
# pylint: disable=unnecessary-lambda
@@ -532,8 +658,10 @@ class Browser(QMainWindow):
f.actionRemove_Tags.triggered,
lambda: self.remove_tags_from_selected_notes(),
)
- qconnect(f.actionClear_Unused_Tags.triggered, self.clearUnusedTags)
- qconnect(f.actionToggle_Mark.triggered, lambda: self.onMark())
+ qconnect(f.actionClear_Unused_Tags.triggered, self.clear_unused_tags)
+ qconnect(
+ f.actionToggle_Mark.triggered, lambda: self.toggle_mark_of_selected_notes()
+ )
qconnect(f.actionChangeModel.triggered, self.onChangeModel)
qconnect(f.actionFindDuplicates.triggered, self.onFindDupes)
qconnect(f.actionFindReplace.triggered, self.onFindReplace)
@@ -546,10 +674,16 @@ class Browser(QMainWindow):
qconnect(f.action_set_due_date.triggered, self.set_due_date)
qconnect(f.action_forget.triggered, self.forget_cards)
qconnect(f.actionToggle_Suspend.triggered, self.suspend_selected_cards)
- qconnect(f.actionRed_Flag.triggered, lambda: self.onSetFlag(1))
- qconnect(f.actionOrange_Flag.triggered, lambda: self.onSetFlag(2))
- qconnect(f.actionGreen_Flag.triggered, lambda: self.onSetFlag(3))
- qconnect(f.actionBlue_Flag.triggered, lambda: self.onSetFlag(4))
+ qconnect(f.actionRed_Flag.triggered, lambda: self.set_flag_of_selected_cards(1))
+ qconnect(
+ f.actionOrange_Flag.triggered, lambda: self.set_flag_of_selected_cards(2)
+ )
+ qconnect(
+ f.actionGreen_Flag.triggered, lambda: self.set_flag_of_selected_cards(3)
+ )
+ qconnect(
+ f.actionBlue_Flag.triggered, lambda: self.set_flag_of_selected_cards(4)
+ )
qconnect(f.actionExport.triggered, lambda: self._on_export_notes())
# jumps
qconnect(f.actionPreviousCard.triggered, self.onPreviousCard)
@@ -601,7 +735,7 @@ class Browser(QMainWindow):
if self._closeEventHasCleanedUp:
evt.accept()
return
- self.editor.saveNow(self._closeWindow)
+ self.editor.call_after_note_saved(self._closeWindow)
evt.ignore()
def _closeWindow(self) -> None:
@@ -618,12 +752,10 @@ class Browser(QMainWindow):
self.mw.deferred_delete_and_garbage_collect(self)
self.close()
+ @ensure_editor_saved
def closeWithCallback(self, onsuccess: Callable) -> None:
- def callback() -> None:
- self._closeWindow()
- onsuccess()
-
- self.editor.saveNow(callback)
+ self._closeWindow()
+ onsuccess()
def keyPressEvent(self, evt: QKeyEvent) -> None:
if evt.key() == Qt.Key_Escape:
@@ -689,10 +821,8 @@ class Browser(QMainWindow):
self.form.searchEdit.setFocus()
# search triggered by user
+ @ensure_editor_saved
def onSearchActivated(self) -> None:
- self.editor.saveNow(self._onSearchActivated)
-
- def _onSearchActivated(self) -> None:
text = self.form.searchEdit.lineEdit().text()
try:
normed = self.col.build_search_string(text)
@@ -725,7 +855,7 @@ class Browser(QMainWindow):
show_invalid_search_error(err)
if not self.model.cards:
# no row change will fire
- self._onRowChanged(None, None)
+ self.onRowChanged(None, None)
def update_history(self) -> None:
sh = self.mw.pm.profile["searchHistory"]
@@ -762,12 +892,11 @@ class Browser(QMainWindow):
self.search_for(search, "")
self.focusCid(card.id)
- self.editor.saveNow(on_show_single_card)
+ self.editor.call_after_note_saved(on_show_single_card)
def onReset(self) -> None:
self.sidebar.refresh()
- self.editor.setNote(None)
- self.search()
+ self.model.reset()
# Table view & editor
######################################################################
@@ -822,39 +951,33 @@ QTableView {{ gridline-color: {grid} }}
self.editor = aqt.editor.Editor(self.mw, self.form.fieldsArea, self)
gui_hooks.editor_did_init_left_buttons.remove(add_preview_button)
- def onRowChanged(self, current: QItemSelection, previous: QItemSelection) -> None:
- "Update current note and hide/show editor."
- self.editor.saveNow(lambda: self._onRowChanged(current, previous))
-
- def _onRowChanged(self, current: QItemSelection, previous: QItemSelection) -> None:
+ @ensure_editor_saved
+ def onRowChanged(
+ self, current: Optional[QItemSelection], previous: Optional[QItemSelection]
+ ) -> None:
+ """Update current note and hide/show editor."""
if self._closeEventHasCleanedUp:
return
update = self.updateTitle()
show = self.model.cards and update == 1
- self.form.splitter.widget(1).setVisible(bool(show))
idx = self.form.tableView.selectionModel().currentIndex()
if idx.isValid():
self.card = self.model.getCard(idx)
+ show = show and self.card is not None
+ self.form.splitter.widget(1).setVisible(bool(show))
if not show:
- self.editor.setNote(None)
+ self.editor.set_note(None)
self.singleCard = False
self._renderPreview()
else:
- self.editor.setNote(self.card.note(reload=True), focusTo=self.focusTo)
+ self.editor.set_note(self.card.note(reload=True), focusTo=self.focusTo)
self.focusTo = None
self.editor.card = self.card
self.singleCard = True
- self._updateFlagsMenu()
+ self._update_flags_menu()
gui_hooks.browser_did_change_row(self)
- def refreshCurrentCard(self, note: Note) -> None:
- self.model.refreshNote(note)
- self._renderPreview()
-
- def onLoadNote(self, editor: Editor) -> None:
- self.refreshCurrentCard(editor.note)
-
def currentRow(self) -> int:
idx = self.form.tableView.selectionModel().currentIndex()
return idx.row()
@@ -879,11 +1002,9 @@ QTableView {{ gridline-color: {grid} }}
qconnect(hh.sortIndicatorChanged, self.onSortChanged)
qconnect(hh.sectionMoved, self.onColumnMoved)
+ @ensure_editor_saved
def onSortChanged(self, idx: int, ord: int) -> None:
- ord_bool = bool(ord)
- self.editor.saveNow(lambda: self._onSortChanged(idx, ord_bool))
-
- def _onSortChanged(self, idx: int, ord: bool) -> None:
+ ord = bool(ord)
type = self.model.activeCols[idx]
noSort = ("question", "answer")
if type in noSort:
@@ -931,10 +1052,8 @@ QTableView {{ gridline-color: {grid} }}
gui_hooks.browser_header_will_show_context_menu(self, m)
m.exec_(gpos)
+ @ensure_editor_saved_on_trigger
def toggleField(self, type: str) -> None:
- self.editor.saveNow(lambda: self._toggleField(type))
-
- def _toggleField(self, type: str) -> None:
self.model.beginReset()
if type in self.model.activeCols:
if len(self.model.activeCols) < 2:
@@ -978,15 +1097,13 @@ QTableView {{ gridline-color: {grid} }}
self.sidebar = SidebarTreeView(self)
self.sidebarTree = self.sidebar # legacy alias
dw.setWidget(self.sidebar)
- self.sidebar.toolbar = toolbar = SidebarToolbar(self.sidebar)
- self.sidebar.searchBar = searchBar = SidebarSearchBar(self.sidebar)
qconnect(
self.form.actionSidebarFilter.triggered,
self.focusSidebarSearchBar,
)
grid = QGridLayout()
- grid.addWidget(searchBar, 0, 0)
- grid.addWidget(toolbar, 0, 1)
+ grid.addWidget(self.sidebar.searchBar, 0, 0)
+ grid.addWidget(self.sidebar.toolbar, 0, 1)
grid.addWidget(self.sidebar, 1, 0, 1, 2)
grid.setContentsMargins(0, 0, 0, 0)
grid.setSpacing(0)
@@ -1065,13 +1182,13 @@ QTableView {{ gridline-color: {grid} }}
# Menu helpers
######################################################################
- def selectedCards(self) -> List[int]:
+ def selected_cards(self) -> List[int]:
return [
self.model.cards[idx.row()]
for idx in self.form.tableView.selectionModel().selectedRows()
]
- def selectedNotes(self) -> List[int]:
+ def selected_notes(self) -> List[int]:
return self.col.db.list(
"""
select distinct nid from cards
@@ -1087,11 +1204,11 @@ where id in %s"""
def selectedNotesAsCards(self) -> List[int]:
return self.col.db.list(
"select id from cards where nid in (%s)"
- % ",".join([str(s) for s in self.selectedNotes()])
+ % ",".join([str(s) for s in self.selected_notes()])
)
def oneModelNotes(self) -> List[int]:
- sf = self.selectedNotes()
+ sf = self.selected_notes()
if not sf:
return []
mods = self.col.db.scalar(
@@ -1108,23 +1225,23 @@ where id in %s"""
def onHelp(self) -> None:
openHelp(HelpPage.BROWSING)
+ # legacy
+
+ selectedCards = selected_cards
+ selectedNotes = selected_notes
+
# Misc menu options
######################################################################
+ @ensure_editor_saved_on_trigger
def onChangeModel(self) -> None:
- self.editor.saveNow(self._onChangeModel)
-
- def _onChangeModel(self) -> None:
nids = self.oneModelNotes()
if nids:
ChangeModel(self, nids)
def createFilteredDeck(self) -> None:
search = self.form.searchEdit.lineEdit().text()
- if (
- self.mw.col.schedVer() != 1
- and self.mw.app.keyboardModifiers() & Qt.AltModifier
- ):
+ if self.mw.col.schedVer() != 1 and KeyboardModifiersPressed().alt:
aqt.dialogs.open("DynDeckConfDialog", self.mw, search_2=search)
else:
aqt.dialogs.open("DynDeckConfDialog", self.mw, search=search)
@@ -1168,39 +1285,18 @@ where id in %s"""
return
# nothing selected?
- nids = self.selectedNotes()
+ nids = self.selected_notes()
if not nids:
return
- # figure out where to place the cursor after the deletion
- current_row = self.form.tableView.selectionModel().currentIndex().row()
- selected_rows = [
- i.row() for i in self.form.tableView.selectionModel().selectedRows()
- ]
- if min(selected_rows) < current_row < max(selected_rows):
- # last selection in middle; place one below last selected item
- move = sum(1 for i in selected_rows if i > current_row)
- new_row = current_row - move
- elif max(selected_rows) <= current_row:
- # last selection at bottom; place one below bottommost selection
- new_row = max(selected_rows) - len(nids) + 1
- else:
- # last selection at top; place one above topmost selection
- new_row = min(selected_rows) - 1
+ # select the next card if there is one
+ self._onNextCard()
- def do_remove() -> None:
- self.col.remove_notes(nids)
-
- def on_done(fut: Future) -> None:
- fut.result()
- self.search()
- if len(self.model.cards):
- row = min(new_row, len(self.model.cards) - 1)
- row = max(row, 0)
- self.model.focusedCard = self.model.cards[row]
- tooltip(tr(TR.BROWSING_NOTE_DELETED, count=len(nids)))
-
- self.perform_op(do_remove, on_done, reset_model=True)
+ remove_notes(
+ mw=self.mw,
+ note_ids=nids,
+ success=lambda _: tooltip(tr(TR.BROWSING_NOTE_DELETED, count=len(nids))),
+ )
# legacy
@@ -1209,10 +1305,11 @@ where id in %s"""
# Deck change
######################################################################
+ @ensure_editor_saved_on_trigger
def set_deck_of_selected_cards(self) -> None:
from aqt.studydeck import StudyDeck
- cids = self.selectedCards()
+ cids = self.selected_cards()
if not cids:
return
@@ -1230,14 +1327,7 @@ where id in %s"""
return
did = self.col.decks.id(ret.name)
- def do_move() -> None:
- self.col.set_deck(cids, did)
-
- def on_done(fut: Future) -> None:
- fut.result()
- self.mw.requireReset(reason=ResetReason.BrowserSetDeck, context=self)
-
- self.perform_op(do_move, on_done)
+ set_card_deck(mw=self.mw, card_ids=cids, deck_id=did)
# legacy
@@ -1246,58 +1336,55 @@ where id in %s"""
# Tags
######################################################################
+ @ensure_editor_saved_on_trigger
def add_tags_to_selected_notes(
self,
tags: Optional[str] = None,
) -> None:
"Shows prompt if tags not provided."
- self.editor.saveNow(
- lambda: self._update_tags_of_selected_notes(
- func=self.col.tags.bulk_add,
- tags=tags,
- prompt=tr(TR.BROWSING_ENTER_TAGS_TO_ADD),
- )
+ if not (
+ tags := tags or self._prompt_for_tags(tr(TR.BROWSING_ENTER_TAGS_TO_ADD))
+ ):
+ return
+ add_tags(
+ mw=self.mw,
+ note_ids=self.selected_notes(),
+ space_separated_tags=tags,
+ success=lambda out: tooltip(
+ tr(TR.BROWSING_NOTES_UPDATED, count=out.count), parent=self
+ ),
)
+ @ensure_editor_saved_on_trigger
def remove_tags_from_selected_notes(self, tags: Optional[str] = None) -> None:
"Shows prompt if tags not provided."
- self.editor.saveNow(
- lambda: self._update_tags_of_selected_notes(
- func=self.col.tags.bulk_remove,
- tags=tags,
- prompt=tr(TR.BROWSING_ENTER_TAGS_TO_DELETE),
- )
+ if not (
+ tags := tags or self._prompt_for_tags(tr(TR.BROWSING_ENTER_TAGS_TO_DELETE))
+ ):
+ return
+ remove_tags_for_notes(
+ mw=self.mw,
+ note_ids=self.selected_notes(),
+ space_separated_tags=tags,
+ success=lambda out: tooltip(
+ tr(TR.BROWSING_NOTES_UPDATED, count=out.count), parent=self
+ ),
)
- def _update_tags_of_selected_notes(
- self,
- func: Callable[[List[int], str], int],
- tags: Optional[str],
- prompt: Optional[str],
- ) -> None:
- "If tags provided, prompt skipped. If tags not provided, prompt must be."
- if tags is None:
- (tags, ok) = getTag(self, self.col, prompt)
- if not ok:
- return
+ def _prompt_for_tags(self, prompt: str) -> Optional[str]:
+ (tags, ok) = getTag(self, self.col, prompt)
+ if not ok:
+ return None
+ else:
+ return tags
- self.model.beginReset()
- func(self.selectedNotes(), tags)
- self.model.endReset()
- self.mw.requireReset(reason=ResetReason.BrowserAddTags, context=self)
-
- def clearUnusedTags(self) -> None:
- self.editor.saveNow(self._clearUnusedTags)
-
- def _clearUnusedTags(self) -> None:
- def on_done(fut: Future) -> None:
- fut.result()
- self.on_tag_list_update()
-
- self.mw.taskman.run_in_background(self.col.tags.registerNotes, on_done)
+ @ensure_editor_saved_on_trigger
+ def clear_unused_tags(self) -> None:
+ clear_unused_tags(mw=self.mw, parent=self)
addTags = add_tags_to_selected_notes
deleteTags = remove_tags_from_selected_notes
+ clearUnusedTags = clear_unused_tags
# Suspending
######################################################################
@@ -1305,18 +1392,15 @@ where id in %s"""
def current_card_is_suspended(self) -> bool:
return bool(self.card and self.card.queue == QUEUE_TYPE_SUSPENDED)
+ @ensure_editor_saved_on_trigger
def suspend_selected_cards(self) -> None:
- self.editor.saveNow(self._suspend_selected_cards)
-
- def _suspend_selected_cards(self) -> None:
want_suspend = not self.current_card_is_suspended()
- c = self.selectedCards()
+ cids = self.selected_cards()
+
if want_suspend:
- self.col.sched.suspend_cards(c)
+ suspend_cards(mw=self.mw, card_ids=cids)
else:
- self.col.sched.unsuspend_cards(c)
- self.model.reset()
- self.mw.requireReset(reason=ResetReason.BrowserSuspend, context=self)
+ unsuspend_cards(mw=self.mw, card_ids=cids)
# Exporting
######################################################################
@@ -1329,28 +1413,26 @@ where id in %s"""
# Flags & Marking
######################################################################
- def onSetFlag(self, n: int) -> None:
+ @ensure_editor_saved
+ def set_flag_of_selected_cards(self, flag: int) -> None:
if not self.card:
return
- self.editor.saveNow(lambda: self._on_set_flag(n))
- def _on_set_flag(self, n: int) -> None:
# flag needs toggling off?
- if n == self.card.user_flag():
- n = 0
- self.col.set_user_flag_for_cards(n, self.selectedCards())
- self.model.reset()
+ if flag == self.card.user_flag():
+ flag = 0
- def _updateFlagsMenu(self) -> None:
+ set_card_flag(mw=self.mw, card_ids=self.selected_cards(), flag=flag)
+
+ def _update_flags_menu(self) -> None:
flag = self.card and self.card.user_flag()
flag = flag or 0
- f = self.form
flagActions = [
- f.actionRed_Flag,
- f.actionOrange_Flag,
- f.actionGreen_Flag,
- f.actionBlue_Flag,
+ self.form.actionRed_Flag,
+ self.form.actionOrange_Flag,
+ self.form.actionGreen_Flag,
+ self.form.actionBlue_Flag,
]
for c, act in enumerate(flagActions):
@@ -1358,98 +1440,49 @@ where id in %s"""
qtMenuShortcutWorkaround(self.form.menuFlag)
- def onMark(self, mark: bool = None) -> None:
- if mark is None:
- mark = not self.isMarked()
- if mark:
- self.add_tags_to_selected_notes(tags="marked")
+ def toggle_mark_of_selected_notes(self) -> None:
+ have_mark = bool(self.card and self.card.note().has_tag(MARKED_TAG))
+ if have_mark:
+ self.remove_tags_from_selected_notes(tags=MARKED_TAG)
else:
- self.remove_tags_from_selected_notes(tags="marked")
-
- def isMarked(self) -> bool:
- return bool(self.card and self.card.note().has_tag("Marked"))
-
- # Repositioning
- ######################################################################
-
- def reposition(self) -> None:
- self.editor.saveNow(self._reposition)
-
- def _reposition(self) -> None:
- cids = self.selectedCards()
- cids2 = self.col.db.list(
- f"select id from cards where type = {CARD_TYPE_NEW} and id in "
- + ids2str(cids)
- )
- if not cids2:
- showInfo(tr(TR.BROWSING_ONLY_NEW_CARDS_CAN_BE_REPOSITIONED))
- return
- d = QDialog(self)
- disable_help_button(d)
- d.setWindowModality(Qt.WindowModal)
- frm = aqt.forms.reposition.Ui_Dialog()
- frm.setupUi(d)
- (pmin, pmax) = self.col.db.first(
- f"select min(due), max(due) from cards where type={CARD_TYPE_NEW} and odid=0"
- )
- pmin = pmin or 0
- pmax = pmax or 0
- txt = tr(TR.BROWSING_QUEUE_TOP, val=pmin)
- txt += "\n" + tr(TR.BROWSING_QUEUE_BOTTOM, val=pmax)
- frm.label.setText(txt)
- frm.start.selectAll()
- if not d.exec_():
- return
- self.model.beginReset()
- self.mw.checkpoint(tr(TR.ACTIONS_REPOSITION))
- self.col.sched.sortCards(
- cids,
- start=frm.start.value(),
- step=frm.step.value(),
- shuffle=frm.randomize.isChecked(),
- shift=frm.shift.isChecked(),
- )
- self.search()
- self.mw.requireReset(reason=ResetReason.BrowserReposition, context=self)
- self.model.endReset()
+ self.add_tags_to_selected_notes(tags=MARKED_TAG)
# Scheduling
######################################################################
- def _after_schedule(self) -> None:
- self.model.reset()
- # updates undo status
- self.mw.requireReset(reason=ResetReason.BrowserReschedule, context=self)
+ @ensure_editor_saved_on_trigger
+ def reposition(self) -> None:
+ if self.card and self.card.queue != QUEUE_TYPE_NEW:
+ showInfo(tr(TR.BROWSING_ONLY_NEW_CARDS_CAN_BE_REPOSITIONED), parent=self)
+ return
- def set_due_date(self) -> None:
- self.editor.saveNow(
- lambda: set_due_date_dialog(
- mw=self.mw,
- parent=self,
- card_ids=self.selectedCards(),
- config_key=Config.String.SET_DUE_BROWSER,
- on_done=self._after_schedule,
- )
+ reposition_new_cards_dialog(
+ mw=self.mw, parent=self, card_ids=self.selected_cards()
)
+ @ensure_editor_saved_on_trigger
+ def set_due_date(self) -> None:
+ set_due_date_dialog(
+ mw=self.mw,
+ parent=self,
+ card_ids=self.selected_cards(),
+ config_key=Config.String.SET_DUE_BROWSER,
+ )
+
+ @ensure_editor_saved_on_trigger
def forget_cards(self) -> None:
- self.editor.saveNow(
- lambda: forget_cards(
- mw=self.mw,
- parent=self,
- card_ids=self.selectedCards(),
- on_done=self._after_schedule,
- )
+ forget_cards(
+ mw=self.mw,
+ parent=self,
+ card_ids=self.selected_cards(),
)
# Edit: selection
######################################################################
+ @ensure_editor_saved_on_trigger
def selectNotes(self) -> None:
- self.editor.saveNow(self._selectNotes)
-
- def _selectNotes(self) -> None:
- nids = self.selectedNotes()
+ nids = self.selected_notes()
# clear the selection so we don't waste energy preserving it
tv = self.form.tableView
tv.selectionModel().clear()
@@ -1472,24 +1505,22 @@ where id in %s"""
def setupHooks(self) -> None:
gui_hooks.undo_state_did_change.append(self.onUndoState)
- gui_hooks.state_did_reset.append(self.onReset)
- gui_hooks.editor_did_fire_typing_timer.append(self.refreshCurrentCard)
- gui_hooks.editor_did_load_note.append(self.onLoadNote)
- gui_hooks.editor_did_unfocus_field.append(self.on_unfocus_field)
+ # fixme: remove these once all items are using `operation_did_execute`
gui_hooks.sidebar_should_refresh_decks.append(self.on_item_added)
gui_hooks.sidebar_should_refresh_notetypes.append(self.on_item_added)
+ gui_hooks.backend_will_block.append(self.on_backend_will_block)
+ gui_hooks.backend_did_block.append(self.on_backend_did_block)
+ gui_hooks.operation_did_execute.append(self.on_operation_did_execute)
+ gui_hooks.focus_did_change.append(self.on_focus_change)
def teardownHooks(self) -> None:
gui_hooks.undo_state_did_change.remove(self.onUndoState)
- gui_hooks.state_did_reset.remove(self.onReset)
- gui_hooks.editor_did_fire_typing_timer.remove(self.refreshCurrentCard)
- gui_hooks.editor_did_load_note.remove(self.onLoadNote)
- gui_hooks.editor_did_unfocus_field.remove(self.on_unfocus_field)
gui_hooks.sidebar_should_refresh_decks.remove(self.on_item_added)
gui_hooks.sidebar_should_refresh_notetypes.remove(self.on_item_added)
-
- def on_unfocus_field(self, changed: bool, note: Note, field_idx: int) -> None:
- self.refreshCurrentCard(note)
+ gui_hooks.backend_will_block.remove(self.on_backend_will_block)
+ gui_hooks.backend_did_block.remove(self.on_backend_will_block)
+ gui_hooks.operation_did_execute.remove(self.on_operation_did_execute)
+ gui_hooks.focus_did_change.remove(self.on_focus_change)
# covers the tag, note and deck case
def on_item_added(self, item: Any = None) -> None:
@@ -1516,103 +1547,21 @@ where id in %s"""
# Edit: replacing
######################################################################
+ @ensure_editor_saved_on_trigger
def onFindReplace(self) -> None:
- self.editor.saveNow(self._onFindReplace)
-
- def _onFindReplace(self) -> None:
- nids = self.selectedNotes()
+ nids = self.selected_notes()
if not nids:
return
- import anki.find
- def find() -> List[str]:
- return anki.find.fieldNamesForNotes(self.mw.col, nids)
-
- def on_done(fut: Future) -> None:
- self._on_find_replace_diag(fut.result(), nids)
-
- self.mw.taskman.with_progress(find, on_done, self)
-
- def _on_find_replace_diag(self, fields: List[str], nids: List[int]) -> None:
- d = QDialog(self)
- disable_help_button(d)
- frm = aqt.forms.findreplace.Ui_Dialog()
- frm.setupUi(d)
- d.setWindowModality(Qt.WindowModal)
-
- combo = "BrowserFindAndReplace"
- findhistory = restore_combo_history(frm.find, combo + "Find")
- frm.find.completer().setCaseSensitivity(True)
- replacehistory = restore_combo_history(frm.replace, combo + "Replace")
- frm.replace.completer().setCaseSensitivity(True)
-
- restore_is_checked(frm.re, combo + "Regex")
- restore_is_checked(frm.ignoreCase, combo + "ignoreCase")
-
- frm.find.setFocus()
- allfields = [tr(TR.BROWSING_ALL_FIELDS)] + fields
- frm.field.addItems(allfields)
- restore_combo_index_for_session(frm.field, allfields, combo + "Field")
- qconnect(frm.buttonBox.helpRequested, self.onFindReplaceHelp)
- restoreGeom(d, "findreplace")
- r = d.exec_()
- saveGeom(d, "findreplace")
- if not r:
- return
-
- save_combo_index_for_session(frm.field, combo + "Field")
- if frm.field.currentIndex() == 0:
- field = None
- else:
- field = fields[frm.field.currentIndex() - 1]
-
- search = save_combo_history(frm.find, findhistory, combo + "Find")
- replace = save_combo_history(frm.replace, replacehistory, combo + "Replace")
-
- regex = frm.re.isChecked()
- nocase = frm.ignoreCase.isChecked()
-
- save_is_checked(frm.re, combo + "Regex")
- save_is_checked(frm.ignoreCase, combo + "ignoreCase")
-
- self.mw.checkpoint(tr(TR.BROWSING_FIND_AND_REPLACE))
- # starts progress dialog as well
- self.model.beginReset()
-
- def do_search() -> int:
- return self.col.find_and_replace(
- nids, search, replace, regex, field, nocase
- )
-
- def on_done(fut: Future) -> None:
- self.search()
- self.mw.requireReset(reason=ResetReason.BrowserFindReplace, context=self)
- self.model.endReset()
-
- total = len(nids)
- try:
- changed = fut.result()
- except InvalidInput as e:
- show_invalid_search_error(e)
- return
-
- showInfo(
- tr(TR.FINDREPLACE_NOTES_UPDATED, changed=changed, total=total),
- parent=self,
- )
-
- self.mw.taskman.run_in_background(do_search, on_done)
-
- def onFindReplaceHelp(self) -> None:
- openHelp(HelpPage.BROWSING_FIND_AND_REPLACE)
+ FindAndReplaceDialog(self, mw=self.mw, note_ids=nids)
# Edit: finding dupes
######################################################################
+ @ensure_editor_saved
def onFindDupes(self) -> None:
- self.editor.saveNow(self._onFindDupes)
+ import anki.find
- def _onFindDupes(self) -> None:
d = QDialog(self)
self.mw.garbage_collect_on_dialog_finish(d)
frm = aqt.forms.finddupes.Ui_Dialog()
@@ -1731,14 +1680,14 @@ where id in %s"""
def onPreviousCard(self) -> None:
self.focusTo = self.editor.currentField
- self.editor.saveNow(self._onPreviousCard)
+ self.editor.call_after_note_saved(self._onPreviousCard)
def _onPreviousCard(self) -> None:
self._moveCur(QAbstractItemView.MoveUp)
def onNextCard(self) -> None:
self.focusTo = self.editor.currentField
- self.editor.saveNow(self._onNextCard)
+ self.editor.call_after_note_saved(self._onNextCard)
def _onNextCard(self) -> None:
self._moveCur(QAbstractItemView.MoveDown)
@@ -1747,7 +1696,7 @@ where id in %s"""
sm = self.form.tableView.selectionModel()
idx = sm.currentIndex()
self._moveCur(None, self.model.index(0, 0))
- if not self.mw.app.keyboardModifiers() & Qt.ShiftModifier:
+ if not KeyboardModifiersPressed().shift:
return
idx2 = sm.currentIndex()
item = QItemSelection(idx2, idx)
@@ -1757,7 +1706,7 @@ where id in %s"""
sm = self.form.tableView.selectionModel()
idx = sm.currentIndex()
self._moveCur(None, self.model.index(len(self.model.cards) - 1, 0))
- if not self.mw.app.keyboardModifiers() & Qt.ShiftModifier:
+ if not KeyboardModifiersPressed().shift:
return
idx2 = sm.currentIndex()
item = QItemSelection(idx, idx2)
@@ -1807,6 +1756,9 @@ class ChangeModel(QDialog):
restoreGeom(self, "changeModel")
gui_hooks.state_did_reset.append(self.onReset)
gui_hooks.current_note_type_did_change.append(self.on_note_type_change)
+ # ugh - these are set dynamically by rebuildTemplateMap()
+ self.tcombos: List[QComboBox] = []
+ self.fcombos: List[QComboBox] = []
self.exec_()
def on_note_type_change(self, notetype: NoteType) -> None:
diff --git a/qt/aqt/card_ops.py b/qt/aqt/card_ops.py
new file mode 100644
index 000000000..d7553527a
--- /dev/null
+++ b/qt/aqt/card_ops.py
@@ -0,0 +1,16 @@
+# Copyright: Ankitects Pty Ltd and contributors
+# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
+
+from __future__ import annotations
+
+from typing import Sequence
+
+from aqt import AnkiQt
+
+
+def set_card_deck(*, mw: AnkiQt, card_ids: Sequence[int], deck_id: int) -> None:
+ mw.perform_op(lambda: mw.col.set_deck(card_ids, deck_id))
+
+
+def set_card_flag(*, mw: AnkiQt, card_ids: Sequence[int], flag: int) -> None:
+ mw.perform_op(lambda: mw.col.set_user_flag_for_cards(flag, card_ids))
diff --git a/qt/aqt/clayout.py b/qt/aqt/clayout.py
index 01474cd21..318b942b0 100644
--- a/qt/aqt/clayout.py
+++ b/qt/aqt/clayout.py
@@ -795,7 +795,7 @@ class CardLayout(QDialog):
showWarning(str(e))
return
self.mw.reset()
- tooltip(tr(TR.CARD_TEMPLATES_CHANGES_SAVED), parent=self.parent())
+ tooltip(tr(TR.CARD_TEMPLATES_CHANGES_SAVED), parent=self.parentWidget())
self.cleanup()
gui_hooks.sidebar_should_refresh_notetypes()
return QDialog.accept(self)
diff --git a/qt/aqt/deck_ops.py b/qt/aqt/deck_ops.py
new file mode 100644
index 000000000..60a70a49a
--- /dev/null
+++ b/qt/aqt/deck_ops.py
@@ -0,0 +1,24 @@
+# Copyright: Ankitects Pty Ltd and contributors
+# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
+
+from __future__ import annotations
+
+from typing import Sequence
+
+from anki.lang import TR
+from aqt import AnkiQt, QWidget
+from aqt.utils import tooltip, tr
+
+
+def remove_decks(
+ *,
+ mw: AnkiQt,
+ parent: QWidget,
+ deck_ids: Sequence[int],
+) -> None:
+ mw.perform_op(
+ lambda: mw.col.decks.remove(deck_ids),
+ success=lambda out: tooltip(
+ tr(TR.BROWSING_CARDS_DELETED, count=out.count), parent=parent
+ ),
+ )
diff --git a/qt/aqt/deckbrowser.py b/qt/aqt/deckbrowser.py
index e80a7953f..af6ca9cb4 100644
--- a/qt/aqt/deckbrowser.py
+++ b/qt/aqt/deckbrowser.py
@@ -8,10 +8,12 @@ from dataclasses import dataclass
from typing import Any
import aqt
+from anki.collection import OpChanges
from anki.decks import DeckTreeNode
from anki.errors import DeckIsFilteredError
from anki.utils import intTime
from aqt import AnkiQt, gui_hooks
+from aqt.deck_ops import remove_decks
from aqt.qt import *
from aqt.sound import av_player
from aqt.toolbar import BottomBar
@@ -23,7 +25,6 @@ from aqt.utils import (
shortcut,
showInfo,
showWarning,
- tooltip,
tr,
)
@@ -61,6 +62,7 @@ class DeckBrowser:
self.bottom = BottomBar(mw, mw.bottomWeb)
self.scrollPos = QPoint(0, 0)
self._v1_message_dismissed_at = 0
+ self._refresh_needed = False
def show(self) -> None:
av_player.stop_and_clear_queue()
@@ -68,9 +70,24 @@ class DeckBrowser:
self._renderPage()
# redraw top bar for theme change
self.mw.toolbar.redraw()
+ self.refresh()
def refresh(self) -> None:
self._renderPage()
+ self._refresh_needed = False
+
+ def refresh_if_needed(self) -> None:
+ if self._refresh_needed:
+ self.refresh()
+
+ def op_executed(self, changes: OpChanges, focused: bool) -> bool:
+ if self.mw.col.op_affects_study_queue(changes):
+ self._refresh_needed = True
+
+ if focused:
+ self.refresh_if_needed()
+
+ return self._refresh_needed
# Event handlers
##########################################################################
@@ -145,7 +162,6 @@ class DeckBrowser:
],
context=self,
)
- self.web.key = "deckBrowser"
self._drawButtons()
if offset is not None:
self._scrollToOffset(offset)
@@ -305,15 +321,7 @@ class DeckBrowser:
self.mw.taskman.with_progress(process, on_done)
def _delete(self, did: int) -> None:
- def do_delete() -> int:
- return self.mw.col.decks.remove([did])
-
- def on_done(fut: Future) -> None:
- self.mw.update_undo_actions()
- self.show()
- tooltip(tr(TR.BROWSING_CARDS_DELETED, count=fut.result()))
-
- self.mw.taskman.with_progress(do_delete, on_done)
+ remove_decks(mw=self.mw, parent=self.mw, deck_ids=[did])
# Top buttons
######################################################################
diff --git a/qt/aqt/editcurrent.py b/qt/aqt/editcurrent.py
index 4a540d76b..79ebd8e83 100644
--- a/qt/aqt/editcurrent.py
+++ b/qt/aqt/editcurrent.py
@@ -1,10 +1,12 @@
# Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
+
import aqt.editor
+from anki.collection import OpChanges
+from anki.errors import NotFoundError
from aqt import gui_hooks
-from aqt.main import ResetReason
from aqt.qt import *
-from aqt.utils import TR, disable_help_button, restoreGeom, saveGeom, tooltip, tr
+from aqt.utils import TR, disable_help_button, restoreGeom, saveGeom, tr
class EditCurrent(QDialog):
@@ -23,59 +25,53 @@ class EditCurrent(QDialog):
)
self.editor = aqt.editor.Editor(self.mw, self.form.fieldsArea, self)
self.editor.card = self.mw.reviewer.card
- self.editor.setNote(self.mw.reviewer.card.note(), focusTo=0)
+ self.editor.set_note(self.mw.reviewer.card.note(), focusTo=0)
restoreGeom(self, "editcurrent")
- gui_hooks.state_did_reset.append(self.onReset)
- self.mw.requireReset(reason=ResetReason.EditCurrentInit, context=self)
+ gui_hooks.operation_did_execute.append(self.on_operation_did_execute)
self.show()
- # reset focus after open, taking care not to retain webview
- # pylint: disable=unnecessary-lambda
- self.mw.progress.timer(100, lambda: self.editor.web.setFocus(), False)
- def onReset(self) -> None:
- # lazy approach for now: throw away edits
- try:
- n = self.editor.note
- n.load() # reload in case the model changed
- except:
- # card's been deleted
- gui_hooks.state_did_reset.remove(self.onReset)
- self.editor.setNote(None)
- self.mw.reset()
- aqt.dialogs.markClosed("EditCurrent")
- self.close()
+ def on_operation_did_execute(self, changes: OpChanges) -> None:
+ if not (changes.note or changes.notetype):
return
- self.editor.setNote(n)
+ if self.editor.is_updating_note():
+ return
+
+ # reload note
+ note = self.editor.note
+ try:
+ note.load()
+ except NotFoundError:
+ # note's been deleted
+ self.cleanup_and_close()
+ return
+
+ self.editor.set_note(note)
+
+ def cleanup_and_close(self) -> None:
+ gui_hooks.operation_did_execute.remove(self.on_operation_did_execute)
+ self.editor.cleanup()
+ saveGeom(self, "editcurrent")
+ aqt.dialogs.markClosed("EditCurrent")
+ QDialog.reject(self)
def reopen(self, mw: aqt.AnkiQt) -> None:
- tooltip("Please finish editing the existing card first.")
- self.onReset()
+ if card := self.mw.reviewer.card:
+ self.editor.set_note(card.note())
def reject(self) -> None:
self.saveAndClose()
def saveAndClose(self) -> None:
- self.editor.saveNow(self._saveAndClose)
+ self.editor.call_after_note_saved(self._saveAndClose)
def _saveAndClose(self) -> None:
- gui_hooks.state_did_reset.remove(self.onReset)
- r = self.mw.reviewer
- try:
- r.card.load()
- except:
- # card was removed by clayout
- pass
- else:
- self.mw.reviewer.cardQueue.append(self.mw.reviewer.card)
- self.editor.cleanup()
- self.mw.moveToState("review")
- saveGeom(self, "editcurrent")
- aqt.dialogs.markClosed("EditCurrent")
- QDialog.reject(self)
+ self.cleanup_and_close()
def closeWithCallback(self, onsuccess: Callable[[], None]) -> None:
def callback() -> None:
self._saveAndClose()
onsuccess()
- self.editor.saveNow(callback)
+ self.editor.call_after_note_saved(callback)
+
+ onReset = on_operation_did_execute
diff --git a/qt/aqt/editor.py b/qt/aqt/editor.py
index ec2d69dd8..b18e99537 100644
--- a/qt/aqt/editor.py
+++ b/qt/aqt/editor.py
@@ -1,5 +1,8 @@
# 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 base64
import html
import itertools
@@ -24,16 +27,17 @@ from anki.collection import Config, SearchNode
from anki.consts import MODEL_CLOZE
from anki.hooks import runFilter
from anki.httpclient import HttpClient
-from anki.notes import Note
+from anki.notes import DuplicateOrEmptyResult, Note
from anki.utils import checksum, isLin, isWin, namedtmp
from aqt import AnkiQt, colors, gui_hooks
-from aqt.main import ResetReason
+from aqt.note_ops import update_note
from aqt.qt import *
from aqt.sound import av_player
from aqt.theme import theme_manager
from aqt.utils import (
TR,
HelpPage,
+ KeyboardModifiersPressed,
disable_help_button,
getFile,
openHelp,
@@ -90,8 +94,17 @@ _html = """
"""
-# caller is responsible for resetting note on reset
+
class Editor:
+ """The screen that embeds an editing widget should listen for changes via
+ the `operation_did_execute` hook, and call set_note() when the editor needs
+ redrawing.
+
+ The editor will cause that hook to be fired when it saves changes. To avoid
+ an unwanted refresh, the parent widget should call editor.is_updating_note(),
+ and avoid re-setting the note if it returns true.
+ """
+
def __init__(
self, mw: AnkiQt, widget: QWidget, parentWindow: QWidget, addMode: bool = False
) -> None:
@@ -101,6 +114,7 @@ class Editor:
self.note: Optional[Note] = None
self.addMode = addMode
self.currentField: Optional[int] = None
+ self._is_updating_note = False
# current card, for card layout
self.card: Optional[Card] = None
self.setupOuter()
@@ -399,7 +413,7 @@ class Editor:
return checkFocus
def onFields(self) -> None:
- self.saveNow(self._onFields)
+ self.call_after_note_saved(self._onFields)
def _onFields(self) -> None:
from aqt.fields import FieldDialog
@@ -407,7 +421,7 @@ class Editor:
FieldDialog(self.mw, self.note.model(), parent=self.parentWindow)
def onCardLayout(self) -> None:
- self.saveNow(self._onCardLayout)
+ self.call_after_note_saved(self._onCardLayout)
def _onCardLayout(self) -> None:
from aqt.clayout import CardLayout
@@ -450,7 +464,6 @@ class Editor:
if not self.addMode:
self._save_current_note()
- self.mw.requireReset(reason=ResetReason.EditorBridgeCmd, context=self)
if type == "blur":
self.currentField = None
# run any filters
@@ -459,10 +472,10 @@ class Editor:
# event has had time to fire
self.mw.progress.timer(100, self.loadNoteKeepingFocus, False)
else:
- self.checkValid()
+ self._check_and_update_duplicate_display_async()
else:
gui_hooks.editor_did_fire_typing_timer(self.note)
- self.checkValid()
+ self._check_and_update_duplicate_display_async()
# focused into field?
elif cmd.startswith("focus"):
@@ -492,7 +505,7 @@ class Editor:
# Setting/unsetting the current note
######################################################################
- def setNote(
+ def set_note(
self, note: Optional[Note], hide: bool = True, focusTo: Optional[int] = None
) -> None:
"Make NOTE the current note."
@@ -519,11 +532,15 @@ class Editor:
self.widget.show()
self.updateTags()
+ dupe_status = self.note.duplicate_or_empty()
+
def oncallback(arg: Any) -> None:
if not self.note:
return
self.setupForegroundButton()
- self.checkValid()
+ # we currently do this synchronously to ensure we load before the
+ # sidebar on browser startup
+ self._update_duplicate_display(dupe_status)
if focusTo is not None:
self.web.setFocus()
gui_hooks.editor_did_load_note(self)
@@ -544,7 +561,14 @@ class Editor:
def _save_current_note(self) -> None:
"Call after note is updated with data from webview."
- self.mw.col.update_note(self.note)
+ self._is_updating_note = True
+ update_note(mw=self.mw, note=self.note, after_hooks=self._after_updating_note)
+
+ def _after_updating_note(self) -> None:
+ self._is_updating_note = False
+
+ def is_updating_note(self) -> bool:
+ return self._is_updating_note
def fonts(self) -> List[Tuple[str, int, bool]]:
return [
@@ -552,19 +576,34 @@ class Editor:
for f in self.note.model()["flds"]
]
- def saveNow(self, callback: Callable, keepFocus: bool = False) -> None:
+ def call_after_note_saved(
+ self, callback: Callable, keepFocus: bool = False
+ ) -> None:
"Save unsaved edits then call callback()."
if not self.note:
# calling code may not expect the callback to fire immediately
self.mw.progress.timer(10, callback, False)
return
- self.saveTags()
+ self.blur_tags_if_focused()
self.web.evalWithCallback("saveNow(%d)" % keepFocus, lambda res: callback())
- def checkValid(self) -> None:
+ saveNow = call_after_note_saved
+
+ def _check_and_update_duplicate_display_async(self) -> None:
+ note = self.note
+
+ def on_done(result: DuplicateOrEmptyResult.V) -> None:
+ if self.note != note:
+ return
+ self._update_duplicate_display(result)
+
+ self.mw.query_op(self.note.duplicate_or_empty, success=on_done)
+
+ checkValid = _check_and_update_duplicate_display_async
+
+ def _update_duplicate_display(self, result: DuplicateOrEmptyResult.V) -> None:
cols = [""] * len(self.note.fields)
- err = self.note.duplicate_or_empty()
- if err == 2:
+ if result == DuplicateOrEmptyResult.DUPLICATE:
cols[0] = "dupe"
self.web.eval(f"setBackgrounds({json.dumps(cols)});")
@@ -597,16 +636,20 @@ class Editor:
return True
def cleanup(self) -> None:
- self.setNote(None)
+ self.set_note(None)
# prevent any remaining evalWithCallback() events from firing after C++ object deleted
self.web = None
+ # legacy
+
+ setNote = set_note
+
# HTML editing
######################################################################
def onHtmlEdit(self) -> None:
field = self.currentField
- self.saveNow(lambda: self._onHtmlEdit(field))
+ self.call_after_note_saved(lambda: self._onHtmlEdit(field))
def _onHtmlEdit(self, field: int) -> None:
d = QDialog(self.widget, Qt.Window)
@@ -656,7 +699,7 @@ class Editor:
l = QLabel(tr(TR.EDITING_TAGS))
tb.addWidget(l, 1, 0)
self.tags = aqt.tagedit.TagEdit(self.widget)
- qconnect(self.tags.lostFocus, self.saveTags)
+ qconnect(self.tags.lostFocus, self.on_tag_focus_lost)
self.tags.setToolTip(
shortcut(tr(TR.EDITING_JUMP_TO_TAGS_WITH_CTRLANDSHIFTANDT))
)
@@ -672,13 +715,17 @@ class Editor:
if not self.tags.text() or not self.addMode:
self.tags.setText(self.note.stringTags().strip())
- def saveTags(self) -> None:
- if not self.note:
- return
+ def on_tag_focus_lost(self) -> None:
self.note.tags = self.mw.col.tags.split(self.tags.text())
+ gui_hooks.editor_did_update_tags(self.note)
if not self.addMode:
self._save_current_note()
- gui_hooks.editor_did_update_tags(self.note)
+
+ def blur_tags_if_focused(self) -> None:
+ if not self.note:
+ return
+ if self.tags.hasFocus():
+ self.widget.setFocus()
def hideCompleters(self) -> None:
self.tags.hideCompleter()
@@ -687,9 +734,12 @@ class Editor:
self.tags.setFocus()
# legacy
+
def saveAddModeVars(self) -> None:
pass
+ saveTags = blur_tags_if_focused
+
# Format buttons
######################################################################
@@ -712,7 +762,7 @@ class Editor:
self.web.eval("setFormat('removeFormat');")
def onCloze(self) -> None:
- self.saveNow(self._onCloze, keepFocus=True)
+ self.call_after_note_saved(self._onCloze, keepFocus=True)
def _onCloze(self) -> None:
# check that the model is set up for cloze deletion
@@ -729,7 +779,7 @@ class Editor:
if m:
highest = max(highest, sorted([int(x) for x in m])[-1])
# reuse last?
- if not self.mw.app.keyboardModifiers() & Qt.AltModifier:
+ if not KeyboardModifiersPressed().alt:
highest += 1
# must start at 1
highest = max(1, highest)
@@ -1106,7 +1156,7 @@ class EditorWebView(AnkiWebView):
strip_html = self.editor.mw.col.get_config_bool(
Config.Bool.PASTE_STRIPS_FORMATTING
)
- if self.editor.mw.app.queryKeyboardModifiers() & Qt.ShiftModifier:
+ if KeyboardModifiersPressed().shift:
strip_html = not strip_html
return strip_html
diff --git a/qt/aqt/errors.py b/qt/aqt/errors.py
index 51e5ca14b..8ed9e6273 100644
--- a/qt/aqt/errors.py
+++ b/qt/aqt/errors.py
@@ -4,7 +4,7 @@ import html
import re
import sys
import traceback
-from typing import Optional
+from typing import Optional, TextIO, cast
from markdown import markdown
@@ -37,7 +37,7 @@ class ErrorHandler(QObject):
qconnect(self.errorTimer, self._setTimer)
self.pool = ""
self._oldstderr = sys.stderr
- sys.stderr = self
+ sys.stderr = cast(TextIO, self)
def unload(self) -> None:
sys.stderr = self._oldstderr
diff --git a/qt/aqt/fields.py b/qt/aqt/fields.py
index e19668842..5fb326351 100644
--- a/qt/aqt/fields.py
+++ b/qt/aqt/fields.py
@@ -26,7 +26,7 @@ from aqt.utils import (
class FieldDialog(QDialog):
def __init__(
- self, mw: AnkiQt, nt: NoteType, parent: Optional[QDialog] = None
+ self, mw: AnkiQt, nt: NoteType, parent: Optional[QWidget] = None
) -> None:
QDialog.__init__(self, parent or mw)
self.mw = mw
diff --git a/qt/aqt/find_and_replace.py b/qt/aqt/find_and_replace.py
new file mode 100644
index 000000000..5a57d134f
--- /dev/null
+++ b/qt/aqt/find_and_replace.py
@@ -0,0 +1,182 @@
+# Copyright: Ankitects Pty Ltd and contributors
+# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
+
+from __future__ import annotations
+
+from typing import List, Optional, Sequence
+
+import aqt
+from anki.lang import TR
+from aqt import AnkiQt, QWidget
+from aqt.qt import QDialog, Qt
+from aqt.utils import (
+ HelpPage,
+ disable_help_button,
+ openHelp,
+ qconnect,
+ restore_combo_history,
+ restore_combo_index_for_session,
+ restore_is_checked,
+ restoreGeom,
+ save_combo_history,
+ save_combo_index_for_session,
+ save_is_checked,
+ saveGeom,
+ show_invalid_search_error,
+ tooltip,
+ tr,
+)
+
+
+def find_and_replace(
+ *,
+ mw: AnkiQt,
+ parent: QWidget,
+ note_ids: Sequence[int],
+ search: str,
+ replacement: str,
+ regex: bool,
+ field_name: Optional[str],
+ match_case: bool,
+) -> None:
+ mw.perform_op(
+ lambda: mw.col.find_and_replace(
+ note_ids=note_ids,
+ search=search,
+ replacement=replacement,
+ regex=regex,
+ field_name=field_name,
+ match_case=match_case,
+ ),
+ success=lambda out: tooltip(
+ tr(TR.FINDREPLACE_NOTES_UPDATED, changed=out.count, total=len(note_ids)),
+ parent=parent,
+ ),
+ failure=lambda exc: show_invalid_search_error(exc, parent=parent),
+ )
+
+
+def find_and_replace_tag(
+ *,
+ mw: AnkiQt,
+ parent: QWidget,
+ note_ids: Sequence[int],
+ search: str,
+ replacement: str,
+ regex: bool,
+ match_case: bool,
+) -> None:
+ mw.perform_op(
+ lambda: mw.col.tags.find_and_replace(
+ note_ids=note_ids,
+ search=search,
+ replacement=replacement,
+ regex=regex,
+ match_case=match_case,
+ ),
+ success=lambda out: tooltip(
+ tr(TR.FINDREPLACE_NOTES_UPDATED, changed=out.count, total=len(note_ids)),
+ parent=parent,
+ ),
+ failure=lambda exc: show_invalid_search_error(exc, parent=parent),
+ )
+
+
+class FindAndReplaceDialog(QDialog):
+ COMBO_NAME = "BrowserFindAndReplace"
+
+ def __init__(self, parent: QWidget, *, mw: AnkiQt, note_ids: Sequence[int]) -> None:
+ super().__init__(parent)
+ self.mw = mw
+ self.note_ids = note_ids
+ self.field_names: List[str] = []
+
+ # fetch field names and then show
+ mw.query_op(
+ lambda: mw.col.field_names_for_note_ids(note_ids),
+ success=self._show,
+ )
+
+ def _show(self, field_names: Sequence[str]) -> None:
+ # add "all fields" and "tags" to the top of the list
+ self.field_names = [
+ tr(TR.BROWSING_ALL_FIELDS),
+ tr(TR.EDITING_TAGS),
+ ] + list(field_names)
+
+ disable_help_button(self)
+ self.form = aqt.forms.findreplace.Ui_Dialog()
+ self.form.setupUi(self)
+ self.setWindowModality(Qt.WindowModal)
+
+ self._find_history = restore_combo_history(
+ self.form.find, self.COMBO_NAME + "Find"
+ )
+ self.form.find.completer().setCaseSensitivity(True)
+ self._replace_history = restore_combo_history(
+ self.form.replace, self.COMBO_NAME + "Replace"
+ )
+ self.form.replace.completer().setCaseSensitivity(True)
+
+ restore_is_checked(self.form.re, self.COMBO_NAME + "Regex")
+ restore_is_checked(self.form.ignoreCase, self.COMBO_NAME + "ignoreCase")
+
+ self.form.field.addItems(self.field_names)
+ restore_combo_index_for_session(
+ self.form.field, self.field_names, self.COMBO_NAME + "Field"
+ )
+
+ qconnect(self.form.buttonBox.helpRequested, self.show_help)
+
+ restoreGeom(self, "findreplace")
+ self.show()
+ self.form.find.setFocus()
+
+ def accept(self) -> None:
+ saveGeom(self, "findreplace")
+ save_combo_index_for_session(self.form.field, self.COMBO_NAME + "Field")
+
+ search = save_combo_history(
+ self.form.find, self._find_history, self.COMBO_NAME + "Find"
+ )
+ replace = save_combo_history(
+ self.form.replace, self._replace_history, self.COMBO_NAME + "Replace"
+ )
+ regex = self.form.re.isChecked()
+ match_case = not self.form.ignoreCase.isChecked()
+ save_is_checked(self.form.re, self.COMBO_NAME + "Regex")
+ save_is_checked(self.form.ignoreCase, self.COMBO_NAME + "ignoreCase")
+
+ if self.form.field.currentIndex() == 1:
+ # tags
+ find_and_replace_tag(
+ mw=self.mw,
+ parent=self.parentWidget(),
+ note_ids=self.note_ids,
+ search=search,
+ replacement=replace,
+ regex=regex,
+ match_case=match_case,
+ )
+ return
+
+ if self.form.field.currentIndex() == 0:
+ field = None
+ else:
+ field = self.field_names[self.form.field.currentIndex() - 2]
+
+ find_and_replace(
+ mw=self.mw,
+ parent=self.parentWidget(),
+ note_ids=self.note_ids,
+ search=search,
+ replacement=replace,
+ regex=regex,
+ field_name=field,
+ match_case=match_case,
+ )
+
+ super().accept()
+
+ def show_help(self) -> None:
+ openHelp(HelpPage.BROWSING_FIND_AND_REPLACE)
diff --git a/qt/aqt/main.py b/qt/aqt/main.py
index eef4e99d0..f8d09ff1d 100644
--- a/qt/aqt/main.py
+++ b/qt/aqt/main.py
@@ -14,7 +14,20 @@ import zipfile
from argparse import Namespace
from concurrent.futures import Future
from threading import Thread
-from typing import Any, Callable, Dict, List, Optional, Sequence, TextIO, Tuple, cast
+from typing import (
+ Any,
+ Callable,
+ Dict,
+ List,
+ Literal,
+ Optional,
+ Protocol,
+ Sequence,
+ TextIO,
+ Tuple,
+ TypeVar,
+ cast,
+)
import anki
import aqt
@@ -32,8 +45,11 @@ from anki.collection import (
Checkpoint,
Collection,
Config,
+ OpChanges,
+ OpChangesWithCount,
ReviewUndo,
UndoResult,
+ UndoStatus,
)
from anki.decks import Deck
from anki.hooks import runHook
@@ -56,8 +72,10 @@ from aqt.theme import theme_manager
from aqt.utils import (
TR,
HelpPage,
+ KeyboardModifiersPressed,
askUser,
checkInvalidFilename,
+ current_top_level_widget,
disable_help_button,
getFile,
getOnlyText,
@@ -71,31 +89,29 @@ from aqt.utils import (
showInfo,
showWarning,
tooltip,
+ top_level_widget,
tr,
)
+
+class HasChangesProperty(Protocol):
+ changes: OpChanges
+
+
+# either an OpChanges object, or an object with .changes on it. This bound
+# doesn't actually work for protobuf objects, so new protobuf objects will
+# either need to be added here, or cast at call time
+ResultWithChanges = TypeVar(
+ "ResultWithChanges", bound=Union[OpChanges, OpChangesWithCount, HasChangesProperty]
+)
+
+PerformOpOptionalSuccessCallback = Optional[Callable[[ResultWithChanges], Any]]
+
install_pylib_legacy()
-
-class ResetReason(enum.Enum):
- Unknown = "unknown"
- AddCardsAddNote = "addCardsAddNote"
- EditCurrentInit = "editCurrentInit"
- EditorBridgeCmd = "editorBridgeCmd"
- BrowserSetDeck = "browserSetDeck"
- BrowserAddTags = "browserAddTags"
- BrowserRemoveTags = "browserRemoveTags"
- BrowserSuspend = "browserSuspend"
- BrowserReposition = "browserReposition"
- BrowserReschedule = "browserReschedule"
- BrowserFindReplace = "browserFindReplace"
- BrowserTagDupes = "browserTagDupes"
- BrowserDeleteDeck = "browserDeleteDeck"
-
-
-class ResetRequired:
- def __init__(self, mw: AnkiQt) -> None:
- self.mw = mw
+MainWindowState = Literal[
+ "startup", "deckBrowser", "overview", "review", "resetRequired", "profileManager"
+]
class AnkiQt(QMainWindow):
@@ -106,7 +122,7 @@ class AnkiQt(QMainWindow):
def __init__(
self,
- app: QApplication,
+ app: aqt.AnkiApp,
profileManager: ProfileManagerType,
backend: _RustBackend,
opts: Namespace,
@@ -114,7 +130,7 @@ class AnkiQt(QMainWindow):
) -> None:
QMainWindow.__init__(self)
self.backend = backend
- self.state = "startup"
+ self.state: MainWindowState = "startup"
self.opts = opts
self.col: Optional[Collection] = None
self.taskman = TaskManager(self)
@@ -123,9 +139,7 @@ class AnkiQt(QMainWindow):
self.app = app
self.pm = profileManager
# init rest of app
- self.safeMode = (
- self.app.queryKeyboardModifiers() & Qt.ShiftModifier
- ) or self.opts.safemode
+ self.safeMode = (KeyboardModifiersPressed().shift) or self.opts.safemode
try:
self.setupUI()
self.setupAddons(args)
@@ -173,6 +187,7 @@ class AnkiQt(QMainWindow):
self.setupHooks()
self.setup_timers()
self.updateTitleBar()
+ self.setup_focus()
# screens
self.setupDeckBrowser()
self.setupOverview()
@@ -201,6 +216,12 @@ class AnkiQt(QMainWindow):
"Shortcut to create a weak reference that doesn't break code completion."
return weakref.proxy(self) # type: ignore
+ def setup_focus(self) -> None:
+ qconnect(self.app.focusChanged, self.on_focus_changed)
+
+ def on_focus_changed(self, old: QWidget, new: QWidget) -> None:
+ gui_hooks.focus_did_change(new, old)
+
# Profiles
##########################################################################
@@ -650,12 +671,12 @@ class AnkiQt(QMainWindow):
self.pm.save()
self.progress.finish()
- # State machine
+ # Tracking main window state (deck browser, reviewer, etc)
##########################################################################
- def moveToState(self, state: str, *args: Any) -> None:
+ def moveToState(self, state: MainWindowState, *args: Any) -> None:
# print("-> move from", self.state, "to", state)
- oldState = self.state or "dummy"
+ oldState = self.state
cleanup = getattr(self, f"_{oldState}Cleanup", None)
if cleanup:
# pylint: disable=not-callable
@@ -694,66 +715,214 @@ class AnkiQt(QMainWindow):
# Resetting state
##########################################################################
- def reset(self, guiOnly: bool = False) -> None:
- "Called for non-trivial edits. Rebuilds queue and updates UI."
+ def query_op(
+ self,
+ op: Callable[[], Any],
+ *,
+ success: Callable[[Any], Any] = None,
+ failure: Optional[Callable[[Exception], Any]] = None,
+ ) -> None:
+ """Run an operation that queries the DB on a background thread.
+
+ Similar interface to perform_op(), but intended to be used for operations
+ that do not change collection state. Undo status will not be changed,
+ and `operation_did_execute` will not fire. No progress window will
+ be shown either.
+
+ `operations_will|did_execute` will still fire, so the UI can defer
+ updates during a background task.
+ """
+
+ def wrapped_done(future: Future) -> None:
+ self._decrease_background_ops()
+ # did something go wrong?
+ if exception := future.exception():
+ if isinstance(exception, Exception):
+ if failure:
+ failure(exception)
+ else:
+ showWarning(str(exception))
+ return
+ else:
+ # BaseException like SystemExit; rethrow it
+ future.result()
+
+ result = future.result()
+ if success:
+ success(result)
+
+ self._increase_background_ops()
+ self.taskman.run_in_background(op, wrapped_done)
+
+ # Resetting state
+ ##########################################################################
+
+ def perform_op(
+ self,
+ op: Callable[[], ResultWithChanges],
+ *,
+ success: PerformOpOptionalSuccessCallback = None,
+ failure: Optional[Callable[[Exception], Any]] = None,
+ after_hooks: Optional[Callable[[], None]] = None,
+ ) -> None:
+ """Run the provided operation on a background thread.
+
+ op() should either return OpChanges, or an object with a 'changes'
+ property. The changes will be passed to `operation_did_execute` so that
+ the UI can decide whether it needs to update itself.
+
+ - Shows progress popup for the duration of the op.
+ - Ensures the browser doesn't try to redraw during the operation, which can lead
+ to a frozen UI
+ - Updates undo state at the end of the operation
+ - Commits changes
+ - Fires the `operation_(will|did)_reset` hooks
+ - Fires the legacy `state_did_reset` hook
+
+ Be careful not to call any UI routines in `op`, as that may crash Qt.
+ This includes things select .selectedCards() in the browse screen.
+
+ success() will be called with the return value of op().
+
+ If op() throws an exception, it will be shown in a popup, or
+ passed to failure() if it is provided.
+
+ after_hooks() will be called after hooks are fired, if it is provided.
+ Components can use this to ignore change notices generated by operations
+ they invoke themselves, or perform some subsequent action.
+ """
+
+ self._increase_background_ops()
+
+ def wrapped_done(future: Future) -> None:
+ self._decrease_background_ops()
+ # did something go wrong?
+ if exception := future.exception():
+ if isinstance(exception, Exception):
+ if failure:
+ failure(exception)
+ else:
+ showWarning(str(exception))
+ return
+ else:
+ # BaseException like SystemExit; rethrow it
+ future.result()
+
+ result = future.result()
+ try:
+ if success:
+ success(result)
+ finally:
+ # update undo status
+ status = self.col.undo_status()
+ self._update_undo_actions_for_status_and_save(status)
+ # fire change hooks
+ self._fire_change_hooks_after_op_performed(result, after_hooks)
+
+ self.taskman.with_progress(op, wrapped_done)
+
+ def _increase_background_ops(self) -> None:
+ if not self._background_op_count:
+ gui_hooks.backend_will_block()
+ self._background_op_count += 1
+
+ def _decrease_background_ops(self) -> None:
+ self._background_op_count -= 1
+ if not self._background_op_count:
+ gui_hooks.backend_did_block()
+ assert self._background_op_count >= 0
+
+ def _fire_change_hooks_after_op_performed(
+ self, result: ResultWithChanges, after_hooks: Optional[Callable[[], None]]
+ ) -> None:
+ if isinstance(result, OpChanges):
+ changes = result
+ else:
+ changes = result.changes
+
+ # fire new hook
+ print("op changes:")
+ print(changes)
+ gui_hooks.operation_did_execute(changes)
+ # fire legacy hook so old code notices changes
+ if self.col.op_made_changes(changes):
+ gui_hooks.state_did_reset()
+ if after_hooks:
+ after_hooks()
+
+ def _synthesize_op_did_execute_from_reset(self) -> None:
+ """Fire the `operation_did_execute` hook with everything marked as changed,
+ after legacy code has called .reset()"""
+ op = OpChanges()
+ for field in op.DESCRIPTOR.fields:
+ if field.name != "kind":
+ setattr(op, field.name, True)
+ gui_hooks.operation_did_execute(op)
+
+ def on_operation_did_execute(self, changes: OpChanges) -> None:
+ "Notify current screen of changes."
+ focused = current_top_level_widget() == self
+ if self.state == "review":
+ dirty = self.reviewer.op_executed(changes, focused)
+ elif self.state == "overview":
+ dirty = self.overview.op_executed(changes, focused)
+ elif self.state == "deckBrowser":
+ dirty = self.deckBrowser.op_executed(changes, focused)
+ else:
+ dirty = False
+
+ if not focused and dirty:
+ self.fade_out_webview()
+
+ def on_focus_did_change(
+ self, new_focus: Optional[QWidget], _old: Optional[QWidget]
+ ) -> None:
+ "If main window has received focus, ensure current UI state is updated."
+ if new_focus and top_level_widget(new_focus) == self:
+ if self.state == "review":
+ self.reviewer.refresh_if_needed()
+ elif self.state == "overview":
+ self.overview.refresh_if_needed()
+ elif self.state == "deckBrowser":
+ self.deckBrowser.refresh_if_needed()
+
+ def fade_out_webview(self) -> None:
+ self.web.eval("document.body.style.opacity = 0.3")
+
+ def fade_in_webview(self) -> None:
+ self.web.eval("document.body.style.opacity = 1")
+
+ def reset(self, unused_arg: bool = False) -> None:
+ """Legacy method of telling UI to refresh after changes made to DB.
+
+ New code should use mw.perform_op() instead."""
if self.col:
- if not guiOnly:
- self.col.reset()
+ # fire new `operation_did_execute` hook first. If the overview
+ # or review screen are currently open, they will rebuild the study
+ # queues (via mw.col.reset())
+ self._synthesize_op_did_execute_from_reset()
+ # fire the old reset hook
gui_hooks.state_did_reset()
self.update_undo_actions()
- self.moveToState(self.state)
+
+ # legacy
def requireReset(
self,
modal: bool = False,
- reason: ResetReason = ResetReason.Unknown,
+ reason: Any = None,
context: Any = None,
) -> None:
- "Signal queue needs to be rebuilt when edits are finished or by user."
- self.autosave()
- self.resetModal = modal
- if gui_hooks.main_window_should_require_reset(
- self.interactiveState(), reason, context
- ):
- self.moveToState("resetRequired")
-
- def interactiveState(self) -> bool:
- "True if not in profile manager, syncing, etc."
- return self.state in ("overview", "review", "deckBrowser")
+ self.reset()
def maybeReset(self) -> None:
- self.autosave()
- if self.state == "resetRequired":
- self.state = self.returnState
- self.reset()
+ pass
def delayedMaybeReset(self) -> None:
- # if we redraw the page in a button click event it will often crash on
- # windows
- self.progress.timer(100, self.maybeReset, False)
+ pass
- def _resetRequiredState(self, oldState: str) -> None:
- if oldState != "resetRequired":
- self.returnState = oldState
- if self.resetModal:
- # we don't have to change the webview, as we have a covering window
- return
- web_context = ResetRequired(self)
- self.web.set_bridge_command(lambda url: self.delayedMaybeReset(), web_context)
- i = tr(TR.QT_MISC_WAITING_FOR_EDITING_TO_FINISH)
- b = self.button("refresh", tr(TR.QT_MISC_RESUME_NOW), id="resume")
- self.web.stdHtml(
- f"""
-
-
-""",
- context=web_context,
- )
- self.bottomWeb.hide()
- self.web.setFocus()
+ def _resetRequiredState(self, oldState: MainWindowState) -> None:
+ pass
# HTML helpers
##########################################################################
@@ -812,10 +981,8 @@ title="%s" %s>%s""" % (
# force webengine processes to load before cwd is changed
if isWin:
- for o in self.web, self.bottomWeb:
- o.requiresCol = False
- o._domReady = False
- o._page.setContent(bytes("", "ascii"))
+ for webview in self.web, self.bottomWeb:
+ webview.force_load_hack()
def closeAllWindows(self, onsuccess: Callable) -> None:
aqt.dialogs.closeAll(onsuccess)
@@ -879,6 +1046,7 @@ title="%s" %s>%s""" % (
def setupThreads(self) -> None:
self._mainThread = QThread.currentThread()
+ self._background_op_count = 0
def inMainThread(self) -> bool:
return self._mainThread == QThread.currentThread()
@@ -988,8 +1156,7 @@ title="%s" %s>%s""" % (
("y", self.on_sync_button_clicked),
]
self.applyShortcuts(globalShortcuts)
-
- self.stateShortcuts: Sequence[Tuple[str, Callable]] = []
+ self.stateShortcuts: List[QShortcut] = []
def applyShortcuts(
self, shortcuts: Sequence[Tuple[str, Callable]]
@@ -1087,6 +1254,8 @@ title="%s" %s>%s""" % (
if on_done:
on_done(result)
+ # fixme: perform_op? -> needs to save
+ # fixme: parent
self.taskman.with_progress(self.col.undo, on_done_outer)
def update_undo_actions(self) -> None:
@@ -1108,6 +1277,23 @@ title="%s" %s>%s""" % (
self.form.actionUndo.setEnabled(False)
gui_hooks.undo_state_did_change(False)
+ def _update_undo_actions_for_status_and_save(self, status: UndoStatus) -> None:
+ """Update menu text and enable/disable menu item as appropriate.
+ Plural as this may handle redo in the future too."""
+ undo_action = status.undo
+
+ if undo_action:
+ undo_action = tr(TR.UNDO_UNDO_ACTION, val=undo_action)
+ self.form.actionUndo.setText(undo_action)
+ self.form.actionUndo.setEnabled(True)
+ gui_hooks.undo_state_did_change(True)
+ else:
+ self.form.actionUndo.setText(tr(TR.UNDO_UNDO))
+ self.form.actionUndo.setEnabled(False)
+ gui_hooks.undo_state_did_change(False)
+
+ self.col.autosave()
+
def checkpoint(self, name: str) -> None:
self.col.save(name)
self.update_undo_actions()
@@ -1149,7 +1335,7 @@ title="%s" %s>%s""" % (
deck = self._selectedDeck()
if not deck:
return
- want_old = self.app.queryKeyboardModifiers() & Qt.ShiftModifier
+ want_old = KeyboardModifiersPressed().shift
if want_old:
aqt.dialogs.open("DeckStats", self)
else:
@@ -1300,7 +1486,7 @@ title="%s" %s>%s""" % (
if elap > minutes * 60:
self.maybe_auto_sync_media()
- # Permanent libanki hooks
+ # Permanent hooks
##########################################################################
def setupHooks(self) -> None:
@@ -1310,6 +1496,8 @@ title="%s" %s>%s""" % (
gui_hooks.av_player_will_play.append(self.on_av_player_will_play)
gui_hooks.av_player_did_end_playing.append(self.on_av_player_did_end_playing)
+ gui_hooks.operation_did_execute.append(self.on_operation_did_execute)
+ gui_hooks.focus_did_change.append(self.on_focus_did_change)
self._activeWindowOnPlay: Optional[QWidget] = None
@@ -1404,13 +1592,14 @@ title="%s" %s>%s""" % (
frm = self.debug_diag_form = aqt.forms.debug.Ui_Dialog()
class DebugDialog(QDialog):
+ silentlyClose = True
+
def reject(self) -> None:
super().reject()
saveSplitter(frm.splitter, "DebugConsoleWindow")
saveGeom(self, "DebugConsoleWindow")
d = self.debugDiag = DebugDialog()
- d.silentlyClose = True
disable_help_button(d)
frm.setupUi(d)
restoreGeom(d, "DebugConsoleWindow")
@@ -1574,7 +1763,8 @@ title="%s" %s>%s""" % (
if not self.hideMenuAccels:
return
tgt = tgt or self
- for action in tgt.findChildren(QAction):
+ for action_ in tgt.findChildren(QAction):
+ action = cast(QAction, action_)
txt = str(action.text())
m = re.match(r"^(.+)\(&.+\)(.+)?", txt)
if m:
@@ -1582,7 +1772,7 @@ title="%s" %s>%s""" % (
def hideStatusTips(self) -> None:
for action in self.findChildren(QAction):
- action.setStatusTip("")
+ cast(QAction, action).setStatusTip("")
def onMacMinimize(self) -> None:
self.setWindowState(self.windowState() | Qt.WindowMinimized) # type: ignore
@@ -1645,6 +1835,10 @@ title="%s" %s>%s""" % (
def _isAddon(self, buf: str) -> bool:
return buf.endswith(self.addonManager.ext)
+ def interactiveState(self) -> bool:
+ "True if not in profile manager, syncing, etc."
+ return self.state in ("overview", "review", "deckBrowser")
+
# GC
##########################################################################
# The default Python garbage collection can trigger on any thread. This can
@@ -1700,3 +1894,20 @@ title="%s" %s>%s""" % (
def serverURL(self) -> str:
return "http://127.0.0.1:%d/" % self.mediaServer.getPort()
+
+
+# legacy
+class ResetReason(enum.Enum):
+ Unknown = "unknown"
+ AddCardsAddNote = "addCardsAddNote"
+ EditCurrentInit = "editCurrentInit"
+ EditorBridgeCmd = "editorBridgeCmd"
+ BrowserSetDeck = "browserSetDeck"
+ BrowserAddTags = "browserAddTags"
+ BrowserRemoveTags = "browserRemoveTags"
+ BrowserSuspend = "browserSuspend"
+ BrowserReposition = "browserReposition"
+ BrowserReschedule = "browserReschedule"
+ BrowserFindReplace = "browserFindReplace"
+ BrowserTagDupes = "browserTagDupes"
+ BrowserDeleteDeck = "browserDeleteDeck"
diff --git a/qt/aqt/models.py b/qt/aqt/models.py
index e03a08fe3..a893178cc 100644
--- a/qt/aqt/models.py
+++ b/qt/aqt/models.py
@@ -31,7 +31,7 @@ class Models(QDialog):
def __init__(
self,
mw: AnkiQt,
- parent: Optional[QDialog] = None,
+ parent: Optional[QWidget] = None,
fromMain: bool = False,
selected_notetype_id: Optional[int] = None,
):
diff --git a/qt/aqt/note_ops.py b/qt/aqt/note_ops.py
new file mode 100644
index 000000000..582a380b4
--- /dev/null
+++ b/qt/aqt/note_ops.py
@@ -0,0 +1,36 @@
+# Copyright: Ankitects Pty Ltd and contributors
+# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
+
+from __future__ import annotations
+
+from typing import Callable, Sequence
+
+from anki.notes import Note
+from aqt import AnkiQt
+from aqt.main import PerformOpOptionalSuccessCallback
+
+
+def add_note(
+ *,
+ mw: AnkiQt,
+ note: Note,
+ target_deck_id: int,
+ success: PerformOpOptionalSuccessCallback = None,
+) -> None:
+ mw.perform_op(lambda: mw.col.add_note(note, target_deck_id), success=success)
+
+
+def update_note(*, mw: AnkiQt, note: Note, after_hooks: Callable[[], None]) -> None:
+ mw.perform_op(
+ lambda: mw.col.update_note(note),
+ after_hooks=after_hooks,
+ )
+
+
+def remove_notes(
+ *,
+ mw: AnkiQt,
+ note_ids: Sequence[int],
+ success: PerformOpOptionalSuccessCallback = None,
+) -> None:
+ mw.perform_op(lambda: mw.col.remove_notes(note_ids), success=success)
diff --git a/qt/aqt/overview.py b/qt/aqt/overview.py
index 723528602..9bb908a48 100644
--- a/qt/aqt/overview.py
+++ b/qt/aqt/overview.py
@@ -6,6 +6,7 @@ from dataclasses import dataclass
from typing import Any, Callable, Dict, List, Optional, Tuple
import aqt
+from anki.collection import OpChanges
from aqt import gui_hooks
from aqt.sound import av_player
from aqt.toolbar import BottomBar
@@ -42,6 +43,7 @@ class Overview:
self.mw = mw
self.web = mw.web
self.bottom = BottomBar(mw, mw.bottomWeb)
+ self._refresh_needed = False
def show(self) -> None:
av_player.stop_and_clear_queue()
@@ -55,6 +57,20 @@ class Overview:
self._renderBottom()
self.mw.web.setFocus()
gui_hooks.overview_did_refresh(self)
+ self._refresh_needed = False
+
+ def refresh_if_needed(self) -> None:
+ if self._refresh_needed:
+ self.refresh()
+
+ def op_executed(self, changes: OpChanges, focused: bool) -> bool:
+ if self.mw.col.op_affects_study_queue(changes):
+ self._refresh_needed = True
+
+ if focused:
+ self.refresh_if_needed()
+
+ return self._refresh_needed
# Handlers
############################################################
diff --git a/qt/aqt/previewer.py b/qt/aqt/previewer.py
index 26a55ff77..9cfaed271 100644
--- a/qt/aqt/previewer.py
+++ b/qt/aqt/previewer.py
@@ -1,11 +1,14 @@
# Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-# mypy: check-untyped-defs
+
+from __future__ import annotations
+
import json
import re
import time
from typing import Any, Callable, Optional, Tuple, Union
+import aqt.browser
from anki.cards import Card
from anki.collection import Config
from aqt import AnkiQt, gui_hooks
@@ -300,6 +303,12 @@ class MultiCardPreviewer(Previewer):
class BrowserPreviewer(MultiCardPreviewer):
_last_card_id = 0
+ _parent: Optional[aqt.browser.Browser]
+
+ def __init__(
+ self, parent: aqt.browser.Browser, mw: AnkiQt, on_close: Callable[[], None]
+ ) -> None:
+ super().__init__(parent=parent, mw=mw, on_close=on_close)
def card(self) -> Optional[Card]:
if self._parent.singleCard:
@@ -317,12 +326,12 @@ class BrowserPreviewer(MultiCardPreviewer):
return changed
def _on_prev_card(self) -> None:
- self._parent.editor.saveNow(
+ self._parent.editor.call_after_note_saved(
lambda: self._parent._moveCur(QAbstractItemView.MoveUp)
)
def _on_next_card(self) -> None:
- self._parent.editor.saveNow(
+ self._parent.editor.call_after_note_saved(
lambda: self._parent._moveCur(QAbstractItemView.MoveDown)
)
diff --git a/qt/aqt/progress.py b/qt/aqt/progress.py
index d5002208e..9f52e26b2 100644
--- a/qt/aqt/progress.py
+++ b/qt/aqt/progress.py
@@ -16,10 +16,11 @@ from aqt.utils import TR, disable_help_button, tr
class ProgressManager:
def __init__(self, mw: aqt.AnkiQt) -> None:
self.mw = mw
- self.app = QApplication.instance()
+ self.app = mw.app
self.inDB = False
self.blockUpdates = False
self._show_timer: Optional[QTimer] = None
+ self._busy_cursor_timer: Optional[QTimer] = None
self._win: Optional[ProgressDialog] = None
self._levels = 0
@@ -74,7 +75,7 @@ class ProgressManager:
max: int = 0,
min: int = 0,
label: Optional[str] = None,
- parent: Optional[QDialog] = None,
+ parent: Optional[QWidget] = None,
immediate: bool = False,
) -> Optional[ProgressDialog]:
self._levels += 1
@@ -94,14 +95,15 @@ class ProgressManager:
self._win.setWindowTitle("Anki")
self._win.setWindowModality(Qt.ApplicationModal)
self._win.setMinimumWidth(300)
- self._setBusy()
+ self._busy_cursor_timer = QTimer(self.mw)
+ self._busy_cursor_timer.setSingleShot(True)
+ self._busy_cursor_timer.start(300)
+ qconnect(self._busy_cursor_timer.timeout, self._set_busy_cursor)
self._shown: float = 0
self._counter = min
self._min = min
self._max = max
self._firstTime = time.time()
- self._lastUpdate = time.time()
- self._updating = False
self._show_timer = QTimer(self.mw)
self._show_timer.setSingleShot(True)
self._show_timer.start(immediate and 100 or 600)
@@ -120,13 +122,10 @@ class ProgressManager:
if not self.mw.inMainThread():
print("progress.update() called on wrong thread")
return
- if self._updating:
- return
if maybeShow:
self._maybeShow()
if not self._shown:
return
- elapsed = time.time() - self._lastUpdate
if label:
self._win.form.label.setText(label)
@@ -136,19 +135,16 @@ class ProgressManager:
self._counter = value or (self._counter + 1)
self._win.form.progressBar.setValue(self._counter)
- if process and elapsed >= 0.2:
- self._updating = True
- self.app.processEvents() # type: ignore #possibly related to https://github.com/python/mypy/issues/6910
- self._updating = False
- self._lastUpdate = time.time()
-
def finish(self) -> None:
self._levels -= 1
self._levels = max(0, self._levels)
if self._levels == 0:
if self._win:
self._closeWin()
- self._unsetBusy()
+ if self._busy_cursor_timer:
+ self._busy_cursor_timer.stop()
+ self._busy_cursor_timer = None
+ self._restore_cursor()
if self._show_timer:
self._show_timer.stop()
self._show_timer = None
@@ -183,14 +179,17 @@ class ProgressManager:
if elap >= 0.5:
break
self.app.processEvents(QEventLoop.ExcludeUserInputEvents) # type: ignore #possibly related to https://github.com/python/mypy/issues/6910
- self._win.cancel()
+ # if the parent window has been deleted, the progress dialog may have
+ # already been dropped; delete it if it hasn't been
+ if not sip.isdeleted(self._win):
+ self._win.cancel()
self._win = None
self._shown = 0
- def _setBusy(self) -> None:
+ def _set_busy_cursor(self) -> None:
self.mw.app.setOverrideCursor(QCursor(Qt.WaitCursor))
- def _unsetBusy(self) -> None:
+ def _restore_cursor(self) -> None:
self.app.restoreOverrideCursor()
def busy(self) -> int:
diff --git a/qt/aqt/reviewer.py b/qt/aqt/reviewer.py
index 420293ea3..169b4d216 100644
--- a/qt/aqt/reviewer.py
+++ b/qt/aqt/reviewer.py
@@ -7,19 +7,30 @@ import html
import json
import re
import unicodedata as ucd
+from enum import Enum, auto
from typing import Any, Callable, List, Match, Optional, Sequence, Tuple, Union
from PyQt5.QtCore import Qt
from anki import hooks
from anki.cards import Card
-from anki.collection import Config
+from anki.collection import Config, OpChanges
+from anki.tags import MARKED_TAG
from anki.utils import stripHTML
from aqt import AnkiQt, gui_hooks
+from aqt.card_ops import set_card_flag
+from aqt.note_ops import remove_notes
from aqt.profiles import VideoDriver
from aqt.qt import *
-from aqt.scheduling import set_due_date_dialog
+from aqt.scheduling_ops import (
+ bury_cards,
+ bury_note,
+ set_due_date_dialog,
+ suspend_cards,
+ suspend_note,
+)
from aqt.sound import av_player, play_clicked_audio, record_audio
+from aqt.tag_ops import add_tags, remove_tags_for_notes
from aqt.theme import theme_manager
from aqt.toolbar import BottomBar
from aqt.utils import (
@@ -33,6 +44,12 @@ from aqt.utils import (
from aqt.webview import AnkiWebView
+class RefreshNeeded(Enum):
+ NO = auto()
+ NOTE_TEXT = auto()
+ QUEUES = auto()
+
+
class ReviewerBottomBar:
def __init__(self, reviewer: Reviewer) -> None:
self.reviewer = reviewer
@@ -61,16 +78,17 @@ class Reviewer:
self._recordedAudio: Optional[str] = None
self.typeCorrect: str = None # web init happens before this is set
self.state: Optional[str] = None
+ self._refresh_needed = RefreshNeeded.NO
self.bottom = BottomBar(mw, mw.bottomWeb)
hooks.card_did_leech.append(self.onLeech)
def show(self) -> None:
- self.mw.col.reset()
self.mw.setStateShortcuts(self._shortcutKeys()) # type: ignore
self.web.set_bridge_command(self._linkHandler, self)
self.bottom.web.set_bridge_command(self._linkHandler, ReviewerBottomBar(self))
self._reps: int = None
- self.nextCard()
+ self._refresh_needed = RefreshNeeded.QUEUES
+ self.refresh_if_needed()
def lastCard(self) -> Optional[Card]:
if self._answeredIds:
@@ -86,6 +104,42 @@ class Reviewer:
gui_hooks.reviewer_will_end()
self.card = None
+ def refresh_if_needed(self) -> None:
+ if self._refresh_needed is RefreshNeeded.QUEUES:
+ self.mw.col.reset()
+ self.nextCard()
+ self.mw.fade_in_webview()
+ self._refresh_needed = RefreshNeeded.NO
+ elif self._refresh_needed is RefreshNeeded.NOTE_TEXT:
+ self._redraw_current_card()
+ self.mw.fade_in_webview()
+ self._refresh_needed = RefreshNeeded.NO
+
+ def op_executed(self, changes: OpChanges, focused: bool) -> bool:
+ if changes.note and changes.kind == OpChanges.UPDATE_NOTE_TAGS:
+ self.card.load()
+ self._update_mark_icon()
+ elif changes.card and changes.kind == OpChanges.SET_CARD_FLAG:
+ # fixme: v3 mtime check
+ self.card.load()
+ self._update_flag_icon()
+ elif self.mw.col.op_affects_study_queue(changes):
+ self._refresh_needed = RefreshNeeded.QUEUES
+ elif changes.note or changes.notetype or changes.tag:
+ self._refresh_needed = RefreshNeeded.NOTE_TEXT
+
+ if focused and self._refresh_needed is not RefreshNeeded.NO:
+ self.refresh_if_needed()
+
+ return self._refresh_needed is not RefreshNeeded.NO
+
+ def _redraw_current_card(self) -> None:
+ self.card.load()
+ if self.state == "answer":
+ self._showAnswer()
+ else:
+ self._showQuestion()
+
# Fetching a card
##########################################################################
@@ -219,7 +273,7 @@ class Reviewer:
self.web.eval(f"_drawFlag({self.card.user_flag()});")
def _update_mark_icon(self) -> None:
- self.web.eval(f"_drawMark({json.dumps(self.card.note().has_tag('marked'))});")
+ self.web.eval(f"_drawMark({json.dumps(self.card.note().has_tag(MARKED_TAG))});")
_drawMark = _update_mark_icon
_drawFlag = _update_flag_icon
@@ -782,24 +836,23 @@ time = %(time)d;
def onOptions(self) -> None:
self.mw.onDeckConf(self.mw.col.decks.get(self.card.odid or self.card.did))
- def set_flag_on_current_card(self, flag: int) -> None:
+ def set_flag_on_current_card(self, desired_flag: int) -> None:
# need to toggle off?
- if self.card.user_flag() == flag:
+ if self.card.user_flag() == desired_flag:
flag = 0
- self.card.set_user_flag(flag)
- self.mw.col.update_card(self.card)
- self.mw.update_undo_actions()
- self._update_flag_icon()
+ else:
+ flag = desired_flag
+
+ set_card_flag(mw=self.mw, card_ids=[self.card.id], flag=flag)
def toggle_mark_on_current_note(self) -> None:
note = self.card.note()
- if note.has_tag("marked"):
- note.remove_tag("marked")
+ if note.has_tag(MARKED_TAG):
+ remove_tags_for_notes(
+ mw=self.mw, note_ids=[note.id], space_separated_tags=MARKED_TAG
+ )
else:
- note.add_tag("marked")
- self.mw.col.update_note(note)
- self.mw.update_undo_actions()
- self._update_mark_icon()
+ add_tags(mw=self.mw, note_ids=[note.id], space_separated_tags=MARKED_TAG)
def on_set_due(self) -> None:
if self.mw.state != "review" or not self.card:
@@ -810,38 +863,52 @@ time = %(time)d;
parent=self.mw,
card_ids=[self.card.id],
config_key=Config.String.SET_DUE_REVIEWER,
- on_done=self.mw.reset,
)
def suspend_current_note(self) -> None:
- self.mw.col.sched.suspend_cards([c.id for c in self.card.note().cards()])
- self.mw.reset()
- tooltip(tr(TR.STUDYING_NOTE_SUSPENDED))
+ suspend_note(
+ mw=self.mw,
+ note_id=self.card.nid,
+ success=lambda _: tooltip(tr(TR.STUDYING_NOTE_SUSPENDED)),
+ )
def suspend_current_card(self) -> None:
- self.mw.col.sched.suspend_cards([self.card.id])
- self.mw.reset()
- tooltip(tr(TR.STUDYING_CARD_SUSPENDED))
-
- def bury_current_card(self) -> None:
- self.mw.col.sched.bury_cards([self.card.id])
- self.mw.reset()
- tooltip(tr(TR.STUDYING_CARD_BURIED))
+ suspend_cards(
+ mw=self.mw,
+ card_ids=[self.card.id],
+ success=lambda _: tooltip(tr(TR.STUDYING_CARD_SUSPENDED)),
+ )
def bury_current_note(self) -> None:
- self.mw.col.sched.bury_note(self.card.note())
- self.mw.reset()
- tooltip(tr(TR.STUDYING_NOTE_BURIED))
+ bury_note(
+ mw=self.mw,
+ note_id=self.card.nid,
+ success=lambda _: tooltip(tr(TR.STUDYING_NOTE_BURIED)),
+ )
+
+ def bury_current_card(self) -> None:
+ bury_cards(
+ mw=self.mw,
+ card_ids=[self.card.id],
+ success=lambda _: tooltip(tr(TR.STUDYING_CARD_BURIED)),
+ )
def delete_current_note(self) -> None:
# need to check state because the shortcut is global to the main
# window
if self.mw.state != "review" or not self.card:
return
+
+ # fixme: pass this back from the backend method instead
cnt = len(self.card.note().cards())
- self.mw.col.remove_notes([self.card.note().id])
- self.mw.reset()
- tooltip(tr(TR.STUDYING_NOTE_AND_ITS_CARD_DELETED, count=cnt))
+
+ remove_notes(
+ mw=self.mw,
+ note_ids=[self.card.nid],
+ success=lambda _: tooltip(
+ tr(TR.STUDYING_NOTE_AND_ITS_CARD_DELETED, count=cnt)
+ ),
+ )
def onRecordVoice(self) -> None:
def after_record(path: str) -> None:
diff --git a/qt/aqt/scheduling.py b/qt/aqt/scheduling.py
deleted file mode 100644
index f16d49b1a..000000000
--- a/qt/aqt/scheduling.py
+++ /dev/null
@@ -1,82 +0,0 @@
-# Copyright: Ankitects Pty Ltd and contributors
-# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-
-from __future__ import annotations
-
-from concurrent.futures import Future
-from typing import List, Optional
-
-import aqt
-from anki.collection import Config
-from anki.lang import TR
-from aqt.qt import *
-from aqt.utils import getText, showWarning, tooltip, tr
-
-
-def set_due_date_dialog(
- *,
- mw: aqt.AnkiQt,
- parent: QDialog,
- card_ids: List[int],
- config_key: Optional[Config.String.Key.V],
- on_done: Callable[[], None],
-) -> None:
- if not card_ids:
- return
-
- default = mw.col.get_config_string(config_key) if config_key is not None else ""
- prompt = "\n".join(
- [
- tr(TR.SCHEDULING_SET_DUE_DATE_PROMPT, cards=len(card_ids)),
- tr(TR.SCHEDULING_SET_DUE_DATE_PROMPT_HINT),
- ]
- )
- (days, success) = getText(
- prompt=prompt,
- parent=parent,
- default=default,
- title=tr(TR.ACTIONS_SET_DUE_DATE),
- )
- if not success or not days.strip():
- return
-
- def set_due() -> None:
- mw.col.sched.set_due_date(card_ids, days, config_key)
-
- def after_set(fut: Future) -> None:
- try:
- fut.result()
- except Exception as e:
- showWarning(str(e))
- on_done()
- return
-
- tooltip(
- tr(TR.SCHEDULING_SET_DUE_DATE_DONE, cards=len(card_ids)),
- parent=parent,
- )
-
- on_done()
-
- mw.taskman.with_progress(set_due, after_set)
-
-
-def forget_cards(
- *, mw: aqt.AnkiQt, parent: QDialog, card_ids: List[int], on_done: Callable[[], None]
-) -> None:
- if not card_ids:
- return
-
- def on_done_wrapper(fut: Future) -> None:
- try:
- fut.result()
- except Exception as e:
- showWarning(str(e))
- else:
- tooltip(tr(TR.SCHEDULING_FORGOT_CARDS, cards=len(card_ids)), parent=parent)
-
- on_done()
-
- mw.taskman.with_progress(
- lambda: mw.col.sched.schedule_cards_as_new(card_ids), on_done_wrapper
- )
diff --git a/qt/aqt/scheduling_ops.py b/qt/aqt/scheduling_ops.py
new file mode 100644
index 000000000..7468ed00a
--- /dev/null
+++ b/qt/aqt/scheduling_ops.py
@@ -0,0 +1,175 @@
+# Copyright: Ankitects Pty Ltd and contributors
+# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
+
+from __future__ import annotations
+
+from typing import List, Optional, Sequence
+
+import aqt
+from anki.collection import CARD_TYPE_NEW, Config
+from anki.lang import TR
+from aqt import AnkiQt
+from aqt.main import PerformOpOptionalSuccessCallback
+from aqt.qt import *
+from aqt.utils import disable_help_button, getText, tooltip, tr
+
+
+def set_due_date_dialog(
+ *,
+ mw: aqt.AnkiQt,
+ parent: QWidget,
+ card_ids: List[int],
+ config_key: Optional[Config.String.Key.V],
+) -> None:
+ if not card_ids:
+ return
+
+ default_text = (
+ mw.col.get_config_string(config_key) if config_key is not None else ""
+ )
+ prompt = "\n".join(
+ [
+ tr(TR.SCHEDULING_SET_DUE_DATE_PROMPT, cards=len(card_ids)),
+ tr(TR.SCHEDULING_SET_DUE_DATE_PROMPT_HINT),
+ ]
+ )
+ (days, success) = getText(
+ prompt=prompt,
+ parent=parent,
+ default=default_text,
+ title=tr(TR.ACTIONS_SET_DUE_DATE),
+ )
+ if not success or not days.strip():
+ return
+
+ mw.perform_op(
+ lambda: mw.col.sched.set_due_date(card_ids, days, config_key),
+ success=lambda _: tooltip(
+ tr(TR.SCHEDULING_SET_DUE_DATE_DONE, cards=len(card_ids)),
+ parent=parent,
+ ),
+ )
+
+
+def forget_cards(*, mw: aqt.AnkiQt, parent: QWidget, card_ids: List[int]) -> None:
+ if not card_ids:
+ return
+
+ mw.perform_op(
+ lambda: mw.col.sched.schedule_cards_as_new(card_ids),
+ success=lambda _: tooltip(
+ tr(TR.SCHEDULING_FORGOT_CARDS, cards=len(card_ids)), parent=parent
+ ),
+ )
+
+
+def reposition_new_cards_dialog(
+ *, mw: AnkiQt, parent: QWidget, card_ids: Sequence[int]
+) -> None:
+ assert mw.col.db
+ row = mw.col.db.first(
+ f"select min(due), max(due) from cards where type={CARD_TYPE_NEW} and odid=0"
+ )
+ assert row
+ (min_position, max_position) = row
+ min_position = max(min_position or 0, 0)
+ max_position = max_position or 0
+
+ d = QDialog(parent)
+ disable_help_button(d)
+ d.setWindowModality(Qt.WindowModal)
+ frm = aqt.forms.reposition.Ui_Dialog()
+ frm.setupUi(d)
+
+ txt = tr(TR.BROWSING_QUEUE_TOP, val=min_position)
+ txt += "\n" + tr(TR.BROWSING_QUEUE_BOTTOM, val=max_position)
+ frm.label.setText(txt)
+
+ frm.start.selectAll()
+ if not d.exec_():
+ return
+
+ start = frm.start.value()
+ step = frm.step.value()
+ randomize = frm.randomize.isChecked()
+ shift = frm.shift.isChecked()
+
+ reposition_new_cards(
+ mw=mw,
+ parent=parent,
+ card_ids=card_ids,
+ starting_from=start,
+ step_size=step,
+ randomize=randomize,
+ shift_existing=shift,
+ )
+
+
+def reposition_new_cards(
+ *,
+ mw: AnkiQt,
+ parent: QWidget,
+ card_ids: Sequence[int],
+ starting_from: int,
+ step_size: int,
+ randomize: bool,
+ shift_existing: bool,
+) -> None:
+ mw.perform_op(
+ lambda: mw.col.sched.reposition_new_cards(
+ card_ids=card_ids,
+ starting_from=starting_from,
+ step_size=step_size,
+ randomize=randomize,
+ shift_existing=shift_existing,
+ ),
+ success=lambda out: tooltip(
+ tr(TR.BROWSING_CHANGED_NEW_POSITION, count=out.count), parent=parent
+ ),
+ )
+
+
+def suspend_cards(
+ *,
+ mw: AnkiQt,
+ card_ids: Sequence[int],
+ success: PerformOpOptionalSuccessCallback = None,
+) -> None:
+ mw.perform_op(lambda: mw.col.sched.suspend_cards(card_ids), success=success)
+
+
+def suspend_note(
+ *,
+ mw: AnkiQt,
+ note_id: int,
+ success: PerformOpOptionalSuccessCallback = None,
+) -> None:
+ mw.taskman.run_in_background(
+ lambda: mw.col.card_ids_of_note(note_id),
+ lambda future: suspend_cards(mw=mw, card_ids=future.result(), success=success),
+ )
+
+
+def unsuspend_cards(*, mw: AnkiQt, card_ids: Sequence[int]) -> None:
+ mw.perform_op(lambda: mw.col.sched.unsuspend_cards(card_ids))
+
+
+def bury_cards(
+ *,
+ mw: AnkiQt,
+ card_ids: Sequence[int],
+ success: PerformOpOptionalSuccessCallback = None,
+) -> None:
+ mw.perform_op(lambda: mw.col.sched.bury_cards(card_ids), success=success)
+
+
+def bury_note(
+ *,
+ mw: AnkiQt,
+ note_id: int,
+ success: PerformOpOptionalSuccessCallback = None,
+) -> None:
+ mw.taskman.run_in_background(
+ lambda: mw.col.card_ids_of_note(note_id),
+ lambda future: bury_cards(mw=mw, card_ids=future.result(), success=success),
+ )
diff --git a/qt/aqt/sidebar.py b/qt/aqt/sidebar.py
index 03d903152..757c090af 100644
--- a/qt/aqt/sidebar.py
+++ b/qt/aqt/sidebar.py
@@ -8,7 +8,7 @@ from enum import Enum, auto
from typing import Dict, Iterable, List, Optional, Tuple, cast
import aqt
-from anki.collection import Config, SearchJoiner, SearchNode
+from anki.collection import Config, OpChanges, SearchJoiner, SearchNode
from anki.decks import DeckTreeNode
from anki.errors import DeckIsFilteredError, InvalidInput
from anki.notes import Note
@@ -16,18 +16,18 @@ from anki.tags import TagTreeNode
from anki.types import assert_exhaustive
from aqt import colors, gui_hooks
from aqt.clayout import CardLayout
-from aqt.main import ResetReason
+from aqt.deck_ops import remove_decks
from aqt.models import Models
from aqt.qt import *
+from aqt.tag_ops import remove_tags_for_all_notes, rename_tag, reparent_tags
from aqt.theme import ColoredIcon, theme_manager
from aqt.utils import (
TR,
+ KeyboardModifiersPressed,
askUser,
getOnlyText,
show_invalid_search_error,
- showInfo,
showWarning,
- tooltip,
tr,
)
@@ -253,9 +253,7 @@ class SidebarModel(QAbstractItemModel):
return QVariant(item.tooltip)
return QVariant(theme_manager.icon_from_resources(item.icon))
- def setData(
- self, index: QModelIndex, text: QVariant, _role: int = Qt.EditRole
- ) -> bool:
+ def setData(self, index: QModelIndex, text: str, _role: int = Qt.EditRole) -> bool:
return self.sidebar._on_rename(index.internalPointer(), text)
def supportedDropActions(self) -> Qt.DropActions:
@@ -353,6 +351,10 @@ def _want_right_border() -> bool:
return not isMac or theme_manager.night_mode
+# fixme: we should have a top-level Sidebar class inheriting from QWidget that
+# handles the treeview, search bar and so on. Currently the treeview embeds the
+# search bar which is wrong, and the layout code is handled in browser.py instead
+# of here
class SidebarTreeView(QTreeView):
def __init__(self, browser: aqt.browser.Browser) -> None:
super().__init__()
@@ -361,6 +363,7 @@ class SidebarTreeView(QTreeView):
self.col = self.mw.col
self.current_search: Optional[str] = None
self.valid_drop_types: Tuple[SidebarItemType, ...] = ()
+ self._refresh_needed = False
self.setContextMenuPolicy(Qt.CustomContextMenu)
self.customContextMenuRequested.connect(self.onContextMenu) # type: ignore
@@ -388,6 +391,10 @@ class SidebarTreeView(QTreeView):
self.setStyleSheet("QTreeView { %s }" % ";".join(styles))
+ # these do not really belong here, they should be in a higher-level class
+ self.toolbar = SidebarToolbar(self)
+ self.searchBar = SidebarSearchBar(self)
+
@property
def tool(self) -> SidebarTool:
return self._tool
@@ -408,7 +415,21 @@ class SidebarTreeView(QTreeView):
self.setExpandsOnDoubleClick(double_click_expands)
def model(self) -> SidebarModel:
- return super().model()
+ return cast(SidebarModel, super().model())
+
+ # Refreshing
+ ###########################
+
+ def op_executed(self, op: OpChanges, focused: bool) -> None:
+ if op.tag or op.notetype or op.deck:
+ self._refresh_needed = True
+ if focused:
+ self.refresh_if_needed()
+
+ def refresh_if_needed(self) -> None:
+ if self._refresh_needed:
+ self.refresh()
+ self._refresh_needed = False
def refresh(
self, is_current: Optional[Callable[[SidebarItem], bool]] = None
@@ -417,16 +438,17 @@ class SidebarTreeView(QTreeView):
if not self.isVisible():
return
- def on_done(fut: Future) -> None:
- self.setUpdatesEnabled(True)
- root = fut.result()
+ def on_done(root: SidebarItem) -> None:
+ # user may have closed browser
+ if sip.isdeleted(self):
+ return
+
+ # block repainting during refreshing to avoid flickering
+ self.setUpdatesEnabled(False)
+
model = SidebarModel(self, root)
-
- # from PyQt5.QtTest import QAbstractItemModelTester
- # tester = QAbstractItemModelTester(model)
-
self.setModel(model)
- qconnect(self.selectionModel().selectionChanged, self._on_selection_changed)
+
if self.current_search:
self.search_for(self.current_search)
else:
@@ -434,9 +456,12 @@ class SidebarTreeView(QTreeView):
if is_current:
self.restore_current(is_current)
- # block repainting during refreshing to avoid flickering
- self.setUpdatesEnabled(False)
- self.mw.taskman.run_in_background(self._root_tree, on_done)
+ self.setUpdatesEnabled(True)
+
+ # needs to be set after changing model
+ qconnect(self.selectionModel().selectionChanged, self._on_selection_changed)
+
+ self.mw.query_op(self._root_tree, success=on_done)
def restore_current(self, is_current: Callable[[SidebarItem], bool]) -> None:
if current := self.find_item(is_current):
@@ -496,22 +521,22 @@ class SidebarTreeView(QTreeView):
joiner: SearchJoiner = "AND",
) -> None:
"""Modify the current search string based on modifier keys, then refresh."""
- mods = self.mw.app.keyboardModifiers()
+ mods = KeyboardModifiersPressed()
previous = SearchNode(parsable_text=self.browser.current_search())
current = self.mw.col.group_searches(*terms, joiner=joiner)
# if Alt pressed, invert
- if mods & Qt.AltModifier:
+ if mods.alt:
current = SearchNode(negated=current)
try:
- if mods & Qt.ControlModifier and mods & Qt.ShiftModifier:
+ if mods.control and mods.shift:
# If Ctrl+Shift, replace searches nodes of the same type.
search = self.col.replace_in_search_node(previous, current)
- elif mods & Qt.ControlModifier:
+ elif mods.control:
# If Ctrl, AND with previous
search = self.col.join_searches(previous, current, "AND")
- elif mods & Qt.ShiftModifier:
+ elif mods.shift:
# If Shift, OR with previous
search = self.col.join_searches(previous, current, "OR")
else:
@@ -602,39 +627,27 @@ class SidebarTreeView(QTreeView):
lambda: self.col.decks.drag_drop_decks(source_ids, target.id), on_done
)
- self.browser.editor.saveNow(on_save)
+ self.browser.editor.call_after_note_saved(on_save)
return True
def _handle_drag_drop_tags(
self, sources: List[SidebarItem], target: SidebarItem
) -> bool:
- source_ids = [
+ tags = [
source.full_name
for source in sources
if source.item_type == SidebarItemType.TAG
]
- if not source_ids:
+ if not tags:
return False
- def on_done(fut: Future) -> None:
- self.mw.requireReset(reason=ResetReason.BrowserAddTags, context=self)
- self.browser.model.endReset()
- fut.result()
- self.refresh()
-
if target.item_type == SidebarItemType.TAG_ROOT:
- target_name = ""
+ new_parent = ""
else:
- target_name = target.full_name
+ new_parent = target.full_name
- def on_save() -> None:
- self.mw.checkpoint(tr(TR.ACTIONS_RENAME_TAG))
- self.browser.model.beginReset()
- self.mw.taskman.with_progress(
- lambda: self.col.tags.drag_drop(source_ids, target_name), on_done
- )
+ reparent_tags(mw=self.mw, parent=self.browser, tags=tags, new_parent=new_parent)
- self.browser.editor.saveNow(on_save)
return True
def _on_search(self, index: QModelIndex) -> None:
@@ -693,7 +706,9 @@ class SidebarTreeView(QTreeView):
for stage in SidebarStage:
if stage == SidebarStage.ROOT:
root = SidebarItem("", "", item_type=SidebarItemType.ROOT)
- handled = gui_hooks.browser_will_build_tree(False, root, stage, self)
+ handled = gui_hooks.browser_will_build_tree(
+ False, root, stage, self.browser
+ )
if not handled:
self._build_stage(root, stage)
@@ -1166,78 +1181,40 @@ class SidebarTreeView(QTreeView):
self.mw.update_undo_actions()
def delete_decks(self, _item: SidebarItem) -> None:
- self.browser.editor.saveNow(self._delete_decks)
-
- def _delete_decks(self) -> None:
- def do_delete() -> int:
- return self.mw.col.decks.remove(dids)
-
- def on_done(fut: Future) -> None:
- self.mw.requireReset(reason=ResetReason.BrowserDeleteDeck, context=self)
- self.browser.search()
- self.browser.model.endReset()
- tooltip(tr(TR.BROWSING_CARDS_DELETED, count=fut.result()), parent=self)
- self.refresh()
-
- dids = self._selected_decks()
- self.browser.model.beginReset()
- self.mw.taskman.with_progress(do_delete, on_done)
+ remove_decks(mw=self.mw, parent=self.browser, deck_ids=self._selected_decks())
# Tags
###########################
def remove_tags(self, item: SidebarItem) -> None:
- self.browser.editor.saveNow(lambda: self._remove_tags(item))
+ tags = self.mw.col.tags.join(self._selected_tags())
+ item.name = "..."
- def _remove_tags(self, _item: SidebarItem) -> None:
- tags = self._selected_tags()
-
- def do_remove() -> int:
- return self.col._backend.expunge_tags(" ".join(tags))
-
- def on_done(fut: Future) -> None:
- self.mw.requireReset(reason=ResetReason.BrowserRemoveTags, context=self)
- self.browser.model.endReset()
- tooltip(tr(TR.BROWSING_NOTES_UPDATED, count=fut.result()), parent=self)
- self.refresh()
-
- self.mw.checkpoint(tr(TR.ACTIONS_REMOVE_TAG))
- self.browser.model.beginReset()
- self.mw.taskman.with_progress(do_remove, on_done)
+ remove_tags_for_all_notes(
+ mw=self.mw, parent=self.browser, space_separated_tags=tags
+ )
def rename_tag(self, item: SidebarItem, new_name: str) -> None:
- new_name = new_name.replace(" ", "")
- if new_name and new_name != item.name:
- # block repainting until collection is updated
- self.setUpdatesEnabled(False)
- self.browser.editor.saveNow(lambda: self._rename_tag(item, new_name))
+ if not new_name or new_name == item.name:
+ return
+
+ new_name_base = new_name
- def _rename_tag(self, item: SidebarItem, new_name: str) -> None:
old_name = item.full_name
new_name = item.name_prefix + new_name
- def do_rename() -> int:
- self.mw.col.tags.remove(old_name)
- return self.col.tags.rename(old_name, new_name)
+ item.name = new_name_base
- def on_done(fut: Future) -> None:
- self.setUpdatesEnabled(True)
- self.mw.requireReset(reason=ResetReason.BrowserAddTags, context=self)
- self.browser.model.endReset()
-
- count = fut.result()
- if not count:
- showInfo(tr(TR.BROWSING_TAG_RENAME_WARNING_EMPTY))
- else:
- tooltip(tr(TR.BROWSING_NOTES_UPDATED, count=count), parent=self)
- self.refresh(
- lambda item: item.item_type == SidebarItemType.TAG
- and item.full_name == new_name
- )
-
- self.mw.checkpoint(tr(TR.ACTIONS_RENAME_TAG))
- self.browser.model.beginReset()
- self.mw.taskman.with_progress(do_rename, on_done)
+ rename_tag(
+ mw=self.mw,
+ parent=self.browser,
+ current_name=old_name,
+ new_name=new_name,
+ after_rename=lambda: self.refresh(
+ lambda item: item.item_type == SidebarItemType.TAG
+ and item.full_name == new_name
+ ),
+ )
# Saved searches
####################################
diff --git a/qt/aqt/sound.py b/qt/aqt/sound.py
index c4532754a..0e6a24a31 100644
--- a/qt/aqt/sound.py
+++ b/qt/aqt/sound.py
@@ -15,7 +15,7 @@ import wave
from abc import ABC, abstractmethod
from concurrent.futures import Future
from operator import itemgetter
-from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Tuple
+from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Tuple, cast
import aqt
from anki import hooks
@@ -568,7 +568,7 @@ class QtAudioInputRecorder(Recorder):
super().start(on_done)
def _on_read_ready(self) -> None:
- self._buffer += self._iodevice.readAll()
+ self._buffer += cast(bytes, self._iodevice.readAll())
def stop(self, on_done: Callable[[str], None]) -> None:
def on_stop_timer() -> None:
diff --git a/qt/aqt/studydeck.py b/qt/aqt/studydeck.py
index cedac958c..6f5df7161 100644
--- a/qt/aqt/studydeck.py
+++ b/qt/aqt/studydeck.py
@@ -33,9 +33,9 @@ class StudyDeck(QDialog):
help: HelpPageArgument = HelpPage.KEYBOARD_SHORTCUTS,
current: Optional[str] = None,
cancel: bool = True,
- parent: Optional[QDialog] = None,
+ parent: Optional[QWidget] = None,
dyn: bool = False,
- buttons: Optional[List[str]] = None,
+ buttons: Optional[List[Union[str, QPushButton]]] = None,
geomKey: str = "default",
) -> None:
QDialog.__init__(self, parent or mw)
@@ -53,8 +53,10 @@ class StudyDeck(QDialog):
self.form.buttonBox.button(QDialogButtonBox.Cancel)
)
if buttons is not None:
- for b in buttons:
- self.form.buttonBox.addButton(b, QDialogButtonBox.ActionRole)
+ for button_or_label in buttons:
+ self.form.buttonBox.addButton(
+ button_or_label, QDialogButtonBox.ActionRole
+ )
else:
b = QPushButton(tr(TR.ACTIONS_ADD))
b.setShortcut(QKeySequence("Ctrl+N"))
@@ -89,7 +91,7 @@ class StudyDeck(QDialog):
self.exec_()
def eventFilter(self, obj: QObject, evt: QEvent) -> bool:
- if evt.type() == QEvent.KeyPress:
+ if isinstance(evt, QKeyEvent) and evt.type() == QEvent.KeyPress:
new_row = current_row = self.form.list.currentRow()
rows_count = self.form.list.count()
key = evt.key()
@@ -98,7 +100,10 @@ class StudyDeck(QDialog):
new_row = current_row - 1
elif key == Qt.Key_Down:
new_row = current_row + 1
- elif evt.modifiers() & Qt.ControlModifier and Qt.Key_1 <= key <= Qt.Key_9:
+ elif (
+ int(evt.modifiers()) & Qt.ControlModifier
+ and Qt.Key_1 <= key <= Qt.Key_9
+ ):
row_index = key - Qt.Key_1
if row_index < rows_count:
new_row = row_index
diff --git a/qt/aqt/tag_ops.py b/qt/aqt/tag_ops.py
new file mode 100644
index 000000000..f5f68abf4
--- /dev/null
+++ b/qt/aqt/tag_ops.py
@@ -0,0 +1,88 @@
+# Copyright: Ankitects Pty Ltd and contributors
+# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
+
+from __future__ import annotations
+
+from typing import Callable, Sequence
+
+from anki.collection import OpChangesWithCount
+from anki.lang import TR
+from aqt import AnkiQt, QWidget
+from aqt.main import PerformOpOptionalSuccessCallback
+from aqt.utils import showInfo, tooltip, tr
+
+
+def add_tags(
+ *,
+ mw: AnkiQt,
+ note_ids: Sequence[int],
+ space_separated_tags: str,
+ success: PerformOpOptionalSuccessCallback = None,
+) -> None:
+ mw.perform_op(
+ lambda: mw.col.tags.bulk_add(note_ids, space_separated_tags), success=success
+ )
+
+
+def remove_tags_for_notes(
+ *,
+ mw: AnkiQt,
+ note_ids: Sequence[int],
+ space_separated_tags: str,
+ success: PerformOpOptionalSuccessCallback = None,
+) -> None:
+ mw.perform_op(
+ lambda: mw.col.tags.bulk_remove(note_ids, space_separated_tags), success=success
+ )
+
+
+def clear_unused_tags(*, mw: AnkiQt, parent: QWidget) -> None:
+ mw.perform_op(
+ mw.col.tags.clear_unused_tags,
+ success=lambda out: tooltip(
+ tr(TR.BROWSING_REMOVED_UNUSED_TAGS_COUNT, count=out.count), parent=parent
+ ),
+ )
+
+
+def rename_tag(
+ *,
+ mw: AnkiQt,
+ parent: QWidget,
+ current_name: str,
+ new_name: str,
+ after_rename: Callable[[], None],
+) -> None:
+ def success(out: OpChangesWithCount) -> None:
+ if out.count:
+ tooltip(tr(TR.BROWSING_NOTES_UPDATED, count=out.count), parent=parent)
+ else:
+ showInfo(tr(TR.BROWSING_TAG_RENAME_WARNING_EMPTY), parent=parent)
+
+ mw.perform_op(
+ lambda: mw.col.tags.rename(old=current_name, new=new_name),
+ success=success,
+ after_hooks=after_rename,
+ )
+
+
+def remove_tags_for_all_notes(
+ *, mw: AnkiQt, parent: QWidget, space_separated_tags: str
+) -> None:
+ mw.perform_op(
+ lambda: mw.col.tags.remove(space_separated_tags=space_separated_tags),
+ success=lambda out: tooltip(
+ tr(TR.BROWSING_NOTES_UPDATED, count=out.count), parent=parent
+ ),
+ )
+
+
+def reparent_tags(
+ *, mw: AnkiQt, parent: QWidget, tags: Sequence[str], new_parent: str
+) -> None:
+ mw.perform_op(
+ lambda: mw.col.tags.reparent(tags=tags, new_parent=new_parent),
+ success=lambda out: tooltip(
+ tr(TR.BROWSING_NOTES_UPDATED, count=out.count), parent=parent
+ ),
+ )
diff --git a/qt/aqt/tagedit.py b/qt/aqt/tagedit.py
index 10ac52027..df01f3acf 100644
--- a/qt/aqt/tagedit.py
+++ b/qt/aqt/tagedit.py
@@ -12,24 +12,24 @@ from aqt.qt import *
class TagEdit(QLineEdit):
- completer: Union[QCompleter, TagCompleter]
+ _completer: Union[QCompleter, TagCompleter]
lostFocus = pyqtSignal()
# 0 = tags, 1 = decks
- def __init__(self, parent: QDialog, type: int = 0) -> None:
+ def __init__(self, parent: QWidget, type: int = 0) -> None:
QLineEdit.__init__(self, parent)
self.col: Optional[Collection] = None
self.model = QStringListModel()
self.type = type
if type == 0:
- self.completer = TagCompleter(self.model, parent, self)
+ self._completer = TagCompleter(self.model, parent, self)
else:
- self.completer = QCompleter(self.model, parent)
- self.completer.setCompletionMode(QCompleter.PopupCompletion)
- self.completer.setCaseSensitivity(Qt.CaseInsensitive)
- self.completer.setFilterMode(Qt.MatchContains)
- self.setCompleter(self.completer)
+ self._completer = QCompleter(self.model, parent)
+ self._completer.setCompletionMode(QCompleter.PopupCompletion)
+ self._completer.setCaseSensitivity(Qt.CaseInsensitive)
+ self._completer.setFilterMode(Qt.MatchContains)
+ self.setCompleter(self._completer)
def setCol(self, col: Collection) -> None:
"Set the current col, updating list of available tags."
@@ -47,29 +47,29 @@ class TagEdit(QLineEdit):
def keyPressEvent(self, evt: QKeyEvent) -> None:
if evt.key() in (Qt.Key_Up, Qt.Key_Down):
# show completer on arrow key up/down
- if not self.completer.popup().isVisible():
+ if not self._completer.popup().isVisible():
self.showCompleter()
return
- if evt.key() == Qt.Key_Tab and evt.modifiers() & Qt.ControlModifier:
+ if evt.key() == Qt.Key_Tab and int(evt.modifiers()) & Qt.ControlModifier:
# select next completion
- if not self.completer.popup().isVisible():
+ if not self._completer.popup().isVisible():
self.showCompleter()
- index = self.completer.currentIndex()
- self.completer.popup().setCurrentIndex(index)
+ index = self._completer.currentIndex()
+ self._completer.popup().setCurrentIndex(index)
cur_row = index.row()
- if not self.completer.setCurrentRow(cur_row + 1):
- self.completer.setCurrentRow(0)
+ if not self._completer.setCurrentRow(cur_row + 1):
+ self._completer.setCurrentRow(0)
return
if (
evt.key() in (Qt.Key_Enter, Qt.Key_Return)
- and self.completer.popup().isVisible()
+ and self._completer.popup().isVisible()
):
# apply first completion if no suggestion selected
- selected_row = self.completer.popup().currentIndex().row()
+ selected_row = self._completer.popup().currentIndex().row()
if selected_row == -1:
- self.completer.setCurrentRow(0)
- index = self.completer.currentIndex()
- self.completer.popup().setCurrentIndex(index)
+ self._completer.setCurrentRow(0)
+ index = self._completer.currentIndex()
+ self._completer.popup().setCurrentIndex(index)
self.hideCompleter()
QWidget.keyPressEvent(self, evt)
return
@@ -90,18 +90,18 @@ class TagEdit(QLineEdit):
gui_hooks.tag_editor_did_process_key(self, evt)
def showCompleter(self) -> None:
- self.completer.setCompletionPrefix(self.text())
- self.completer.complete()
+ self._completer.setCompletionPrefix(self.text())
+ self._completer.complete()
def focusOutEvent(self, evt: QFocusEvent) -> None:
QLineEdit.focusOutEvent(self, evt)
self.lostFocus.emit() # type: ignore
- self.completer.popup().hide()
+ self._completer.popup().hide()
def hideCompleter(self) -> None:
- if sip.isdeleted(self.completer):
+ if sip.isdeleted(self._completer):
return
- self.completer.popup().hide()
+ self._completer.popup().hide()
class TagCompleter(QCompleter):
diff --git a/qt/aqt/taglimit.py b/qt/aqt/taglimit.py
index 15c2e9bab..64459a7f1 100644
--- a/qt/aqt/taglimit.py
+++ b/qt/aqt/taglimit.py
@@ -15,8 +15,8 @@ class TagLimit(QDialog):
self.tags: str = ""
self.tags_list: List[str] = []
self.mw = mw
- self.parent: Optional[QWidget] = parent
- self.deck = self.parent.deck
+ self.parent_: Optional[CustomStudy] = parent
+ self.deck = self.parent_.deck
self.dialog = aqt.forms.taglimit.Ui_Dialog()
self.dialog.setupUi(self)
disable_help_button(self)
diff --git a/qt/aqt/taskman.py b/qt/aqt/taskman.py
index 0a059e910..3189024f3 100644
--- a/qt/aqt/taskman.py
+++ b/qt/aqt/taskman.py
@@ -3,6 +3,8 @@
"""
Helper for running tasks on background threads.
+
+See mw.query_op() and mw.perform_op() for slightly higher-level routines.
"""
from __future__ import annotations
@@ -49,6 +51,14 @@ class TaskManager(QObject):
the completed future.
Args if provided will be passed on as keyword arguments to the task callable."""
+ # Before we launch a background task, ensure any pending on_done closure are run on
+ # main. Qt's signal/slot system will have posted a notification, but it may
+ # not have been processed yet. The on_done() closures may make small queries
+ # to the database that we want to run first - if we delay them until after the
+ # background task starts, and it takes out a long-running lock on the database,
+ # the UI thread will hang until the end of the op.
+ self._on_closures_pending()
+
if args is None:
args = {}
diff --git a/qt/aqt/theme.py b/qt/aqt/theme.py
index 7362f6b7c..11b30d011 100644
--- a/qt/aqt/theme.py
+++ b/qt/aqt/theme.py
@@ -86,12 +86,12 @@ class ThemeManager:
else:
# specified colours
icon = QIcon(path.path)
- img = icon.pixmap(16)
- painter = QPainter(img)
+ pixmap = icon.pixmap(16)
+ painter = QPainter(pixmap)
painter.setCompositionMode(QPainter.CompositionMode_SourceIn)
- painter.fillRect(img.rect(), QColor(path.current_color(self.night_mode)))
+ painter.fillRect(pixmap.rect(), QColor(path.current_color(self.night_mode)))
painter.end()
- icon = QIcon(img)
+ icon = QIcon(pixmap)
return icon
return cache.setdefault(path, icon)
diff --git a/qt/aqt/utils.py b/qt/aqt/utils.py
index 8d94aa5c7..ceb4db073 100644
--- a/qt/aqt/utils.py
+++ b/qt/aqt/utils.py
@@ -7,6 +7,7 @@ import re
import subprocess
import sys
from enum import Enum
+from functools import wraps
from typing import (
TYPE_CHECKING,
Any,
@@ -110,7 +111,7 @@ def openHelp(section: HelpPageArgument) -> None:
openLink(link)
-def openLink(link: str) -> None:
+def openLink(link: Union[str, QUrl]) -> None:
tooltip(tr(TR.QT_MISC_LOADING), period=1000)
with noBundledLibs():
QDesktopServices.openUrl(QUrl(link))
@@ -118,7 +119,7 @@ def openLink(link: str) -> None:
def showWarning(
text: str,
- parent: Optional[QDialog] = None,
+ parent: Optional[QWidget] = None,
help: HelpPageArgument = "",
title: str = "Anki",
textFormat: Optional[TextFormat] = None,
@@ -138,17 +139,17 @@ def showCritical(
return showInfo(text, parent, help, "critical", title=title, textFormat=textFormat)
-def show_invalid_search_error(err: Exception) -> None:
+def show_invalid_search_error(err: Exception, parent: Optional[QWidget] = None) -> None:
"Render search errors in markdown, then display a warning."
text = str(err)
if isinstance(err, InvalidInput):
text = markdown(text)
- showWarning(text)
+ showWarning(text, parent=parent)
def showInfo(
text: str,
- parent: Union[Literal[False], QDialog] = False,
+ parent: Optional[QWidget] = None,
help: HelpPageArgument = "",
type: str = "info",
title: str = "Anki",
@@ -157,7 +158,7 @@ def showInfo(
) -> int:
"Show a small info window with an OK button."
parent_widget: QWidget
- if parent is False:
+ if parent is None:
parent_widget = aqt.mw.app.activeWindow() or aqt.mw
else:
parent_widget = parent
@@ -213,6 +214,7 @@ def showText(
disable_help_button(diag)
layout = QVBoxLayout(diag)
diag.setLayout(layout)
+ text: Union[QPlainTextEdit, QTextBrowser]
if plain_text_edit:
# used by the importer
text = QPlainTextEdit()
@@ -221,10 +223,10 @@ def showText(
else:
text = QTextBrowser()
text.setOpenExternalLinks(True)
- if type == "text":
- text.setPlainText(txt)
- else:
- text.setHtml(txt)
+ if type == "text":
+ text.setPlainText(txt)
+ else:
+ text.setHtml(txt)
layout.addWidget(text)
box = QDialogButtonBox(QDialogButtonBox.Close)
layout.addWidget(box)
@@ -262,7 +264,7 @@ def showText(
def askUser(
text: str,
- parent: QDialog = None,
+ parent: QWidget = None,
help: HelpPageArgument = None,
defaultno: bool = False,
msgfunc: Optional[Callable] = None,
@@ -295,7 +297,7 @@ class ButtonedDialog(QMessageBox):
self,
text: str,
buttons: List[str],
- parent: Optional[QDialog] = None,
+ parent: Optional[QWidget] = None,
help: HelpPageArgument = None,
title: str = "Anki",
):
@@ -328,7 +330,7 @@ class ButtonedDialog(QMessageBox):
def askUserDialog(
text: str,
buttons: List[str],
- parent: Optional[QDialog] = None,
+ parent: Optional[QWidget] = None,
help: HelpPageArgument = None,
title: str = "Anki",
) -> ButtonedDialog:
@@ -341,7 +343,7 @@ def askUserDialog(
class GetTextDialog(QDialog):
def __init__(
self,
- parent: Optional[QDialog],
+ parent: Optional[QWidget],
question: str,
help: HelpPageArgument = None,
edit: Optional[QLineEdit] = None,
@@ -388,7 +390,7 @@ class GetTextDialog(QDialog):
def getText(
prompt: str,
- parent: Optional[QDialog] = None,
+ parent: Optional[QWidget] = None,
help: HelpPageArgument = None,
edit: Optional[QLineEdit] = None,
default: str = "",
@@ -445,7 +447,7 @@ def chooseList(
def getTag(
- parent: QDialog, deck: Collection, question: str, **kwargs: Any
+ parent: QWidget, deck: Collection, question: str, **kwargs: Any
) -> Tuple[str, int]:
from aqt.tagedit import TagEdit
@@ -458,7 +460,8 @@ def getTag(
def disable_help_button(widget: QWidget) -> None:
"Disable the help button in the window titlebar."
- flags = cast(Qt.WindowType, widget.windowFlags() & ~Qt.WindowContextHelpButtonHint)
+ flags_int = int(widget.windowFlags()) & ~Qt.WindowContextHelpButtonHint
+ flags = Qt.WindowFlags(flags_int) # type: ignore
widget.setWindowFlags(flags)
@@ -467,7 +470,7 @@ def disable_help_button(widget: QWidget) -> None:
def getFile(
- parent: QDialog,
+ parent: QWidget,
title: str,
# single file returned unless multi=True
cb: Optional[Callable[[Union[str, Sequence[str]]], None]],
@@ -547,9 +550,9 @@ def getSaveFile(
return file
-def saveGeom(widget: QDialog, key: str) -> None:
+def saveGeom(widget: QWidget, key: str) -> None:
key += "Geom"
- if isMac and widget.windowState() & Qt.WindowFullScreen:
+ if isMac and int(widget.windowState()) & Qt.WindowFullScreen:
geom = None
else:
geom = widget.saveGeometry()
@@ -599,12 +602,12 @@ def ensureWidgetInScreenBoundaries(widget: QWidget) -> None:
widget.move(x, y)
-def saveState(widget: QFileDialog, key: str) -> None:
+def saveState(widget: Union[QFileDialog, QMainWindow], key: str) -> None:
key += "State"
aqt.mw.pm.profile[key] = widget.saveState()
-def restoreState(widget: Union[aqt.AnkiQt, QFileDialog], key: str) -> None:
+def restoreState(widget: Union[QFileDialog, QMainWindow], key: str) -> None:
key += "State"
if aqt.mw.pm.profile.get(key):
widget.restoreState(aqt.mw.pm.profile[key])
@@ -632,12 +635,12 @@ def restoreHeader(widget: QHeaderView, key: str) -> None:
widget.restoreState(aqt.mw.pm.profile[key])
-def save_is_checked(widget: QWidget, key: str) -> None:
+def save_is_checked(widget: QCheckBox, key: str) -> None:
key += "IsChecked"
aqt.mw.pm.profile[key] = widget.isChecked()
-def restore_is_checked(widget: QWidget, key: str) -> None:
+def restore_is_checked(widget: QCheckBox, key: str) -> None:
key += "IsChecked"
if aqt.mw.pm.profile.get(key) is not None:
widget.setChecked(aqt.mw.pm.profile[key])
@@ -718,8 +721,9 @@ def maybeHideClose(bbox: QDialogButtonBox) -> None:
def addCloseShortcut(widg: QDialog) -> None:
if not isMac:
return
- widg._closeShortcut = QShortcut(QKeySequence("Ctrl+W"), widg)
- qconnect(widg._closeShortcut.activated, widg.reject)
+ shortcut = QShortcut(QKeySequence("Ctrl+W"), widg)
+ qconnect(shortcut.activated, widg.reject)
+ setattr(widg, "_closeShortcut", shortcut)
def downArrow() -> str:
@@ -729,6 +733,20 @@ def downArrow() -> str:
return "â–¾"
+def top_level_widget(widget: QWidget) -> QWidget:
+ window = None
+ while widget := widget.parentWidget():
+ window = widget
+ return window
+
+
+def current_top_level_widget() -> Optional[QWidget]:
+ if widget := QApplication.focusWidget():
+ return top_level_widget(widget)
+ else:
+ return None
+
+
# Tooltips
######################################################################
@@ -739,7 +757,7 @@ _tooltipLabel: Optional[QLabel] = None
def tooltip(
msg: str,
period: int = 3000,
- parent: Optional[aqt.AnkiQt] = None,
+ parent: Optional[QWidget] = None,
x_offset: int = 0,
y_offset: int = 100,
) -> None:
@@ -974,3 +992,50 @@ def startup_info() -> Any:
si = subprocess.STARTUPINFO() # pytype: disable=module-attr
si.dwFlags |= subprocess.STARTF_USESHOWWINDOW # pytype: disable=module-attr
return si
+
+
+def ensure_editor_saved(func: Callable) -> Callable:
+ """Ensure the current editor's note is saved before running the wrapped function.
+
+ Must be used on functions that may be invoked from a shortcut key while the
+ editor has focus. For functions that can't be activated while the editor has
+ focus, you don't need this.
+
+ Will look for the editor as self.editor.
+ """
+
+ @wraps(func)
+ def decorated(self: Any, *args: Any, **kwargs: Any) -> None:
+ self.editor.call_after_note_saved(lambda: func(self, *args, **kwargs))
+
+ return decorated
+
+
+def ensure_editor_saved_on_trigger(func: Callable) -> Callable:
+ """Like ensure_editor_saved(), but tells Qt this function takes no args.
+
+ This ensures PyQt doesn't attempt to pass a `toggled` arg
+ into functions connected to a `triggered` signal.
+ """
+ return pyqtSlot()(ensure_editor_saved(func)) # type: ignore
+
+
+class KeyboardModifiersPressed:
+ "Util for type-safe checks of currently-pressed modifier keys."
+
+ def __init__(self) -> None:
+ from aqt import mw
+
+ self._modifiers = int(mw.app.keyboardModifiers())
+
+ @property
+ def shift(self) -> bool:
+ return bool(self._modifiers & Qt.ShiftModifier)
+
+ @property
+ def control(self) -> bool:
+ return bool(self._modifiers & Qt.ControlModifier)
+
+ @property
+ def alt(self) -> bool:
+ return bool(self._modifiers & Qt.AltModifier)
diff --git a/qt/aqt/webview.py b/qt/aqt/webview.py
index f5543eaeb..642033976 100644
--- a/qt/aqt/webview.py
+++ b/qt/aqt/webview.py
@@ -1,5 +1,6 @@
# Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
+
import dataclasses
import json
import re
@@ -31,12 +32,15 @@ class AnkiWebPage(QWebEnginePage):
def _setupBridge(self) -> None:
class Bridge(QObject):
+ def __init__(self, bridge_handler: Callable[[str], Any]) -> None:
+ super().__init__()
+ self.onCmd = bridge_handler
+
@pyqtSlot(str, result=str) # type: ignore
def cmd(self, str: str) -> Any:
return json.dumps(self.onCmd(str))
- self._bridge = Bridge()
- self._bridge.onCmd = self._onCmd
+ self._bridge = Bridge(self._onCmd)
self._channel = QWebChannel(self)
self._channel.registerObject("py", self._bridge)
@@ -46,7 +50,7 @@ class AnkiWebPage(QWebEnginePage):
jsfile = QFile(qwebchannel)
if not jsfile.open(QIODevice.ReadOnly):
print(f"Error opening '{qwebchannel}': {jsfile.error()}", file=sys.stderr)
- jstext = bytes(jsfile.readAll()).decode("utf-8")
+ jstext = bytes(cast(bytes, jsfile.readAll())).decode("utf-8")
jsfile.close()
script = QWebEngineScript()
@@ -131,7 +135,7 @@ class AnkiWebPage(QWebEnginePage):
openLink(url)
return False
- def _onCmd(self, str: str) -> None:
+ def _onCmd(self, str: str) -> Any:
return self._onBridgeCmd(str)
def javaScriptAlert(self, url: QUrl, text: str) -> None:
@@ -252,7 +256,7 @@ class AnkiWebView(QWebEngineView):
# disable pinch to zoom gesture
if isinstance(evt, QNativeGestureEvent):
return True
- elif evt.type() == QEvent.MouseButtonRelease:
+ elif isinstance(evt, QMouseEvent) and evt.type() == QEvent.MouseButtonRelease:
if evt.button() == Qt.MidButton and isLin:
self.onMiddleClickPaste()
return True
@@ -273,7 +277,9 @@ class AnkiWebView(QWebEngineView):
w.close()
else:
# in the main window, removes focus from type in area
- self.parent().setFocus()
+ parent = self.parent()
+ assert isinstance(parent, QWidget)
+ parent.setFocus()
break
w = w.parent()
@@ -315,15 +321,16 @@ class AnkiWebView(QWebEngineView):
self.set_open_links_externally(True)
def _setHtml(self, html: str) -> None:
- app = QApplication.instance()
- oldFocus = app.focusWidget()
+ from aqt import mw
+
+ oldFocus = mw.app.focusWidget()
self._domDone = False
self._page.setHtml(html)
# work around webengine stealing focus on setHtml()
if oldFocus:
oldFocus.setFocus()
- def load(self, url: QUrl) -> None:
+ def load_url(self, url: QUrl) -> None:
# allow queuing actions when loading url directly
self._domDone = False
super().load(url)
@@ -641,5 +648,12 @@ document.head.appendChild(style);
else:
extra = ""
self.hide_while_preserving_layout()
- self.load(QUrl(f"{mw.serverURL()}_anki/pages/{name}.html{extra}"))
+ self.load_url(QUrl(f"{mw.serverURL()}_anki/pages/{name}.html{extra}"))
self.inject_dynamic_style_and_show()
+
+ def force_load_hack(self) -> None:
+ """Force process to initialize.
+ Must be done on Windows prior to changing current working directory."""
+ self.requiresCol = False
+ self._domReady = False
+ self._page.setContent(bytes("", "ascii"))
diff --git a/qt/mypy.ini b/qt/mypy.ini
index 63ed611ac..089683636 100644
--- a/qt/mypy.ini
+++ b/qt/mypy.ini
@@ -9,6 +9,19 @@ check_untyped_defs = true
disallow_untyped_defs = True
strict_equality = true
+[mypy-aqt.scheduling_ops]
+no_strict_optional = false
+[mypy-aqt.note_ops]
+no_strict_optional = false
+[mypy-aqt.card_ops]
+no_strict_optional = false
+[mypy-aqt.deck_ops]
+no_strict_optional = false
+[mypy-aqt.find_and_replace]
+no_strict_optional = false
+[mypy-aqt.tag_ops]
+no_strict_optional = false
+
[mypy-aqt.winpaths]
disallow_untyped_defs=false
[mypy-aqt.mpv]
diff --git a/qt/tools/genhooks_gui.py b/qt/tools/genhooks_gui.py
index 4512dbbc9..5e950eb93 100644
--- a/qt/tools/genhooks_gui.py
+++ b/qt/tools/genhooks_gui.py
@@ -24,7 +24,7 @@ from anki.cards import Card
from anki.decks import Deck, DeckConfig
from anki.hooks import runFilter, runHook
from anki.models import NoteType
-from aqt.qt import QDialog, QEvent, QMenu
+from aqt.qt import QDialog, QEvent, QMenu, QWidget
from aqt.tagedit import TagEdit
"""
@@ -365,8 +365,9 @@ hooks = [
args=["context: aqt.browser.SearchContext"],
doc="""Allows you to modify the list of returned card ids from a search.""",
),
- # States
+ # Main window states
###################
+ # these refer to things like deckbrowser, overview and reviewer state,
Hook(
name="state_will_change",
args=["new_state: str", "old_state: str"],
@@ -382,6 +383,8 @@ hooks = [
name="state_shortcuts_will_change",
args=["state: str", "shortcuts: List[Tuple[str, Callable]]"],
),
+ # UI state/refreshing
+ ###################
Hook(
name="state_did_revert",
args=["action: str"],
@@ -391,7 +394,46 @@ hooks = [
Hook(
name="state_did_reset",
legacy_hook="reset",
- doc="Called when the interface needs to be redisplayed after non-trivial changes have been made.",
+ doc="""Legacy 'reset' hook. Called by mw.reset() and mw.perform_op() to redraw the UI.
+
+ New code should use `operation_did_execute` instead.
+ """,
+ ),
+ Hook(
+ name="operation_did_execute",
+ args=[
+ "changes: anki.collection.OpChanges",
+ ],
+ doc="""Called after an operation completes.
+ Changes can be inspected to determine whether the UI needs updating.
+
+ This will also be called when the legacy mw.reset() is used.
+ """,
+ ),
+ Hook(
+ name="focus_did_change",
+ args=[
+ "new: Optional[QWidget]",
+ "old: Optional[QWidget]",
+ ],
+ doc="""Called each time the focus changes. Can be used to defer updates from
+ `operation_did_execute` until a window is brought to the front.""",
+ ),
+ Hook(
+ name="backend_will_block",
+ doc="""Called before one or more operations are executed with mw.perform_op().
+
+ Subscribers can use this to set a flag to avoid DB queries until the operation
+ completes, as doing so will freeze the UI until the long-running operation
+ completes.
+ """,
+ ),
+ Hook(
+ name="backend_did_block",
+ doc="""Called after one or more operations are executed with mw.perform_op().
+ Called regardless of the success of individual operations, and only called when
+ there are no outstanding ops.
+ """,
),
# Webview
###################
diff --git a/rslib/backend.proto b/rslib/backend.proto
index 7ffc67010..443a59972 100644
--- a/rslib/backend.proto
+++ b/rslib/backend.proto
@@ -45,6 +45,11 @@ message StringList {
repeated string vals = 1;
}
+message OpChangesWithCount {
+ uint32 count = 1;
+ OpChanges changes = 2;
+}
+
// IDs used in RPC calls
///////////////////////////////////////////////////////////
@@ -108,19 +113,19 @@ service SchedulingService {
rpc ExtendLimits(ExtendLimitsIn) returns (Empty);
rpc CountsForDeckToday(DeckID) returns (CountsForDeckTodayOut);
rpc CongratsInfo(Empty) returns (CongratsInfoOut);
- rpc RestoreBuriedAndSuspendedCards(CardIDs) returns (Empty);
+ rpc RestoreBuriedAndSuspendedCards(CardIDs) returns (OpChanges);
rpc UnburyCardsInCurrentDeck(UnburyCardsInCurrentDeckIn) returns (Empty);
- rpc BuryOrSuspendCards(BuryOrSuspendCardsIn) returns (Empty);
+ rpc BuryOrSuspendCards(BuryOrSuspendCardsIn) returns (OpChanges);
rpc EmptyFilteredDeck(DeckID) returns (Empty);
rpc RebuildFilteredDeck(DeckID) returns (UInt32);
- rpc ScheduleCardsAsNew(ScheduleCardsAsNewIn) returns (Empty);
- rpc SetDueDate(SetDueDateIn) returns (Empty);
- rpc SortCards(SortCardsIn) returns (Empty);
- rpc SortDeck(SortDeckIn) returns (Empty);
+ rpc ScheduleCardsAsNew(ScheduleCardsAsNewIn) returns (OpChanges);
+ rpc SetDueDate(SetDueDateIn) returns (OpChanges);
+ rpc SortCards(SortCardsIn) returns (OpChangesWithCount);
+ rpc SortDeck(SortDeckIn) returns (OpChangesWithCount);
rpc GetNextCardStates(CardID) returns (NextCardStates);
rpc DescribeNextStates(NextCardStates) returns (StringList);
rpc StateIsLeech(SchedulingState) returns (Bool);
- rpc AnswerCard(AnswerCardIn) returns (Empty);
+ rpc AnswerCard(AnswerCardIn) returns (OpChanges);
rpc UpgradeScheduler(Empty) returns (Empty);
rpc GetQueuedCards(GetQueuedCardsIn) returns (GetQueuedCardsOut);
}
@@ -134,23 +139,21 @@ service DecksService {
rpc GetDeckLegacy(DeckID) returns (Json);
rpc GetDeckNames(GetDeckNamesIn) returns (DeckNames);
rpc NewDeckLegacy(Bool) returns (Json);
- rpc RemoveDecks(DeckIDs) returns (UInt32);
- rpc DragDropDecks(DragDropDecksIn) returns (Empty);
- rpc RenameDeck(RenameDeckIn) returns (Empty);
+ rpc RemoveDecks(DeckIDs) returns (OpChangesWithCount);
+ rpc DragDropDecks(DragDropDecksIn) returns (OpChanges);
+ rpc RenameDeck(RenameDeckIn) returns (OpChanges);
}
service NotesService {
rpc NewNote(NoteTypeID) returns (Note);
- rpc AddNote(AddNoteIn) returns (NoteID);
+ rpc AddNote(AddNoteIn) returns (AddNoteOut);
rpc DefaultsForAdding(DefaultsForAddingIn) returns (DeckAndNotetype);
rpc DefaultDeckForNotetype(NoteTypeID) returns (DeckID);
- rpc UpdateNote(UpdateNoteIn) returns (Empty);
+ rpc UpdateNote(UpdateNoteIn) returns (OpChanges);
rpc GetNote(NoteID) returns (Note);
- rpc RemoveNotes(RemoveNotesIn) returns (Empty);
- rpc AddNoteTags(AddNoteTagsIn) returns (UInt32);
- rpc UpdateNoteTags(UpdateNoteTagsIn) returns (UInt32);
+ rpc RemoveNotes(RemoveNotesIn) returns (OpChanges);
rpc ClozeNumbersInNote(Note) returns (ClozeNumbersInNoteOut);
- rpc AfterNoteUpdates(AfterNoteUpdatesIn) returns (Empty);
+ rpc AfterNoteUpdates(AfterNoteUpdatesIn) returns (OpChanges);
rpc FieldNamesForNotes(FieldNamesForNotesIn) returns (FieldNamesForNotesOut);
rpc NoteIsDuplicateOrEmpty(Note) returns (NoteIsDuplicateOrEmptyOut);
rpc CardsOfNote(NoteID) returns (CardIDs);
@@ -179,7 +182,7 @@ service ConfigService {
rpc GetConfigString(Config.String) returns (String);
rpc SetConfigString(SetConfigStringIn) returns (Empty);
rpc GetPreferences(Empty) returns (Preferences);
- rpc SetPreferences(Preferences) returns (Empty);
+ rpc SetPreferences(Preferences) returns (OpChanges);
}
service NoteTypesService {
@@ -212,13 +215,16 @@ service DeckConfigService {
}
service TagsService {
- rpc ClearUnusedTags(Empty) returns (Empty);
+ rpc ClearUnusedTags(Empty) returns (OpChangesWithCount);
rpc AllTags(Empty) returns (StringList);
- rpc ExpungeTags(String) returns (UInt32);
+ rpc RemoveTags(String) returns (OpChangesWithCount);
rpc SetTagExpanded(SetTagExpandedIn) returns (Empty);
- rpc ClearTag(String) returns (Empty);
rpc TagTree(Empty) returns (TagTreeNode);
- rpc DragDropTags(DragDropTagsIn) returns (Empty);
+ rpc ReparentTags(ReparentTagsIn) returns (OpChangesWithCount);
+ rpc RenameTags(RenameTagsIn) returns (OpChangesWithCount);
+ rpc AddNoteTags(NoteIDsAndTagsIn) returns (OpChangesWithCount);
+ rpc RemoveNoteTags(NoteIDsAndTagsIn) returns (OpChangesWithCount);
+ rpc FindAndReplaceTag(FindAndReplaceTagIn) returns (OpChangesWithCount);
}
service SearchService {
@@ -227,7 +233,7 @@ service SearchService {
rpc SearchNotes(SearchNotesIn) returns (SearchNotesOut);
rpc JoinSearchNodes(JoinSearchNodesIn) returns (String);
rpc ReplaceSearchNode(ReplaceSearchNodeIn) returns (String);
- rpc FindAndReplace(FindAndReplaceIn) returns (UInt32);
+ rpc FindAndReplace(FindAndReplaceIn) returns (OpChangesWithCount);
}
service StatsService {
@@ -264,9 +270,10 @@ service CollectionService {
service CardsService {
rpc GetCard(CardID) returns (Card);
- rpc UpdateCard(UpdateCardIn) returns (Empty);
+ rpc UpdateCard(UpdateCardIn) returns (OpChanges);
rpc RemoveCards(RemoveCardsIn) returns (Empty);
- rpc SetDeck(SetDeckIn) returns (Empty);
+ rpc SetDeck(SetDeckIn) returns (OpChanges);
+ rpc SetFlag(SetFlagIn) returns (OpChanges);
}
// Protobuf stored in .anki2 files
@@ -919,9 +926,14 @@ message TagTreeNode {
bool expanded = 4;
}
-message DragDropTagsIn {
- repeated string source_tags = 1;
- string target_tag = 2;
+message ReparentTagsIn {
+ repeated string tags = 1;
+ string new_parent = 2;
+}
+
+message RenameTagsIn {
+ string current_prefix = 1;
+ string new_prefix = 2;
}
message SetConfigJsonIn {
@@ -970,6 +982,11 @@ message AddNoteIn {
int64 deck_id = 2;
}
+message AddNoteOut {
+ int64 note_id = 1;
+ OpChanges changes = 2;
+}
+
message UpdateNoteIn {
Note note = 1;
bool skip_undo_entry = 2;
@@ -1027,16 +1044,17 @@ message AfterNoteUpdatesIn {
bool generate_cards = 3;
}
-message AddNoteTagsIn {
- repeated int64 nids = 1;
+message NoteIDsAndTagsIn {
+ repeated int64 note_ids = 1;
string tags = 2;
}
-message UpdateNoteTagsIn {
- repeated int64 nids = 1;
- string tags = 2;
+message FindAndReplaceTagIn {
+ repeated int64 note_ids = 1;
+ string search = 2;
string replacement = 3;
bool regex = 4;
+ bool match_case = 5;
}
message CheckDatabaseOut {
@@ -1442,6 +1460,24 @@ message GetQueuedCardsOut {
}
}
+message OpChanges {
+ // this is not an exhaustive list; we can add more cases as we need them
+ enum Kind {
+ OTHER = 0;
+ UPDATE_NOTE_TAGS = 1;
+ SET_CARD_FLAG = 2;
+ UPDATE_NOTE = 3;
+ }
+
+ Kind kind = 1;
+ bool card = 2;
+ bool note = 3;
+ bool deck = 4;
+ bool tag = 5;
+ bool notetype = 6;
+ bool preference = 7;
+}
+
message UndoStatus {
string undo = 1;
string redo = 2;
@@ -1459,4 +1495,9 @@ message DeckAndNotetype {
message RenameDeckIn {
int64 deck_id = 1;
string new_name = 2;
-}
\ No newline at end of file
+}
+
+message SetFlagIn {
+ repeated int64 card_ids = 1;
+ uint32 flag = 2;
+}
diff --git a/rslib/src/backend/card.rs b/rslib/src/backend/card.rs
index 8172f3f56..53e286ea8 100644
--- a/rslib/src/backend/card.rs
+++ b/rslib/src/backend/card.rs
@@ -21,22 +21,17 @@ impl CardsService for Backend {
})
}
- fn update_card(&self, input: pb::UpdateCardIn) -> Result {
+ fn update_card(&self, input: pb::UpdateCardIn) -> Result {
self.with_col(|col| {
- let op = if input.skip_undo_entry {
- None
- } else {
- Some(UndoableOpKind::UpdateCard)
- };
let mut card: Card = input.card.ok_or(AnkiError::NotFound)?.try_into()?;
- col.update_card_with_op(&mut card, op)
+ col.update_card_maybe_undoable(&mut card, !input.skip_undo_entry)
})
.map(Into::into)
}
fn remove_cards(&self, input: pb::RemoveCardsIn) -> Result {
self.with_col(|col| {
- col.transact(None, |col| {
+ col.transact_no_undo(|col| {
col.remove_cards_and_orphaned_notes(
&input
.card_ids
@@ -49,11 +44,18 @@ impl CardsService for Backend {
})
}
- fn set_deck(&self, input: pb::SetDeckIn) -> Result {
+ fn set_deck(&self, input: pb::SetDeckIn) -> Result {
let cids: Vec<_> = input.card_ids.into_iter().map(CardID).collect();
let deck_id = input.deck_id.into();
self.with_col(|col| col.set_deck(&cids, deck_id).map(Into::into))
}
+
+ fn set_flag(&self, input: pb::SetFlagIn) -> Result {
+ self.with_col(|col| {
+ col.set_card_flag(&to_card_ids(input.card_ids), input.flag)
+ .map(Into::into)
+ })
+ }
}
impl TryFrom for Card {
@@ -111,3 +113,7 @@ impl From for pb::Card {
}
}
}
+
+fn to_card_ids(v: Vec) -> Vec {
+ v.into_iter().map(CardID).collect()
+}
diff --git a/rslib/src/backend/collection.rs b/rslib/src/backend/collection.rs
index 6153d1f9d..83df57dda 100644
--- a/rslib/src/backend/collection.rs
+++ b/rslib/src/backend/collection.rs
@@ -85,20 +85,20 @@ impl CollectionService for Backend {
}
fn get_undo_status(&self, _input: pb::Empty) -> Result {
- self.with_col(|col| Ok(col.undo_status()))
+ self.with_col(|col| Ok(col.undo_status().into_protobuf(&col.i18n)))
}
fn undo(&self, _input: pb::Empty) -> Result {
self.with_col(|col| {
col.undo()?;
- Ok(col.undo_status())
+ Ok(col.undo_status().into_protobuf(&col.i18n))
})
}
fn redo(&self, _input: pb::Empty) -> Result {
self.with_col(|col| {
col.redo()?;
- Ok(col.undo_status())
+ Ok(col.undo_status().into_protobuf(&col.i18n))
})
}
}
diff --git a/rslib/src/backend/config.rs b/rslib/src/backend/config.rs
index 58ae16984..9e8d59610 100644
--- a/rslib/src/backend/config.rs
+++ b/rslib/src/backend/config.rs
@@ -61,7 +61,7 @@ impl ConfigService for Backend {
fn set_config_json(&self, input: pb::SetConfigJsonIn) -> Result {
self.with_col(|col| {
- col.transact(None, |col| {
+ col.transact_no_undo(|col| {
// ensure it's a well-formed object
let val: Value = serde_json::from_slice(&input.value_json)?;
col.set_config(input.key.as_str(), &val)
@@ -71,7 +71,7 @@ impl ConfigService for Backend {
}
fn remove_config(&self, input: pb::String) -> Result {
- self.with_col(|col| col.transact(None, |col| col.remove_config(input.val.as_str())))
+ self.with_col(|col| col.transact_no_undo(|col| col.remove_config(input.val.as_str())))
.map(Into::into)
}
@@ -92,8 +92,10 @@ impl ConfigService for Backend {
}
fn set_config_bool(&self, input: pb::SetConfigBoolIn) -> Result {
- self.with_col(|col| col.transact(None, |col| col.set_bool(input.key().into(), input.value)))
- .map(Into::into)
+ self.with_col(|col| {
+ col.transact_no_undo(|col| col.set_bool(input.key().into(), input.value))
+ })
+ .map(Into::into)
}
fn get_config_string(&self, input: pb::config::String) -> Result {
@@ -106,7 +108,7 @@ impl ConfigService for Backend {
fn set_config_string(&self, input: pb::SetConfigStringIn) -> Result {
self.with_col(|col| {
- col.transact(None, |col| col.set_string(input.key().into(), &input.value))
+ col.transact_no_undo(|col| col.set_string(input.key().into(), &input.value))
})
.map(Into::into)
}
@@ -115,7 +117,7 @@ impl ConfigService for Backend {
self.with_col(|col| col.get_preferences())
}
- fn set_preferences(&self, input: pb::Preferences) -> Result {
+ fn set_preferences(&self, input: pb::Preferences) -> Result {
self.with_col(|col| col.set_preferences(input))
.map(Into::into)
}
diff --git a/rslib/src/backend/dbproxy.rs b/rslib/src/backend/dbproxy.rs
index 49207d334..d6d2b6f41 100644
--- a/rslib/src/backend/dbproxy.rs
+++ b/rslib/src/backend/dbproxy.rs
@@ -75,7 +75,7 @@ pub(super) fn db_command_bytes(col: &mut Collection, input: &[u8]) -> Result {
- maybe_clear_undo(col, &sql);
+ update_state_after_modification(col, &sql);
if first_row_only {
db_query_row(&col.storage, &sql, &args)?
} else {
@@ -87,6 +87,10 @@ pub(super) fn db_command_bytes(col: &mut Collection, input: &[u8]) -> Result {
+ if col.state.modified_by_dbproxy {
+ col.storage.set_modified()?;
+ col.state.modified_by_dbproxy = false;
+ }
col.storage.commit_trx()?;
DBResult::None
}
@@ -96,17 +100,17 @@ pub(super) fn db_command_bytes(col: &mut Collection, input: &[u8]) -> Result {
- maybe_clear_undo(col, &sql);
+ update_state_after_modification(col, &sql);
db_execute_many(&col.storage, &sql, &args)?
}
};
Ok(serde_json::to_vec(&resp)?)
}
-fn maybe_clear_undo(col: &mut Collection, sql: &str) {
+fn update_state_after_modification(col: &mut Collection, sql: &str) {
if !is_dql(sql) {
println!("clearing undo+study due to {}", sql);
- col.discard_undo_and_study_queues();
+ col.update_state_after_dbproxy_modification();
}
}
diff --git a/rslib/src/backend/deckconfig.rs b/rslib/src/backend/deckconfig.rs
index 9b20fe8f7..4737959e8 100644
--- a/rslib/src/backend/deckconfig.rs
+++ b/rslib/src/backend/deckconfig.rs
@@ -17,7 +17,7 @@ impl DeckConfigService for Backend {
let conf: DeckConfSchema11 = serde_json::from_slice(&input.config)?;
let mut conf: DeckConf = conf.into();
self.with_col(|col| {
- col.transact(None, |col| {
+ col.transact_no_undo(|col| {
col.add_or_update_deck_config(&mut conf, input.preserve_usn_and_mtime)?;
Ok(pb::DeckConfigId { dcid: conf.id.0 })
})
@@ -54,7 +54,7 @@ impl DeckConfigService for Backend {
}
fn remove_deck_config(&self, input: pb::DeckConfigId) -> Result {
- self.with_col(|col| col.transact(None, |col| col.remove_deck_config(input.into())))
+ self.with_col(|col| col.transact_no_undo(|col| col.remove_deck_config(input.into())))
.map(Into::into)
}
}
diff --git a/rslib/src/backend/decks.rs b/rslib/src/backend/decks.rs
index ab1fae037..9cfb908f0 100644
--- a/rslib/src/backend/decks.rs
+++ b/rslib/src/backend/decks.rs
@@ -15,7 +15,7 @@ impl DecksService for Backend {
let schema11: DeckSchema11 = serde_json::from_slice(&input.deck)?;
let mut deck: Deck = schema11.into();
if input.preserve_usn_and_mtime {
- col.transact(None, |col| {
+ col.transact_no_undo(|col| {
let usn = col.usn()?;
col.add_or_update_single_deck_with_existing_id(&mut deck, usn)
})?;
@@ -109,12 +109,12 @@ impl DecksService for Backend {
.map(Into::into)
}
- fn remove_decks(&self, input: pb::DeckIDs) -> Result {
+ fn remove_decks(&self, input: pb::DeckIDs) -> Result {
self.with_col(|col| col.remove_decks_and_child_decks(&Into::>::into(input)))
.map(Into::into)
}
- fn drag_drop_decks(&self, input: pb::DragDropDecksIn) -> Result {
+ fn drag_drop_decks(&self, input: pb::DragDropDecksIn) -> Result {
let source_dids: Vec<_> = input.source_deck_ids.into_iter().map(Into::into).collect();
let target_did = if input.target_deck_id == 0 {
None
@@ -125,7 +125,7 @@ impl DecksService for Backend {
.map(Into::into)
}
- fn rename_deck(&self, input: pb::RenameDeckIn) -> Result {
+ fn rename_deck(&self, input: pb::RenameDeckIn) -> Result {
self.with_col(|col| col.rename_deck(input.deck_id.into(), &input.new_name))
.map(Into::into)
}
diff --git a/rslib/src/backend/generic.rs b/rslib/src/backend/generic.rs
index 3aae3b721..4595e279f 100644
--- a/rslib/src/backend/generic.rs
+++ b/rslib/src/backend/generic.rs
@@ -80,3 +80,12 @@ impl From> for pb::StringList {
pb::StringList { vals }
}
}
+
+impl From> for pb::OpChangesWithCount {
+ fn from(out: OpOutput) -> Self {
+ pb::OpChangesWithCount {
+ count: out.output as u32,
+ changes: Some(out.changes.into()),
+ }
+ }
+}
diff --git a/rslib/src/backend/media.rs b/rslib/src/backend/media.rs
index f36a92c60..9c8f7ba7f 100644
--- a/rslib/src/backend/media.rs
+++ b/rslib/src/backend/media.rs
@@ -19,7 +19,7 @@ impl MediaService for Backend {
move |progress| handler.update(Progress::MediaCheck(progress as u32), true);
self.with_col(|col| {
let mgr = MediaManager::new(&col.media_folder, &col.media_db)?;
- col.transact(None, |ctx| {
+ col.transact_no_undo(|ctx| {
let mut checker = MediaChecker::new(ctx, &mgr, progress_fn);
let mut output = checker.check()?;
@@ -62,7 +62,7 @@ impl MediaService for Backend {
self.with_col(|col| {
let mgr = MediaManager::new(&col.media_folder, &col.media_db)?;
- col.transact(None, |ctx| {
+ col.transact_no_undo(|ctx| {
let mut checker = MediaChecker::new(ctx, &mgr, progress_fn);
checker.empty_trash()
@@ -78,7 +78,7 @@ impl MediaService for Backend {
self.with_col(|col| {
let mgr = MediaManager::new(&col.media_folder, &col.media_db)?;
- col.transact(None, |ctx| {
+ col.transact_no_undo(|ctx| {
let mut checker = MediaChecker::new(ctx, &mgr, progress_fn);
checker.restore_trash()
diff --git a/rslib/src/backend/mod.rs b/rslib/src/backend/mod.rs
index a13cb88f9..7c1521260 100644
--- a/rslib/src/backend/mod.rs
+++ b/rslib/src/backend/mod.rs
@@ -15,6 +15,7 @@ mod i18n;
mod media;
mod notes;
mod notetypes;
+mod ops;
mod progress;
mod scheduler;
mod search;
diff --git a/rslib/src/backend/notes.rs b/rslib/src/backend/notes.rs
index 5c82ad5a4..f147d14cd 100644
--- a/rslib/src/backend/notes.rs
+++ b/rslib/src/backend/notes.rs
@@ -12,9 +12,6 @@ use crate::{
pub(super) use pb::notes_service::Service as NotesService;
impl NotesService for Backend {
- // notes
- //-------------------------------------------------------------------
-
fn new_note(&self, input: pb::NoteTypeId) -> Result {
self.with_col(|col| {
let nt = col.get_notetype(input.into())?.ok_or(AnkiError::NotFound)?;
@@ -22,11 +19,14 @@ impl NotesService for Backend {
})
}
- fn add_note(&self, input: pb::AddNoteIn) -> Result {
+ fn add_note(&self, input: pb::AddNoteIn) -> Result {
self.with_col(|col| {
let mut note: Note = input.note.ok_or(AnkiError::NotFound)?.into();
- col.add_note(&mut note, DeckID(input.deck_id))
- .map(|_| pb::NoteId { nid: note.id.0 })
+ let changes = col.add_note(&mut note, DeckID(input.deck_id))?;
+ Ok(pb::AddNoteOut {
+ note_id: note.id.0,
+ changes: Some(changes.into()),
+ })
})
}
@@ -46,15 +46,10 @@ impl NotesService for Backend {
})
}
- fn update_note(&self, input: pb::UpdateNoteIn) -> Result {
+ fn update_note(&self, input: pb::UpdateNoteIn) -> Result {
self.with_col(|col| {
- let op = if input.skip_undo_entry {
- None
- } else {
- Some(UndoableOpKind::UpdateNote)
- };
let mut note: Note = input.note.ok_or(AnkiError::NotFound)?.into();
- col.update_note_with_op(&mut note, op)
+ col.update_note_maybe_undoable(&mut note, !input.skip_undo_entry)
})
.map(Into::into)
}
@@ -68,7 +63,7 @@ impl NotesService for Backend {
})
}
- fn remove_notes(&self, input: pb::RemoveNotesIn) -> Result {
+ fn remove_notes(&self, input: pb::RemoveNotesIn) -> Result {
self.with_col(|col| {
if !input.note_ids.is_empty() {
col.remove_notes(
@@ -77,9 +72,8 @@ impl NotesService for Backend {
.into_iter()
.map(Into::into)
.collect::>(),
- )?;
- }
- if !input.card_ids.is_empty() {
+ )
+ } else {
let nids = col.storage.note_ids_of_cards(
&input
.card_ids
@@ -87,29 +81,9 @@ impl NotesService for Backend {
.map(Into::into)
.collect::>(),
)?;
- col.remove_notes(&nids.into_iter().collect::>())?
+ col.remove_notes(&nids.into_iter().collect::>())
}
- Ok(().into())
- })
- }
-
- fn add_note_tags(&self, input: pb::AddNoteTagsIn) -> Result {
- self.with_col(|col| {
- col.add_tags_to_notes(&to_nids(input.nids), &input.tags)
- .map(|n| n as u32)
- })
- .map(Into::into)
- }
-
- fn update_note_tags(&self, input: pb::UpdateNoteTagsIn) -> Result {
- self.with_col(|col| {
- col.replace_tags_for_notes(
- &to_nids(input.nids),
- &input.tags,
- &input.replacement,
- input.regex,
- )
- .map(|n| (n as u32).into())
+ .map(Into::into)
})
}
@@ -123,16 +97,14 @@ impl NotesService for Backend {
})
}
- fn after_note_updates(&self, input: pb::AfterNoteUpdatesIn) -> Result {
+ fn after_note_updates(&self, input: pb::AfterNoteUpdatesIn) -> Result {
self.with_col(|col| {
- col.transact(None, |col| {
- col.after_note_updates(
- &to_nids(input.nids),
- input.generate_cards,
- input.mark_notes_modified,
- )?;
- Ok(pb::Empty {})
- })
+ col.after_note_updates(
+ &to_note_ids(input.nids),
+ input.generate_cards,
+ input.mark_notes_modified,
+ )
+ .map(Into::into)
})
}
@@ -167,6 +139,6 @@ impl NotesService for Backend {
}
}
-fn to_nids(ids: Vec) -> Vec {
+pub(super) fn to_note_ids(ids: Vec) -> Vec {
ids.into_iter().map(NoteID).collect()
}
diff --git a/rslib/src/backend/ops.rs b/rslib/src/backend/ops.rs
new file mode 100644
index 000000000..b25c51de5
--- /dev/null
+++ b/rslib/src/backend/ops.rs
@@ -0,0 +1,46 @@
+// Copyright: Ankitects Pty Ltd and contributors
+// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
+
+use pb::op_changes::Kind;
+
+use crate::{backend_proto as pb, ops::OpChanges, prelude::*, undo::UndoStatus};
+
+impl From for Kind {
+ fn from(o: Op) -> Self {
+ match o {
+ Op::SetFlag => Kind::SetCardFlag,
+ Op::UpdateTag => Kind::UpdateNoteTags,
+ Op::UpdateNote => Kind::UpdateNote,
+ _ => Kind::Other,
+ }
+ }
+}
+
+impl From for pb::OpChanges {
+ fn from(c: OpChanges) -> Self {
+ pb::OpChanges {
+ kind: Kind::from(c.op) as i32,
+ card: c.changes.card,
+ note: c.changes.note,
+ deck: c.changes.deck,
+ tag: c.changes.tag,
+ notetype: c.changes.notetype,
+ preference: c.changes.preference,
+ }
+ }
+}
+
+impl UndoStatus {
+ pub(crate) fn into_protobuf(self, i18n: &I18n) -> pb::UndoStatus {
+ pb::UndoStatus {
+ undo: self.undo.map(|op| op.describe(i18n)).unwrap_or_default(),
+ redo: self.redo.map(|op| op.describe(i18n)).unwrap_or_default(),
+ }
+ }
+}
+
+impl From> for pb::OpChanges {
+ fn from(o: OpOutput<()>) -> Self {
+ o.changes.into()
+ }
+}
diff --git a/rslib/src/backend/scheduler/mod.rs b/rslib/src/backend/scheduler/mod.rs
index be9405edd..2dae46537 100644
--- a/rslib/src/backend/scheduler/mod.rs
+++ b/rslib/src/backend/scheduler/mod.rs
@@ -39,7 +39,7 @@ impl SchedulingService for Backend {
fn update_stats(&self, input: pb::UpdateStatsIn) -> Result {
self.with_col(|col| {
- col.transact(None, |col| {
+ col.transact_no_undo(|col| {
let today = col.current_due_day(0)?;
let usn = col.usn()?;
col.update_deck_stats(today, usn, input).map(Into::into)
@@ -49,7 +49,7 @@ impl SchedulingService for Backend {
fn extend_limits(&self, input: pb::ExtendLimitsIn) -> Result {
self.with_col(|col| {
- col.transact(None, |col| {
+ col.transact_no_undo(|col| {
let today = col.current_due_day(0)?;
let usn = col.usn()?;
col.extend_limits(
@@ -72,7 +72,7 @@ impl SchedulingService for Backend {
self.with_col(|col| col.congrats_info())
}
- fn restore_buried_and_suspended_cards(&self, input: pb::CardIDs) -> Result {
+ fn restore_buried_and_suspended_cards(&self, input: pb::CardIDs) -> Result {
let cids: Vec<_> = input.into();
self.with_col(|col| col.unbury_or_unsuspend_cards(&cids).map(Into::into))
}
@@ -87,7 +87,7 @@ impl SchedulingService for Backend {
})
}
- fn bury_or_suspend_cards(&self, input: pb::BuryOrSuspendCardsIn) -> Result {
+ fn bury_or_suspend_cards(&self, input: pb::BuryOrSuspendCardsIn) -> Result {
self.with_col(|col| {
let mode = input.mode();
let cids: Vec<_> = input.card_ids.into_iter().map(CardID).collect();
@@ -103,7 +103,7 @@ impl SchedulingService for Backend {
self.with_col(|col| col.rebuild_filtered_deck(input.did.into()).map(Into::into))
}
- fn schedule_cards_as_new(&self, input: pb::ScheduleCardsAsNewIn) -> Result {
+ fn schedule_cards_as_new(&self, input: pb::ScheduleCardsAsNewIn) -> Result {
self.with_col(|col| {
let cids: Vec<_> = input.card_ids.into_iter().map(CardID).collect();
let log = input.log;
@@ -111,14 +111,14 @@ impl SchedulingService for Backend {
})
}
- fn set_due_date(&self, input: pb::SetDueDateIn) -> Result {
+ fn set_due_date(&self, input: pb::SetDueDateIn) -> Result {
let config = input.config_key.map(Into::into);
let days = input.days;
let cids: Vec<_> = input.card_ids.into_iter().map(CardID).collect();
self.with_col(|col| col.set_due_date(&cids, &days, config).map(Into::into))
}
- fn sort_cards(&self, input: pb::SortCardsIn) -> Result {
+ fn sort_cards(&self, input: pb::SortCardsIn) -> Result {
let cids: Vec<_> = input.card_ids.into_iter().map(CardID).collect();
let (start, step, random, shift) = (
input.starting_from,
@@ -137,7 +137,7 @@ impl SchedulingService for Backend {
})
}
- fn sort_deck(&self, input: pb::SortDeckIn) -> Result {
+ fn sort_deck(&self, input: pb::SortDeckIn) -> Result {
self.with_col(|col| {
col.sort_deck(input.deck_id.into(), input.randomize)
.map(Into::into)
@@ -161,13 +161,13 @@ impl SchedulingService for Backend {
Ok(state.leeched().into())
}
- fn answer_card(&self, input: pb::AnswerCardIn) -> Result {
+ fn answer_card(&self, input: pb::AnswerCardIn) -> Result {
self.with_col(|col| col.answer_card(&input.into()))
.map(Into::into)
}
fn upgrade_scheduler(&self, _input: pb::Empty) -> Result {
- self.with_col(|col| col.transact(None, |col| col.upgrade_to_v2_scheduler()))
+ self.with_col(|col| col.transact_no_undo(|col| col.upgrade_to_v2_scheduler()))
.map(Into::into)
}
diff --git a/rslib/src/backend/search.rs b/rslib/src/backend/search.rs
index 6bda494ac..ed4c7d497 100644
--- a/rslib/src/backend/search.rs
+++ b/rslib/src/backend/search.rs
@@ -68,7 +68,7 @@ impl SearchService for Backend {
Ok(replace_search_node(existing, replacement).into())
}
- fn find_and_replace(&self, input: pb::FindAndReplaceIn) -> Result {
+ fn find_and_replace(&self, input: pb::FindAndReplaceIn) -> Result {
let mut search = if input.regex {
input.search
} else {
@@ -86,7 +86,7 @@ impl SearchService for Backend {
let repl = input.replacement;
self.with_col(|col| {
col.find_and_replace(nids, &search, &repl, field_name)
- .map(|cnt| pb::UInt32 { val: cnt as u32 })
+ .map(Into::into)
})
}
}
diff --git a/rslib/src/backend/tags.rs b/rslib/src/backend/tags.rs
index 3d08e0f0d..f7ea19a41 100644
--- a/rslib/src/backend/tags.rs
+++ b/rslib/src/backend/tags.rs
@@ -1,13 +1,13 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-use super::Backend;
+use super::{notes::to_note_ids, Backend};
use crate::{backend_proto as pb, prelude::*};
pub(super) use pb::tags_service::Service as TagsService;
impl TagsService for Backend {
- fn clear_unused_tags(&self, _input: pb::Empty) -> Result {
- self.with_col(|col| col.transact(None, |col| col.clear_unused_tags().map(Into::into)))
+ fn clear_unused_tags(&self, _input: pb::Empty) -> Result {
+ self.with_col(|col| col.transact_no_undo(|col| col.clear_unused_tags().map(Into::into)))
}
fn all_tags(&self, _input: pb::Empty) -> Result {
@@ -23,40 +23,66 @@ impl TagsService for Backend {
})
}
- fn expunge_tags(&self, tags: pb::String) -> Result {
- self.with_col(|col| col.expunge_tags(tags.val.as_str()).map(Into::into))
+ fn remove_tags(&self, tags: pb::String) -> Result {
+ self.with_col(|col| col.remove_tags(tags.val.as_str()).map(Into::into))
}
fn set_tag_expanded(&self, input: pb::SetTagExpandedIn) -> Result {
self.with_col(|col| {
- col.transact(None, |col| {
+ col.transact_no_undo(|col| {
col.set_tag_expanded(&input.name, input.expanded)?;
Ok(().into())
})
})
}
- fn clear_tag(&self, tag: pb::String) -> Result {
- self.with_col(|col| {
- col.transact(None, |col| {
- col.storage.clear_tag_and_children(tag.val.as_str())?;
- Ok(().into())
- })
- })
- }
-
fn tag_tree(&self, _input: pb::Empty) -> Result {
self.with_col(|col| col.tag_tree())
}
- fn drag_drop_tags(&self, input: pb::DragDropTagsIn) -> Result {
- let source_tags = input.source_tags;
- let target_tag = if input.target_tag.is_empty() {
+ fn reparent_tags(&self, input: pb::ReparentTagsIn) -> Result {
+ let source_tags = input.tags;
+ let target_tag = if input.new_parent.is_empty() {
None
} else {
- Some(input.target_tag)
+ Some(input.new_parent)
};
- self.with_col(|col| col.drag_drop_tags(&source_tags, target_tag))
+ self.with_col(|col| col.reparent_tags(&source_tags, target_tag))
.map(Into::into)
}
+
+ fn rename_tags(&self, input: pb::RenameTagsIn) -> Result {
+ self.with_col(|col| col.rename_tag(&input.current_prefix, &input.new_prefix))
+ .map(Into::into)
+ }
+
+ fn add_note_tags(&self, input: pb::NoteIDsAndTagsIn) -> Result {
+ self.with_col(|col| {
+ col.add_tags_to_notes(&to_note_ids(input.note_ids), &input.tags)
+ .map(Into::into)
+ })
+ }
+
+ fn remove_note_tags(&self, input: pb::NoteIDsAndTagsIn) -> Result {
+ self.with_col(|col| {
+ col.remove_tags_from_notes(&to_note_ids(input.note_ids), &input.tags)
+ .map(Into::into)
+ })
+ }
+
+ fn find_and_replace_tag(
+ &self,
+ input: pb::FindAndReplaceTagIn,
+ ) -> Result {
+ self.with_col(|col| {
+ col.find_and_replace_tag(
+ &to_note_ids(input.note_ids),
+ &input.search,
+ &input.replacement,
+ input.regex,
+ input.match_case,
+ )
+ .map(Into::into)
+ })
+ }
}
diff --git a/rslib/src/card/mod.rs b/rslib/src/card/mod.rs
index dec89213a..d6fa34a85 100644
--- a/rslib/src/card/mod.rs
+++ b/rslib/src/card/mod.rs
@@ -6,9 +6,10 @@ pub(crate) mod undo;
use crate::err::{AnkiError, Result};
use crate::notes::NoteID;
use crate::{
- collection::Collection, config::SchedulerVersion, timestamp::TimestampSecs, types::Usn,
+ collection::Collection, config::SchedulerVersion, prelude::*, timestamp::TimestampSecs,
+ types::Usn,
};
-use crate::{define_newtype, undo::UndoableOpKind};
+use crate::{define_newtype, ops::StateChanges};
use crate::{deckconf::DeckConf, decks::DeckID};
use num_enum::TryFromPrimitive;
@@ -110,6 +111,15 @@ impl Card {
self.deck_id = deck;
}
+ fn set_flag(&mut self, flag: u8) {
+ // we currently only allow 4 flags
+ assert!(flag < 5);
+
+ // but reserve space for 7, preserving the rest of
+ // the flags (up to a byte)
+ self.flags = (self.flags & !0b111) | flag
+ }
+
/// Return the total number of steps left to do, ignoring the
/// "steps today" number packed into the DB representation.
pub fn remaining_steps(&self) -> u32 {
@@ -139,13 +149,31 @@ impl Card {
}
impl Collection {
- pub(crate) fn update_card_with_op(
+ pub(crate) fn update_card_maybe_undoable(
&mut self,
card: &mut Card,
- op: Option,
- ) -> Result<()> {
+ undoable: bool,
+ ) -> Result> {
let existing = self.storage.get_card(card.id)?.ok_or(AnkiError::NotFound)?;
- self.transact(op, |col| col.update_card_inner(card, existing, col.usn()?))
+ if undoable {
+ self.transact(Op::UpdateCard, |col| {
+ col.update_card_inner(card, existing, col.usn()?)
+ })
+ } else {
+ self.transact_no_undo(|col| {
+ col.update_card_inner(card, existing, col.usn()?)?;
+ Ok(OpOutput {
+ output: (),
+ changes: OpChanges {
+ op: Op::UpdateCard,
+ changes: StateChanges {
+ card: true,
+ ..Default::default()
+ },
+ },
+ })
+ })
+ }
}
#[cfg(test)]
@@ -203,7 +231,7 @@ impl Collection {
Ok(())
}
- pub fn set_deck(&mut self, cards: &[CardID], deck_id: DeckID) -> Result<()> {
+ pub fn set_deck(&mut self, cards: &[CardID], deck_id: DeckID) -> Result> {
let deck = self.get_deck(deck_id)?.ok_or(AnkiError::NotFound)?;
if deck.is_filtered() {
return Err(AnkiError::DeckIsFiltered);
@@ -211,7 +239,7 @@ impl Collection {
self.storage.set_search_table_to_card_ids(cards, false)?;
let sched = self.scheduler_version();
let usn = self.usn()?;
- self.transact(Some(UndoableOpKind::SetDeck), |col| {
+ self.transact(Op::SetDeck, |col| {
for mut card in col.storage.all_searched_cards()? {
if card.deck_id == deck_id {
continue;
@@ -224,6 +252,24 @@ impl Collection {
})
}
+ pub fn set_card_flag(&mut self, cards: &[CardID], flag: u32) -> Result> {
+ if flag > 4 {
+ return Err(AnkiError::invalid_input("invalid flag"));
+ }
+ let flag = flag as u8;
+
+ self.storage.set_search_table_to_card_ids(cards, false)?;
+ let usn = self.usn()?;
+ self.transact(Op::SetFlag, |col| {
+ for mut card in col.storage.all_searched_cards()? {
+ let original = card.clone();
+ card.set_flag(flag);
+ col.update_card_inner(&mut card, original, usn)?;
+ }
+ Ok(())
+ })
+ }
+
/// Get deck config for the given card. If missing, return default values.
#[allow(dead_code)]
pub(crate) fn deck_config_for_card(&mut self, card: &Card) -> Result {
diff --git a/rslib/src/collection.rs b/rslib/src/collection.rs
index bb701f9b6..8dd635a9c 100644
--- a/rslib/src/collection.rs
+++ b/rslib/src/collection.rs
@@ -1,7 +1,6 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-use crate::i18n::I18n;
use crate::log::Logger;
use crate::types::Usn;
use crate::{
@@ -12,6 +11,7 @@ use crate::{
undo::UndoManager,
};
use crate::{err::Result, scheduler::queue::CardQueues};
+use crate::{i18n::I18n, ops::StateChanges};
use std::{collections::HashMap, path::PathBuf, sync::Arc};
pub fn open_collection>(
@@ -65,6 +65,9 @@ pub struct CollectionState {
pub(crate) notetype_cache: HashMap>,
pub(crate) deck_cache: HashMap>,
pub(crate) card_queues: Option,
+ /// True if legacy Python code has executed SQL that has modified the
+ /// database, requiring modification time to be bumped.
+ pub(crate) modified_by_dbproxy: bool,
}
pub struct Collection {
@@ -80,9 +83,7 @@ pub struct Collection {
}
impl Collection {
- /// Execute the provided closure in a transaction, rolling back if
- /// an error is returned.
- pub(crate) fn transact(&mut self, op: Option, func: F) -> Result
+ fn transact_inner(&mut self, op: Option, func: F) -> Result>
where
F: FnOnce(&mut Collection) -> Result,
{
@@ -92,21 +93,56 @@ impl Collection {
let mut res = func(self);
if res.is_ok() {
- if let Err(e) = self.storage.mark_modified() {
+ if let Err(e) = self.storage.set_modified() {
res = Err(e);
} else if let Err(e) = self.storage.commit_rust_trx() {
res = Err(e);
}
}
- if res.is_err() {
- self.discard_undo_and_study_queues();
- self.storage.rollback_rust_trx()?;
- } else {
- self.end_undoable_operation();
+ match res {
+ Ok(output) => {
+ let changes = if op.is_some() {
+ let changes = self.op_changes()?;
+ self.maybe_clear_study_queues_after_op(changes);
+ self.maybe_coalesce_note_undo_entry(changes);
+ changes
+ } else {
+ self.clear_study_queues();
+ // dummy value, not used by transact_no_undo(). only required
+ // until we can migrate all the code to undoable ops
+ OpChanges {
+ op: Op::SetFlag,
+ changes: StateChanges::default(),
+ }
+ };
+ self.end_undoable_operation();
+ Ok(OpOutput { output, changes })
+ }
+ Err(err) => {
+ self.discard_undo_and_study_queues();
+ self.storage.rollback_rust_trx()?;
+ Err(err)
+ }
}
+ }
- res
+ /// Execute the provided closure in a transaction, rolling back if
+ /// an error is returned. Records undo state, and returns changes.
+ pub(crate) fn transact(&mut self, op: Op, func: F) -> Result>
+ where
+ F: FnOnce(&mut Collection) -> Result,
+ {
+ self.transact_inner(Some(op), func)
+ }
+
+ /// Execute the provided closure in a transaction, rolling back if
+ /// an error is returned.
+ pub(crate) fn transact_no_undo(&mut self, func: F) -> Result
+ where
+ F: FnOnce(&mut Collection) -> Result,
+ {
+ self.transact_inner(None, func).map(|out| out.output)
}
pub(crate) fn close(self, downgrade: bool) -> Result<()> {
@@ -120,7 +156,7 @@ impl Collection {
/// Prepare for upload. Caller should not create transaction.
pub(crate) fn before_upload(&mut self) -> Result<()> {
- self.transact(None, |col| {
+ self.transact_no_undo(|col| {
col.storage.clear_all_graves()?;
col.storage.clear_pending_note_usns()?;
col.storage.clear_pending_card_usns()?;
diff --git a/rslib/src/config/undo.rs b/rslib/src/config/undo.rs
index 1aec6d075..55e14f895 100644
--- a/rslib/src/config/undo.rs
+++ b/rslib/src/config/undo.rs
@@ -71,7 +71,7 @@ mod test {
fn undo() -> Result<()> {
let mut col = open_test_collection();
// the op kind doesn't matter, we just need undo enabled
- let op = Some(UndoableOpKind::Bury);
+ let op = Op::Bury;
// test key
let key = BoolKey::NormalizeNoteText;
diff --git a/rslib/src/dbcheck.rs b/rslib/src/dbcheck.rs
index d5668d3a0..01a900026 100644
--- a/rslib/src/dbcheck.rs
+++ b/rslib/src/dbcheck.rs
@@ -129,7 +129,7 @@ impl Collection {
debug!(self.log, "optimize");
self.storage.optimize()?;
- self.transact(None, |col| col.check_database_inner(progress_fn))
+ self.transact_no_undo(|col| col.check_database_inner(progress_fn))
}
fn check_database_inner(&mut self, mut progress_fn: F) -> Result
diff --git a/rslib/src/decks/mod.rs b/rslib/src/decks/mod.rs
index f0e80a4d7..7b12ea5bc 100644
--- a/rslib/src/decks/mod.rs
+++ b/rslib/src/decks/mod.rs
@@ -10,16 +10,14 @@ pub use crate::backend_proto::{
deck_kind::Kind as DeckKind, filtered_search_term::FilteredSearchOrder, Deck as DeckProto,
DeckCommon, DeckKind as DeckKindProto, FilteredDeck, FilteredSearchTerm, NormalDeck,
};
-use crate::{
- backend_proto as pb, markdown::render_markdown, text::sanitize_html_no_images,
- undo::UndoableOpKind,
-};
+use crate::{backend_proto as pb, markdown::render_markdown, text::sanitize_html_no_images};
use crate::{
collection::Collection,
deckconf::DeckConfID,
define_newtype,
err::{AnkiError, Result},
i18n::TR,
+ prelude::*,
text::normalize_to_nfc,
timestamp::TimestampSecs,
types::Usn,
@@ -269,7 +267,7 @@ impl Collection {
/// or rename children as required. Prefer add_deck() or update_deck() to
/// be explicit about your intentions; this function mainly exists so we
/// can integrate with older Python code that behaved this way.
- pub(crate) fn add_or_update_deck(&mut self, deck: &mut Deck) -> Result<()> {
+ pub(crate) fn add_or_update_deck(&mut self, deck: &mut Deck) -> Result> {
if deck.id.0 == 0 {
self.add_deck(deck)
} else {
@@ -278,12 +276,12 @@ impl Collection {
}
/// Add a new deck. The id must be 0, as it will be automatically assigned.
- pub fn add_deck(&mut self, deck: &mut Deck) -> Result<()> {
+ pub fn add_deck(&mut self, deck: &mut Deck) -> Result> {
if deck.id.0 != 0 {
return Err(AnkiError::invalid_input("deck to add must have id 0"));
}
- self.transact(Some(UndoableOpKind::AddDeck), |col| {
+ self.transact(Op::AddDeck, |col| {
let usn = col.usn()?;
col.prepare_deck_for_update(deck, usn)?;
deck.set_modified(usn);
@@ -292,15 +290,15 @@ impl Collection {
})
}
- pub fn update_deck(&mut self, deck: &mut Deck) -> Result<()> {
- self.transact(Some(UndoableOpKind::UpdateDeck), |col| {
+ pub fn update_deck(&mut self, deck: &mut Deck) -> Result> {
+ self.transact(Op::UpdateDeck, |col| {
let existing_deck = col.storage.get_deck(deck.id)?.ok_or(AnkiError::NotFound)?;
col.update_deck_inner(deck, existing_deck, col.usn()?)
})
}
- pub fn rename_deck(&mut self, did: DeckID, new_human_name: &str) -> Result<()> {
- self.transact(Some(UndoableOpKind::RenameDeck), |col| {
+ pub fn rename_deck(&mut self, did: DeckID, new_human_name: &str) -> Result> {
+ self.transact(Op::RenameDeck, |col| {
let existing_deck = col.storage.get_deck(did)?.ok_or(AnkiError::NotFound)?;
let mut deck = existing_deck.clone();
deck.name = human_deck_name_to_native(new_human_name);
@@ -466,9 +464,9 @@ impl Collection {
self.storage.get_deck_id(&machine_name)
}
- pub fn remove_decks_and_child_decks(&mut self, dids: &[DeckID]) -> Result {
- let mut card_count = 0;
- self.transact(Some(UndoableOpKind::RemoveDeck), |col| {
+ pub fn remove_decks_and_child_decks(&mut self, dids: &[DeckID]) -> Result> {
+ self.transact(Op::RemoveDeck, |col| {
+ let mut card_count = 0;
let usn = col.usn()?;
for did in dids {
if let Some(deck) = col.storage.get_deck(*did)? {
@@ -483,9 +481,8 @@ impl Collection {
}
}
}
- Ok(())
- })?;
- Ok(card_count)
+ Ok(card_count)
+ })
}
pub(crate) fn remove_single_deck(&mut self, deck: &Deck, usn: Usn) -> Result {
@@ -625,9 +622,9 @@ impl Collection {
&mut self,
source_decks: &[DeckID],
target: Option,
- ) -> Result<()> {
+ ) -> Result> {
let usn = self.usn()?;
- self.transact(Some(UndoableOpKind::RenameDeck), |col| {
+ self.transact(Op::RenameDeck, |col| {
let target_deck;
let mut target_name = None;
if let Some(target) = target {
diff --git a/rslib/src/filtered.rs b/rslib/src/filtered.rs
index 101fb86cb..0fae27f07 100644
--- a/rslib/src/filtered.rs
+++ b/rslib/src/filtered.rs
@@ -169,7 +169,7 @@ pub(crate) struct DeckFilterContext<'a> {
impl Collection {
pub fn empty_filtered_deck(&mut self, did: DeckID) -> Result<()> {
- self.transact(None, |col| col.return_all_cards_in_filtered_deck(did))
+ self.transact_no_undo(|col| col.return_all_cards_in_filtered_deck(did))
}
pub(super) fn return_all_cards_in_filtered_deck(&mut self, did: DeckID) -> Result<()> {
let cids = self.storage.all_cards_in_single_deck(did)?;
@@ -206,7 +206,7 @@ impl Collection {
today: self.timing_today()?.days_elapsed,
};
- self.transact(None, |col| {
+ self.transact_no_undo(|col| {
col.return_all_cards_in_filtered_deck(did)?;
col.build_filtered_deck(ctx)
})
diff --git a/rslib/src/findreplace.rs b/rslib/src/findreplace.rs
index 8d97eb1ec..a52ef2307 100644
--- a/rslib/src/findreplace.rs
+++ b/rslib/src/findreplace.rs
@@ -45,8 +45,8 @@ impl Collection {
search_re: &str,
repl: &str,
field_name: Option,
- ) -> Result {
- self.transact(None, |col| {
+ ) -> Result> {
+ self.transact(Op::FindAndReplace, |col| {
let norm = col.get_bool(BoolKey::NormalizeNoteText);
let search = if norm {
normalize_to_nfc(search_re)
@@ -119,8 +119,8 @@ mod test {
col.add_note(&mut note2, DeckID(1))?;
let nids = col.search_notes("")?;
- let cnt = col.find_and_replace(nids.clone(), "(?i)AAA", "BBB", None)?;
- assert_eq!(cnt, 2);
+ let out = col.find_and_replace(nids.clone(), "(?i)AAA", "BBB", None)?;
+ assert_eq!(out.output, 2);
let note = col.storage.get_note(note.id)?.unwrap();
// but the update should be limited to the specified field when it was available
@@ -138,10 +138,10 @@ mod test {
"Text".into()
]
);
- let cnt = col.find_and_replace(nids, "BBB", "ccc", Some("Front".into()))?;
+ let out = col.find_and_replace(nids, "BBB", "ccc", Some("Front".into()))?;
// still 2, as the caller is expected to provide only note ids that have
// that field, and if we can't find the field we fall back on all fields
- assert_eq!(cnt, 2);
+ assert_eq!(out.output, 2);
let note = col.storage.get_note(note.id)?.unwrap();
// but the update should be limited to the specified field when it was available
diff --git a/rslib/src/lib.rs b/rslib/src/lib.rs
index c3a138ad0..bcbf847f2 100644
--- a/rslib/src/lib.rs
+++ b/rslib/src/lib.rs
@@ -24,6 +24,7 @@ mod markdown;
pub mod media;
pub mod notes;
pub mod notetype;
+pub mod ops;
mod preferences;
pub mod prelude;
pub mod revlog;
diff --git a/rslib/src/media/check.rs b/rslib/src/media/check.rs
index a6018c1dd..6e8107e83 100644
--- a/rslib/src/media/check.rs
+++ b/rslib/src/media/check.rs
@@ -572,7 +572,7 @@ pub(crate) mod test {
let progress = |_n| true;
- let (output, report) = col.transact(None, |ctx| {
+ let (output, report) = col.transact_no_undo(|ctx| {
let mut checker = MediaChecker::new(ctx, &mgr, progress);
let output = checker.check()?;
let summary = checker.summarize_output(&mut output.clone());
@@ -642,7 +642,7 @@ Unused: unused.jpg
let progress = |_n| true;
- col.transact(None, |ctx| {
+ col.transact_no_undo(|ctx| {
let mut checker = MediaChecker::new(ctx, &mgr, progress);
checker.restore_trash()
})?;
@@ -656,7 +656,7 @@ Unused: unused.jpg
// if we repeat the process, restoring should do the same thing if the contents are equal
fs::write(trash_folder.join("test.jpg"), "test")?;
- col.transact(None, |ctx| {
+ col.transact_no_undo(|ctx| {
let mut checker = MediaChecker::new(ctx, &mgr, progress);
checker.restore_trash()
})?;
@@ -668,7 +668,7 @@ Unused: unused.jpg
// but rename if required
fs::write(trash_folder.join("test.jpg"), "test2")?;
- col.transact(None, |ctx| {
+ col.transact_no_undo(|ctx| {
let mut checker = MediaChecker::new(ctx, &mgr, progress);
checker.restore_trash()
})?;
@@ -692,7 +692,7 @@ Unused: unused.jpg
let progress = |_n| true;
- let mut output = col.transact(None, |ctx| {
+ let mut output = col.transact_no_undo(|ctx| {
let mut checker = MediaChecker::new(ctx, &mgr, progress);
checker.check()
})?;
diff --git a/rslib/src/notes/mod.rs b/rslib/src/notes/mod.rs
index 18dee8f3f..4522400f6 100644
--- a/rslib/src/notes/mod.rs
+++ b/rslib/src/notes/mod.rs
@@ -3,7 +3,6 @@
pub(crate) mod undo;
-use crate::backend_proto::note_is_duplicate_or_empty_out::State as DuplicateState;
use crate::{
backend_proto as pb,
decks::DeckID,
@@ -16,9 +15,11 @@ use crate::{
timestamp::TimestampSecs,
types::Usn,
};
+use crate::{
+ backend_proto::note_is_duplicate_or_empty_out::State as DuplicateState, ops::StateChanges,
+};
use itertools::Itertools;
use num_integer::Integer;
-use regex::{Regex, Replacer};
use std::{
borrow::Cow,
collections::{HashMap, HashSet},
@@ -47,6 +48,23 @@ pub struct Note {
pub(crate) checksum: Option,
}
+/// Information required for updating tags while leaving note content alone.
+/// Tags are stored in their DB form, separated by spaces.
+#[derive(Debug, PartialEq, Clone)]
+pub(crate) struct NoteTags {
+ pub id: NoteID,
+ pub mtime: TimestampSecs,
+ pub usn: Usn,
+ pub tags: String,
+}
+
+impl NoteTags {
+ pub(crate) fn set_modified(&mut self, usn: Usn) {
+ self.mtime = TimestampSecs::now();
+ self.usn = usn;
+ }
+}
+
impl Note {
pub(crate) fn new(notetype: &NoteType) -> Self {
Note {
@@ -191,38 +209,6 @@ impl Note {
.collect()
}
- pub(crate) fn remove_tags(&mut self, re: &Regex) -> bool {
- let old_len = self.tags.len();
- self.tags.retain(|tag| !re.is_match(tag));
- old_len > self.tags.len()
- }
-
- pub(crate) fn replace_tags(&mut self, re: &Regex, mut repl: T) -> bool {
- let mut changed = false;
- for tag in &mut self.tags {
- if let Cow::Owned(rep) = re.replace_all(tag, |caps: ®ex::Captures| {
- if let Some(expanded) = repl.by_ref().no_expansion() {
- if expanded.trim().is_empty() {
- "".to_string()
- } else {
- // include "::" if it was matched
- format!(
- "{}{}",
- expanded,
- caps.get(caps.len() - 1).map_or("", |m| m.as_str())
- )
- }
- } else {
- tag.to_string()
- }
- }) {
- *tag = rep;
- changed = true;
- }
- }
- changed
- }
-
/// Pad or merge fields to match note type.
pub(crate) fn fix_field_count(&mut self, nt: &NoteType) {
while self.fields.len() < nt.fields.len() {
@@ -305,8 +291,8 @@ impl Collection {
Ok(())
}
- pub fn add_note(&mut self, note: &mut Note, did: DeckID) -> Result<()> {
- self.transact(Some(UndoableOpKind::AddNote), |col| {
+ pub fn add_note(&mut self, note: &mut Note, did: DeckID) -> Result> {
+ self.transact(Op::AddNote, |col| {
let nt = col
.get_notetype(note.notetype_id)?
.ok_or_else(|| AnkiError::invalid_input("missing note type"))?;
@@ -334,29 +320,49 @@ impl Collection {
}
#[cfg(test)]
- pub(crate) fn update_note(&mut self, note: &mut Note) -> Result<()> {
- self.update_note_with_op(note, Some(UndoableOpKind::UpdateNote))
+ pub(crate) fn update_note(&mut self, note: &mut Note) -> Result> {
+ self.update_note_maybe_undoable(note, true)
}
- pub(crate) fn update_note_with_op(
+ pub(crate) fn update_note_maybe_undoable(
&mut self,
note: &mut Note,
- op: Option,
- ) -> Result<()> {
+ undoable: bool,
+ ) -> Result> {
+ if undoable {
+ self.transact(Op::UpdateNote, |col| col.update_note_inner(note))
+ } else {
+ self.transact_no_undo(|col| {
+ col.update_note_inner(note)?;
+ Ok(OpOutput {
+ output: (),
+ changes: OpChanges {
+ op: Op::UpdateNote,
+ changes: StateChanges {
+ note: true,
+ tag: true,
+ card: true,
+ ..Default::default()
+ },
+ },
+ })
+ })
+ }
+ }
+
+ pub(crate) fn update_note_inner(&mut self, note: &mut Note) -> Result<()> {
let mut existing_note = self.storage.get_note(note.id)?.ok_or(AnkiError::NotFound)?;
if !note_differs_from_db(&mut existing_note, note) {
// nothing to do
return Ok(());
}
-
- self.transact(op, |col| {
- let nt = col
- .get_notetype(note.notetype_id)?
- .ok_or_else(|| AnkiError::invalid_input("missing note type"))?;
- let ctx = CardGenContext::new(&nt, col.usn()?);
- let norm = col.get_bool(BoolKey::NormalizeNoteText);
- col.update_note_inner_generating_cards(&ctx, note, &existing_note, true, norm)
- })
+ let nt = self
+ .get_notetype(note.notetype_id)?
+ .ok_or_else(|| AnkiError::invalid_input("missing note type"))?;
+ let ctx = CardGenContext::new(&nt, self.usn()?);
+ let norm = self.get_bool(BoolKey::NormalizeNoteText);
+ self.update_note_inner_generating_cards(&ctx, note, &existing_note, true, norm)?;
+ Ok(())
}
pub(crate) fn update_note_inner_generating_cards(
@@ -392,13 +398,13 @@ impl Collection {
if mark_note_modified {
note.set_modified(usn);
}
- self.update_note_undoable(note, original, true)
+ self.update_note_undoable(note, original)
}
/// Remove provided notes, and any cards that use them.
- pub(crate) fn remove_notes(&mut self, nids: &[NoteID]) -> Result<()> {
+ pub(crate) fn remove_notes(&mut self, nids: &[NoteID]) -> Result> {
let usn = self.usn()?;
- self.transact(Some(UndoableOpKind::RemoveNote), |col| {
+ self.transact(Op::RemoveNote, |col| {
for nid in nids {
let nid = *nid;
if let Some(_existing_note) = col.storage.get_note(nid)? {
@@ -408,27 +414,28 @@ impl Collection {
col.remove_note_only_undoable(nid, usn)?;
}
}
-
Ok(())
})
}
/// Update cards and field cache after notes modified externally.
/// If gencards is false, skip card generation.
- pub(crate) fn after_note_updates(
+ pub fn after_note_updates(
&mut self,
nids: &[NoteID],
generate_cards: bool,
mark_notes_modified: bool,
- ) -> Result<()> {
- self.transform_notes(nids, |_note, _nt| {
- Ok(TransformNoteOutput {
- changed: true,
- generate_cards,
- mark_modified: mark_notes_modified,
+ ) -> Result> {
+ self.transact(Op::UpdateNote, |col| {
+ col.transform_notes(nids, |_note, _nt| {
+ Ok(TransformNoteOutput {
+ changed: true,
+ generate_cards,
+ mark_modified: mark_notes_modified,
+ })
})
+ .map(|_| ())
})
- .map(|_| ())
}
pub(crate) fn transform_notes(
diff --git a/rslib/src/notes/undo.rs b/rslib/src/notes/undo.rs
index 02bec7443..c79ebceda 100644
--- a/rslib/src/notes/undo.rs
+++ b/rslib/src/notes/undo.rs
@@ -3,6 +3,8 @@
use crate::{prelude::*, undo::UndoableChange};
+use super::NoteTags;
+
#[derive(Debug)]
pub(crate) enum UndoableNoteChange {
Added(Box),
@@ -10,6 +12,7 @@ pub(crate) enum UndoableNoteChange {
Removed(Box),
GraveAdded(Box<(NoteID, Usn)>),
GraveRemoved(Box<(NoteID, Usn)>),
+ TagsUpdated(Box),
}
impl Collection {
@@ -21,27 +24,25 @@ impl Collection {
.storage
.get_note(note.id)?
.ok_or_else(|| AnkiError::invalid_input("note disappeared"))?;
- self.update_note_undoable(¬e, ¤t, false)
+ self.update_note_undoable(¬e, ¤t)
}
UndoableNoteChange::Removed(note) => self.restore_deleted_note(*note),
UndoableNoteChange::GraveAdded(e) => self.remove_note_grave(e.0, e.1),
UndoableNoteChange::GraveRemoved(e) => self.add_note_grave(e.0, e.1),
+ UndoableNoteChange::TagsUpdated(note_tags) => {
+ let current = self
+ .storage
+ .get_note_tags_by_id(note_tags.id)?
+ .ok_or_else(|| AnkiError::invalid_input("note disappeared"))?;
+ self.update_note_tags_undoable(¬e_tags, current)
+ }
}
}
/// Saves in the undo queue, and commits to DB.
/// No validation, card generation or normalization is done.
- /// If `coalesce_updates` is true, successive updates within a 1 minute
- /// period will not result in further undo entries.
- pub(super) fn update_note_undoable(
- &mut self,
- note: &Note,
- original: &Note,
- coalesce_updates: bool,
- ) -> Result<()> {
- if !coalesce_updates || !self.note_was_just_updated(note) {
- self.save_undo(UndoableNoteChange::Updated(Box::new(original.clone())));
- }
+ pub(super) fn update_note_undoable(&mut self, note: &Note, original: &Note) -> Result<()> {
+ self.save_undo(UndoableNoteChange::Updated(Box::new(original.clone())));
self.storage.update_note(note)?;
Ok(())
@@ -57,6 +58,31 @@ impl Collection {
Ok(())
}
+ /// If note is edited multiple times in quick succession, avoid creating extra undo entries.
+ pub(crate) fn maybe_coalesce_note_undo_entry(&mut self, changes: OpChanges) {
+ if changes.op != Op::UpdateNote {
+ return;
+ }
+
+ if let Some(previous_op) = self.previous_undo_op() {
+ if previous_op.kind != Op::UpdateNote {
+ return;
+ }
+
+ if let (
+ Some(UndoableChange::Note(UndoableNoteChange::Updated(previous))),
+ Some(UndoableChange::Note(UndoableNoteChange::Updated(current))),
+ ) = (
+ previous_op.changes.last(),
+ self.current_undo_op().and_then(|op| op.changes.last()),
+ ) {
+ if previous.id == current.id && previous_op.timestamp.elapsed_secs() < 60 {
+ self.pop_last_change();
+ }
+ }
+ }
+ }
+
/// Add a note, not adding any cards.
pub(super) fn add_note_only_undoable(&mut self, note: &mut Note) -> Result<(), AnkiError> {
self.storage.add_note(note)?;
@@ -65,6 +91,15 @@ impl Collection {
Ok(())
}
+ pub(crate) fn update_note_tags_undoable(
+ &mut self,
+ tags: &NoteTags,
+ original: NoteTags,
+ ) -> Result<()> {
+ self.save_undo(UndoableNoteChange::TagsUpdated(Box::new(original)));
+ self.storage.update_note_tags(tags)
+ }
+
fn remove_note_without_grave(&mut self, note: Note) -> Result<()> {
self.storage.remove_note(note.id)?;
self.save_undo(UndoableNoteChange::Removed(Box::new(note)));
@@ -86,22 +121,4 @@ impl Collection {
self.save_undo(UndoableNoteChange::GraveRemoved(Box::new((nid, usn))));
self.storage.remove_note_grave(nid)
}
-
- /// True only if the last operation was UpdateNote, and the same note was just updated less than
- /// a minute ago.
- fn note_was_just_updated(&self, before_change: &Note) -> bool {
- self.previous_undo_op()
- .map(|op| {
- if let Some(UndoableChange::Note(UndoableNoteChange::Updated(note))) =
- op.changes.last()
- {
- note.id == before_change.id
- && op.kind == UndoableOpKind::UpdateNote
- && op.timestamp.elapsed_secs() < 60
- } else {
- false
- }
- })
- .unwrap_or(false)
- }
}
diff --git a/rslib/src/notetype/mod.rs b/rslib/src/notetype/mod.rs
index 089ae52e5..526ae7849 100644
--- a/rslib/src/notetype/mod.rs
+++ b/rslib/src/notetype/mod.rs
@@ -376,7 +376,7 @@ impl From for NoteTypeProto {
impl Collection {
/// Add a new notetype, and allocate it an ID.
pub fn add_notetype(&mut self, nt: &mut NoteType) -> Result<()> {
- self.transact(None, |col| {
+ self.transact_no_undo(|col| {
let usn = col.usn()?;
nt.set_modified(usn);
col.add_notetype_inner(nt, usn)
@@ -415,7 +415,7 @@ impl Collection {
let existing = self.get_notetype(nt.id)?;
let norm = self.get_bool(BoolKey::NormalizeNoteText);
nt.prepare_for_update(existing.as_ref().map(AsRef::as_ref))?;
- self.transact(None, |col| {
+ self.transact_no_undo(|col| {
if let Some(existing_notetype) = existing {
if existing_notetype.mtime_secs > nt.mtime_secs {
return Err(AnkiError::invalid_input("attempt to save stale notetype"));
@@ -484,7 +484,7 @@ impl Collection {
pub fn remove_notetype(&mut self, ntid: NoteTypeID) -> Result<()> {
// fixme: currently the storage layer is taking care of removing the notes and cards,
// but we need to do it in this layer in the future for undo handling
- self.transact(None, |col| {
+ self.transact_no_undo(|col| {
col.storage.set_schema_modified()?;
col.state.notetype_cache.remove(&ntid);
col.clear_aux_config_for_notetype(ntid)?;
diff --git a/rslib/src/ops.rs b/rslib/src/ops.rs
new file mode 100644
index 000000000..c0bad2827
--- /dev/null
+++ b/rslib/src/ops.rs
@@ -0,0 +1,86 @@
+// Copyright: Ankitects Pty Ltd and contributors
+// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
+
+use crate::prelude::*;
+
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub enum Op {
+ AddDeck,
+ AddNote,
+ AnswerCard,
+ Bury,
+ ClearUnusedTags,
+ FindAndReplace,
+ RemoveDeck,
+ RemoveNote,
+ RemoveTag,
+ RenameDeck,
+ RenameTag,
+ ReparentTag,
+ ScheduleAsNew,
+ SetDeck,
+ SetDueDate,
+ SetFlag,
+ SortCards,
+ Suspend,
+ UnburyUnsuspend,
+ UpdateCard,
+ UpdateDeck,
+ UpdateNote,
+ UpdatePreferences,
+ UpdateTag,
+}
+
+impl Op {
+ pub fn describe(self, i18n: &I18n) -> String {
+ let key = match self {
+ Op::AddDeck => TR::UndoAddDeck,
+ Op::AddNote => TR::UndoAddNote,
+ Op::AnswerCard => TR::UndoAnswerCard,
+ Op::Bury => TR::StudyingBury,
+ Op::RemoveDeck => TR::DecksDeleteDeck,
+ Op::RemoveNote => TR::StudyingDeleteNote,
+ Op::RenameDeck => TR::ActionsRenameDeck,
+ Op::ScheduleAsNew => TR::UndoForgetCard,
+ Op::SetDueDate => TR::ActionsSetDueDate,
+ Op::Suspend => TR::StudyingSuspend,
+ Op::UnburyUnsuspend => TR::UndoUnburyUnsuspend,
+ Op::UpdateCard => TR::UndoUpdateCard,
+ Op::UpdateDeck => TR::UndoUpdateDeck,
+ Op::UpdateNote => TR::UndoUpdateNote,
+ Op::UpdatePreferences => TR::PreferencesPreferences,
+ Op::UpdateTag => TR::UndoUpdateTag,
+ Op::SetDeck => TR::BrowsingChangeDeck,
+ Op::SetFlag => TR::UndoSetFlag,
+ Op::FindAndReplace => TR::BrowsingFindAndReplace,
+ Op::ClearUnusedTags => TR::BrowsingClearUnusedTags,
+ Op::SortCards => TR::BrowsingReschedule,
+ Op::RenameTag => TR::ActionsRenameTag,
+ Op::RemoveTag => TR::ActionsRemoveTag,
+ Op::ReparentTag => TR::UndoReparent,
+ };
+
+ i18n.tr(key).to_string()
+ }
+}
+
+#[derive(Debug, Default, Clone, Copy)]
+pub struct StateChanges {
+ pub card: bool,
+ pub note: bool,
+ pub deck: bool,
+ pub tag: bool,
+ pub notetype: bool,
+ pub preference: bool,
+}
+
+#[derive(Debug, Clone, Copy)]
+pub struct OpChanges {
+ pub op: Op,
+ pub changes: StateChanges,
+}
+
+pub struct OpOutput {
+ pub output: T,
+ pub changes: OpChanges,
+}
diff --git a/rslib/src/preferences.rs b/rslib/src/preferences.rs
index e1b4eb80f..5f607a5b4 100644
--- a/rslib/src/preferences.rs
+++ b/rslib/src/preferences.rs
@@ -10,6 +10,7 @@ use crate::{
collection::Collection,
config::BoolKey,
err::Result,
+ prelude::*,
scheduler::timing::local_minutes_west_for_stamp,
};
@@ -22,17 +23,13 @@ impl Collection {
})
}
- pub fn set_preferences(&mut self, prefs: Preferences) -> Result<()> {
- self.transact(
- Some(crate::undo::UndoableOpKind::UpdatePreferences),
- |col| col.set_preferences_inner(prefs),
- )
+ pub fn set_preferences(&mut self, prefs: Preferences) -> Result> {
+ self.transact(Op::UpdatePreferences, |col| {
+ col.set_preferences_inner(prefs)
+ })
}
- fn set_preferences_inner(
- &mut self,
- prefs: Preferences,
- ) -> Result<(), crate::prelude::AnkiError> {
+ fn set_preferences_inner(&mut self, prefs: Preferences) -> Result<()> {
if let Some(sched) = prefs.scheduling {
self.set_scheduling_preferences(sched)?;
}
diff --git a/rslib/src/prelude.rs b/rslib/src/prelude.rs
index 2e0d589ac..949188e98 100644
--- a/rslib/src/prelude.rs
+++ b/rslib/src/prelude.rs
@@ -11,9 +11,9 @@ pub use crate::{
i18n::{tr_args, tr_strs, I18n, TR},
notes::{Note, NoteID},
notetype::{NoteType, NoteTypeID},
+ ops::{Op, OpChanges, OpOutput},
revlog::RevlogID,
timestamp::{TimestampMillis, TimestampSecs},
types::Usn,
- undo::UndoableOpKind,
};
pub use slog::{debug, Logger};
diff --git a/rslib/src/scheduler/answering/mod.rs b/rslib/src/scheduler/answering/mod.rs
index 87b649250..762d38687 100644
--- a/rslib/src/scheduler/answering/mod.rs
+++ b/rslib/src/scheduler/answering/mod.rs
@@ -240,10 +240,8 @@ impl Collection {
}
/// Answer card, writing its new state to the database.
- pub fn answer_card(&mut self, answer: &CardAnswer) -> Result<()> {
- self.transact(Some(UndoableOpKind::AnswerCard), |col| {
- col.answer_card_inner(answer)
- })
+ pub fn answer_card(&mut self, answer: &CardAnswer) -> Result> {
+ self.transact(Op::AnswerCard, |col| col.answer_card_inner(answer))
}
fn answer_card_inner(&mut self, answer: &CardAnswer) -> Result<()> {
@@ -274,9 +272,7 @@ impl Collection {
self.add_leech_tag(card.note_id)?;
}
- self.update_queues_after_answering_card(&card, timing)?;
-
- Ok(())
+ self.update_queues_after_answering_card(&card, timing)
}
fn maybe_bury_siblings(&mut self, card: &Card, config: &DeckConf) -> Result<()> {
diff --git a/rslib/src/scheduler/bury_and_suspend.rs b/rslib/src/scheduler/bury_and_suspend.rs
index 43f766bf3..011589caa 100644
--- a/rslib/src/scheduler/bury_and_suspend.rs
+++ b/rslib/src/scheduler/bury_and_suspend.rs
@@ -68,8 +68,8 @@ impl Collection {
self.storage.clear_searched_cards_table()
}
- pub fn unbury_or_unsuspend_cards(&mut self, cids: &[CardID]) -> Result<()> {
- self.transact(Some(UndoableOpKind::UnburyUnsuspend), |col| {
+ pub fn unbury_or_unsuspend_cards(&mut self, cids: &[CardID]) -> Result> {
+ self.transact(Op::UnburyUnsuspend, |col| {
col.storage.set_search_table_to_card_ids(cids, false)?;
col.unsuspend_or_unbury_searched_cards()
})
@@ -81,7 +81,7 @@ impl Collection {
UnburyDeckMode::UserOnly => "is:buried-manually",
UnburyDeckMode::SchedOnly => "is:buried-sibling",
};
- self.transact(None, |col| {
+ self.transact_no_undo(|col| {
col.search_cards_into_table(&format!("deck:current {}", search), SortMode::NoOrder)?;
col.unsuspend_or_unbury_searched_cards()
})
@@ -124,12 +124,12 @@ impl Collection {
&mut self,
cids: &[CardID],
mode: BuryOrSuspendMode,
- ) -> Result<()> {
+ ) -> Result> {
let op = match mode {
- BuryOrSuspendMode::Suspend => UndoableOpKind::Suspend,
- BuryOrSuspendMode::BurySched | BuryOrSuspendMode::BuryUser => UndoableOpKind::Bury,
+ BuryOrSuspendMode::Suspend => Op::Suspend,
+ BuryOrSuspendMode::BurySched | BuryOrSuspendMode::BuryUser => Op::Bury,
};
- self.transact(Some(op), |col| {
+ self.transact(op, |col| {
col.storage.set_search_table_to_card_ids(cids, false)?;
col.bury_or_suspend_searched_cards(mode)
})
diff --git a/rslib/src/scheduler/new.rs b/rslib/src/scheduler/new.rs
index 06691a421..c01913f40 100644
--- a/rslib/src/scheduler/new.rs
+++ b/rslib/src/scheduler/new.rs
@@ -7,9 +7,9 @@ use crate::{
decks::DeckID,
err::Result,
notes::NoteID,
+ prelude::*,
search::SortMode,
types::Usn,
- undo::UndoableOpKind,
};
use rand::seq::SliceRandom;
use std::collections::{HashMap, HashSet};
@@ -24,12 +24,14 @@ impl Card {
self.ease_factor = 0;
}
- /// If the card is new, change its position.
- fn set_new_position(&mut self, position: u32) {
+ /// If the card is new, change its position, and return true.
+ fn set_new_position(&mut self, position: u32) -> bool {
if self.queue != CardQueue::New || self.ctype != CardType::New {
- return;
+ false
+ } else {
+ self.due = position as i32;
+ true
}
- self.due = position as i32;
}
}
pub(crate) struct NewCardSorter {
@@ -103,10 +105,10 @@ fn nids_in_preserved_order(cards: &[Card]) -> Vec {
}
impl Collection {
- pub fn reschedule_cards_as_new(&mut self, cids: &[CardID], log: bool) -> Result<()> {
+ pub fn reschedule_cards_as_new(&mut self, cids: &[CardID], log: bool) -> Result> {
let usn = self.usn()?;
let mut position = self.get_next_card_position();
- self.transact(Some(UndoableOpKind::ScheduleAsNew), |col| {
+ self.transact(Op::ScheduleAsNew, |col| {
col.storage.set_search_table_to_card_ids(cids, true)?;
let cards = col.storage.all_searched_cards_in_search_order()?;
for mut card in cards {
@@ -119,8 +121,7 @@ impl Collection {
position += 1;
}
col.set_next_card_position(position)?;
- col.storage.clear_searched_cards_table()?;
- Ok(())
+ col.storage.clear_searched_cards_table()
})
}
@@ -131,9 +132,9 @@ impl Collection {
step: u32,
order: NewCardSortOrder,
shift: bool,
- ) -> Result<()> {
+ ) -> Result> {
let usn = self.usn()?;
- self.transact(None, |col| {
+ self.transact(Op::SortCards, |col| {
col.sort_cards_inner(cids, starting_from, step, order, shift, usn)
})
}
@@ -146,24 +147,28 @@ impl Collection {
order: NewCardSortOrder,
shift: bool,
usn: Usn,
- ) -> Result<()> {
+ ) -> Result {
if shift {
self.shift_existing_cards(starting_from, step * cids.len() as u32, usn)?;
}
self.storage.set_search_table_to_card_ids(cids, true)?;
let cards = self.storage.all_searched_cards_in_search_order()?;
let sorter = NewCardSorter::new(&cards, starting_from, step, order);
+ let mut count = 0;
for mut card in cards {
let original = card.clone();
- card.set_new_position(sorter.position(&card));
- self.update_card_inner(&mut card, original, usn)?;
+ if card.set_new_position(sorter.position(&card)) {
+ count += 1;
+ self.update_card_inner(&mut card, original, usn)?;
+ }
}
- self.storage.clear_searched_cards_table()
+ self.storage.clear_searched_cards_table()?;
+ Ok(count)
}
/// This creates a transaction - we probably want to split it out
/// in the future if calling it as part of a deck options update.
- pub fn sort_deck(&mut self, deck: DeckID, random: bool) -> Result<()> {
+ pub fn sort_deck(&mut self, deck: DeckID, random: bool) -> Result> {
let cids = self.search_cards(&format!("did:{} is:new", deck), SortMode::NoOrder)?;
let order = if random {
NewCardSortOrder::Random
diff --git a/rslib/src/scheduler/queue/mod.rs b/rslib/src/scheduler/queue/mod.rs
index 64d38d1e0..717120748 100644
--- a/rslib/src/scheduler/queue/mod.rs
+++ b/rslib/src/scheduler/queue/mod.rs
@@ -139,6 +139,13 @@ impl Collection {
self.state.card_queues = None;
}
+ pub(crate) fn maybe_clear_study_queues_after_op(&mut self, op: OpChanges) {
+ if op.op != Op::AnswerCard && (op.changes.card || op.changes.deck || op.changes.preference)
+ {
+ self.state.card_queues = None;
+ }
+ }
+
pub(crate) fn update_queues_after_answering_card(
&mut self,
card: &Card,
diff --git a/rslib/src/scheduler/reviews.rs b/rslib/src/scheduler/reviews.rs
index cec735a4e..fdb929438 100644
--- a/rslib/src/scheduler/reviews.rs
+++ b/rslib/src/scheduler/reviews.rs
@@ -7,8 +7,7 @@ use crate::{
config::StringKey,
deckconf::INITIAL_EASE_FACTOR_THOUSANDS,
err::Result,
- prelude::AnkiError,
- undo::UndoableOpKind,
+ prelude::*,
};
use lazy_static::lazy_static;
use rand::distributions::{Distribution, Uniform};
@@ -94,13 +93,13 @@ impl Collection {
cids: &[CardID],
days: &str,
context: Option,
- ) -> Result<()> {
+ ) -> Result> {
let spec = parse_due_date_str(days)?;
let usn = self.usn()?;
let today = self.timing_today()?.days_elapsed;
let mut rng = rand::thread_rng();
let distribution = Uniform::from(spec.min..=spec.max);
- self.transact(Some(UndoableOpKind::SetDueDate), |col| {
+ self.transact(Op::SetDueDate, |col| {
col.storage.set_search_table_to_card_ids(cids, false)?;
for mut card in col.storage.all_searched_cards()? {
let original = card.clone();
diff --git a/rslib/src/storage/card/mod.rs b/rslib/src/storage/card/mod.rs
index 40e476ff2..d04f2c051 100644
--- a/rslib/src/storage/card/mod.rs
+++ b/rslib/src/storage/card/mod.rs
@@ -445,7 +445,7 @@ impl super::SqliteStorage {
/// Injects the provided card IDs into the search_cids table, for
/// when ids have arrived outside of a search.
- /// Clear with clear_searched_cards().
+ /// Clear with clear_searched_cards_table().
pub(crate) fn set_search_table_to_card_ids(
&mut self,
cards: &[CardID],
diff --git a/rslib/src/storage/note/get_tags.sql b/rslib/src/storage/note/get_tags.sql
new file mode 100644
index 000000000..5bcd2be0e
--- /dev/null
+++ b/rslib/src/storage/note/get_tags.sql
@@ -0,0 +1,5 @@
+SELECT id,
+ mod,
+ usn,
+ tags
+FROM notes
\ No newline at end of file
diff --git a/rslib/src/storage/note/mod.rs b/rslib/src/storage/note/mod.rs
index 9ea67cf0d..d8a28b630 100644
--- a/rslib/src/storage/note/mod.rs
+++ b/rslib/src/storage/note/mod.rs
@@ -5,7 +5,7 @@ use std::collections::HashSet;
use crate::{
err::Result,
- notes::{Note, NoteID},
+ notes::{Note, NoteID, NoteTags},
notetype::NoteTypeID,
tags::{join_tags, split_tags},
timestamp::TimestampMillis,
@@ -20,22 +20,6 @@ pub(crate) fn join_fields(fields: &[String]) -> String {
fields.join("\x1f")
}
-fn row_to_note(row: &Row) -> Result {
- Ok(Note::new_from_storage(
- row.get(0)?,
- row.get(1)?,
- row.get(2)?,
- row.get(3)?,
- row.get(4)?,
- split_tags(row.get_raw(5).as_str()?)
- .map(Into::into)
- .collect(),
- split_fields(row.get_raw(6).as_str()?),
- Some(row.get(7)?),
- Some(row.get(8).unwrap_or_default()),
- ))
-}
-
impl super::SqliteStorage {
pub fn get_note(&self, nid: NoteID) -> Result