mirror of
https://github.com/ankitects/anki.git
synced 2025-09-24 16:56:36 -04:00
undoable ops now return changes directly; add new *_ops.py files
- Introduced a new transact() method that wraps the return value in a separate struct that describes the changes that were made. - Changes are now gathered from the undo log, so we don't need to guess at what was changed - eg if update_note() is called with identical note contents, no changes are returned. Card changes will only be set if cards were actually generated by the update_note() call, and tag will only be set if a new tag was added. - mw.perform_op() has been updated to expect the op to return the changes, or a structure with the changes in it, and it will use them to fire the change hook, instead of fetching the changes from undo_status(), so there is no risk of race conditions. - the various calls to mw.perform_op() have been split into separate files like card_ops.py. Aside from making the code cleaner, this works around a rather annoying issue with mypy. Because we run it with no_strict_optional, mypy is happy to accept an operation that returns None, despite the type signature saying it requires changes to be returned. Turning no_strict_optional on for the whole codebase is not practical at the moment, but we can enable it for individual files. Still todo: - The cursor keeps moving back to the start of a field when typing - we need to ignore the refresh hook when we are the initiator. - The busy cursor icon should probably be delayed a few hundreds ms. - Still need to think about a nicer way of handling saveNow() - op_made_changes(), op_affects_study_queue() might be better embedded as properties in the object instead
This commit is contained in:
parent
30c7cf1fdd
commit
6b0fe4b381
57 changed files with 918 additions and 619 deletions
|
@ -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,17 +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
|
||||
OperationInfo = _pb.OperationInfo
|
||||
DefaultsForAdding = _pb.DeckAndNotetype
|
||||
|
||||
|
||||
@dataclass
|
||||
|
@ -323,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
|
||||
|
@ -361,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():
|
||||
|
@ -440,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()
|
||||
|
@ -531,14 +540,23 @@ 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 "",
|
||||
)
|
||||
|
||||
# returns array of ("dupestr", [nids])
|
||||
def findDupes(self, fieldName: str, search: str = "") -> List[Tuple[Any, list]]:
|
||||
|
@ -810,10 +828,17 @@ table.review-log {{ {revlog_style} }}
|
|||
assert_exhaustive(self._undo)
|
||||
assert False
|
||||
|
||||
def op_affects_study_queue(self, op: OperationInfo) -> bool:
|
||||
if op.kind == op.SET_CARD_FLAG:
|
||||
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
|
||||
return op.changes.card or op.changes.deck or op.changes.preference
|
||||
|
||||
def _check_backend_undo_status(self) -> Optional[UndoStatus]:
|
||||
"""Return undo status if undo available on backend.
|
||||
|
@ -986,8 +1011,8 @@ table.review-log {{ {revlog_style} }}
|
|||
|
||||
##########################################################################
|
||||
|
||||
def set_user_flag_for_cards(self, flag: int, cids: List[int]) -> None:
|
||||
self._backend.set_flag(card_ids=cids, flag=flag)
|
||||
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()
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -37,14 +37,15 @@ 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]:
|
||||
|
|
|
@ -178,4 +178,5 @@ def _errMsg(col: anki.collection.Collection, type: str, texpath: str) -> Any:
|
|||
return msg
|
||||
|
||||
|
||||
def setup_hook() -> None:
|
||||
hooks.card_did_render.append(on_card_did_render)
|
||||
|
|
|
@ -5,6 +5,7 @@ from __future__ import annotations
|
|||
|
||||
import anki
|
||||
import anki._backend.backend_pb2 as _pb
|
||||
from anki.collection import OpChanges
|
||||
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."
|
||||
|
|
|
@ -18,6 +18,7 @@ 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
|
||||
|
@ -75,27 +76,27 @@ class TagManager:
|
|||
# Bulk addition/removal from notes
|
||||
#############################################################
|
||||
|
||||
def bulk_add(self, nids: List[int], tags: str) -> int:
|
||||
def bulk_add(self, nids: 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)
|
||||
|
||||
def bulk_update(
|
||||
self, nids: Sequence[int], tags: str, replacement: str, regex: bool
|
||||
) -> int:
|
||||
) -> OpChangesWithCount:
|
||||
"""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, nids: Sequence[int], tags: str) -> int:
|
||||
def bulk_remove(self, nids: Sequence[int], tags: str) -> OpChangesWithCount:
|
||||
return self.bulk_update(nids, tags, "", False)
|
||||
|
||||
def rename(self, old: str, new: str) -> int:
|
||||
def rename(self, old: str, new: str) -> OpChangesWithCount:
|
||||
"Rename provided tag, returning number of changed notes."
|
||||
nids = self.col.find_notes(anki.collection.SearchNode(tag=old))
|
||||
if not nids:
|
||||
return 0
|
||||
return OpChangesWithCount()
|
||||
escaped_name = re.sub(r"[*_\\]", r"\\\g<0>", old)
|
||||
return self.bulk_update(nids, escaped_name, new, False)
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
||||
|
|
|
@ -8,7 +8,7 @@ ignored-modules=win32file,pywintypes,socket,win32pipe,winrt,pyaudio
|
|||
ignored-classes=
|
||||
SearchNode,
|
||||
Config,
|
||||
OperationInfo
|
||||
OpChanges
|
||||
|
||||
[REPORTS]
|
||||
output-format=colorized
|
||||
|
|
|
@ -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
|
||||
|
@ -191,13 +191,12 @@ class AddCards(QDialog):
|
|||
return
|
||||
|
||||
target_deck_id = self.deck_chooser.selected_deck_id
|
||||
self.mw.col.add_note(note, target_deck_id)
|
||||
|
||||
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)
|
||||
|
||||
# workaround for PyQt focus bug
|
||||
self.editor.hideCompleters()
|
||||
|
@ -205,10 +204,12 @@ class AddCards(QDialog):
|
|||
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:
|
||||
|
||||
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()
|
||||
if result == DuplicateOrEmptyResult.EMPTY:
|
||||
|
|
|
@ -12,7 +12,7 @@ from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union,
|
|||
import aqt
|
||||
import aqt.forms
|
||||
from anki.cards import Card
|
||||
from anki.collection import Collection, Config, OperationInfo, SearchNode
|
||||
from anki.collection import Collection, Config, OpChanges, SearchNode
|
||||
from anki.consts import *
|
||||
from anki.errors import InvalidInput, NotFoundError
|
||||
from anki.lang import without_unicode_isolation
|
||||
|
@ -21,13 +21,20 @@ from anki.notes import Note
|
|||
from anki.stats import CardStats
|
||||
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.main import ResetReason
|
||||
from aqt.note_ops import add_tags, find_and_replace, remove_notes, remove_tags
|
||||
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.scheduling_ops import (
|
||||
forget_cards,
|
||||
set_due_date_dialog,
|
||||
suspend_cards,
|
||||
unsuspend_cards,
|
||||
)
|
||||
from aqt.sidebar import SidebarSearchBar, SidebarToolbar, SidebarTreeView
|
||||
from aqt.theme import theme_manager
|
||||
from aqt.utils import (
|
||||
|
@ -284,8 +291,8 @@ class DataModel(QAbstractTableModel):
|
|||
else:
|
||||
tv.selectRow(0)
|
||||
|
||||
def op_executed(self, op: OperationInfo, focused: bool) -> None:
|
||||
if op.changes.card or op.changes.note or op.changes.deck or op.changes.notetype:
|
||||
def op_executed(self, op: OpChanges, focused: bool) -> None:
|
||||
if op.card or op.note or op.deck or op.notetype:
|
||||
self.refresh_needed = True
|
||||
if focused:
|
||||
self.refresh_if_needed()
|
||||
|
@ -497,9 +504,9 @@ class Browser(QMainWindow):
|
|||
# as that will block the UI
|
||||
self.setUpdatesEnabled(False)
|
||||
|
||||
def on_operation_did_execute(self, op: OperationInfo) -> None:
|
||||
def on_operation_did_execute(self, changes: OpChanges) -> None:
|
||||
self.setUpdatesEnabled(True)
|
||||
self.model.op_executed(op, current_top_level_widget() == self)
|
||||
self.model.op_executed(changes, current_top_level_widget() == self)
|
||||
|
||||
def on_focus_change(self, new: Optional[QWidget], old: Optional[QWidget]) -> None:
|
||||
if current_top_level_widget() == self:
|
||||
|
@ -1167,8 +1174,9 @@ where id in %s"""
|
|||
# select the next card if there is one
|
||||
self._onNextCard()
|
||||
|
||||
self.mw.perform_op(
|
||||
lambda: self.col.remove_notes(nids),
|
||||
remove_notes(
|
||||
mw=self.mw,
|
||||
note_ids=nids,
|
||||
success=lambda _: tooltip(tr(TR.BROWSING_NOTE_DELETED, count=len(nids))),
|
||||
)
|
||||
|
||||
|
@ -1200,7 +1208,7 @@ where id in %s"""
|
|||
return
|
||||
did = self.col.decks.id(ret.name)
|
||||
|
||||
self.mw.perform_op(lambda: self.col.set_deck(cids, did))
|
||||
set_card_deck(mw=self.mw, card_ids=cids, deck_id=did)
|
||||
|
||||
# legacy
|
||||
|
||||
|
@ -1214,38 +1222,43 @@ where id in %s"""
|
|||
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),
|
||||
)
|
||||
|
||||
def op() -> None:
|
||||
if not (
|
||||
tags2 := self.maybe_prompt_for_tags(
|
||||
tags, tr(TR.BROWSING_ENTER_TAGS_TO_ADD)
|
||||
)
|
||||
):
|
||||
return
|
||||
nids = self.selectedNotes()
|
||||
add_tags(mw=self.mw, note_ids=nids, space_separated_tags=tags2)
|
||||
|
||||
self.editor.saveNow(op)
|
||||
|
||||
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),
|
||||
)
|
||||
)
|
||||
|
||||
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:
|
||||
def op() -> None:
|
||||
if not (
|
||||
tags2 := self.maybe_prompt_for_tags(
|
||||
tags, tr(TR.BROWSING_ENTER_TAGS_TO_DELETE)
|
||||
)
|
||||
):
|
||||
return
|
||||
nids = self.selectedNotes()
|
||||
remove_tags(mw=self.mw, note_ids=nids, space_separated_tags=tags2)
|
||||
|
||||
self.editor.saveNow(op)
|
||||
|
||||
def _maybe_prompt_for_tags(self, tags: Optional[str], prompt: str) -> Optional[str]:
|
||||
if tags is not None:
|
||||
return tags
|
||||
|
||||
(tags, ok) = getTag(self, self.col, prompt)
|
||||
if not ok:
|
||||
return
|
||||
|
||||
nids = self.selectedNotes()
|
||||
self.mw.perform_op(lambda: func(nids, tags))
|
||||
return None
|
||||
else:
|
||||
return tags
|
||||
|
||||
def clearUnusedTags(self) -> None:
|
||||
self.editor.saveNow(self._clearUnusedTags)
|
||||
|
@ -1271,15 +1284,12 @@ where id in %s"""
|
|||
|
||||
def _suspend_selected_cards(self) -> None:
|
||||
want_suspend = not self.current_card_is_suspended()
|
||||
|
||||
def op() -> None:
|
||||
if want_suspend:
|
||||
self.col.sched.suspend_cards(cids)
|
||||
else:
|
||||
self.col.sched.unsuspend_cards(cids)
|
||||
|
||||
cids = self.selectedCards()
|
||||
self.mw.perform_op(op)
|
||||
|
||||
if want_suspend:
|
||||
suspend_cards(mw=self.mw, card_ids=cids)
|
||||
else:
|
||||
unsuspend_cards(mw=self.mw, card_ids=cids)
|
||||
|
||||
# Exporting
|
||||
######################################################################
|
||||
|
@ -1297,13 +1307,13 @@ where id in %s"""
|
|||
return
|
||||
self.editor.saveNow(lambda: self._on_set_flag(n))
|
||||
|
||||
def _on_set_flag(self, n: int) -> None:
|
||||
def _on_set_flag(self, flag: int) -> None:
|
||||
# flag needs toggling off?
|
||||
if n == self.card.user_flag():
|
||||
n = 0
|
||||
if flag == self.card.user_flag():
|
||||
flag = 0
|
||||
|
||||
cids = self.selectedCards()
|
||||
self.mw.perform_op(lambda: self.col.set_user_flag_for_cards(n, cids))
|
||||
set_card_flag(mw=self.mw, card_ids=cids, flag=flag)
|
||||
|
||||
def _updateFlagsMenu(self) -> None:
|
||||
flag = self.card and self.card.user_flag()
|
||||
|
@ -1531,39 +1541,22 @@ where id in %s"""
|
|||
replace = save_combo_history(frm.replace, replacehistory, combo + "Replace")
|
||||
|
||||
regex = frm.re.isChecked()
|
||||
nocase = frm.ignoreCase.isChecked()
|
||||
match_case = not 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),
|
||||
find_and_replace(
|
||||
mw=self.mw,
|
||||
parent=self,
|
||||
note_ids=nids,
|
||||
search=search,
|
||||
replacement=replace,
|
||||
regex=regex,
|
||||
field_name=field,
|
||||
match_case=match_case,
|
||||
)
|
||||
|
||||
self.mw.taskman.run_in_background(do_search, on_done)
|
||||
|
||||
def onFindReplaceHelp(self) -> None:
|
||||
openHelp(HelpPage.BROWSING_FIND_AND_REPLACE)
|
||||
|
||||
|
|
16
qt/aqt/card_ops.py
Normal file
16
qt/aqt/card_ops.py
Normal file
|
@ -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))
|
24
qt/aqt/deck_ops.py
Normal file
24
qt/aqt/deck_ops.py
Normal file
|
@ -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, QDialog
|
||||
from aqt.utils import tooltip, tr
|
||||
|
||||
|
||||
def remove_decks(
|
||||
*,
|
||||
mw: AnkiQt,
|
||||
parent: QDialog,
|
||||
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
|
||||
),
|
||||
)
|
|
@ -8,11 +8,12 @@ from dataclasses import dataclass
|
|||
from typing import Any
|
||||
|
||||
import aqt
|
||||
from anki.collection import OperationInfo
|
||||
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
|
||||
|
@ -24,7 +25,6 @@ from aqt.utils import (
|
|||
shortcut,
|
||||
showInfo,
|
||||
showWarning,
|
||||
tooltip,
|
||||
tr,
|
||||
)
|
||||
|
||||
|
@ -80,8 +80,8 @@ class DeckBrowser:
|
|||
if self._refresh_needed:
|
||||
self.refresh()
|
||||
|
||||
def op_executed(self, op: OperationInfo, focused: bool) -> bool:
|
||||
if self.mw.col.op_affects_study_queue(op):
|
||||
def op_executed(self, changes: OpChanges, focused: bool) -> bool:
|
||||
if self.mw.col.op_affects_study_queue(changes):
|
||||
self._refresh_needed = True
|
||||
|
||||
if focused:
|
||||
|
@ -322,16 +322,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.reset()
|
||||
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
|
||||
######################################################################
|
||||
|
|
|
@ -27,6 +27,7 @@ from anki.httpclient import HttpClient
|
|||
from anki.notes import Note
|
||||
from anki.utils import checksum, isLin, isWin, namedtmp
|
||||
from aqt import AnkiQt, colors, gui_hooks
|
||||
from aqt.note_ops import update_note
|
||||
from aqt.qt import *
|
||||
from aqt.sound import av_player
|
||||
from aqt.theme import theme_manager
|
||||
|
@ -542,8 +543,7 @@ class Editor:
|
|||
|
||||
def _save_current_note(self) -> None:
|
||||
"Call after note is updated with data from webview."
|
||||
note = self.note
|
||||
self.mw.perform_op(lambda: self.mw.col.update_note(note))
|
||||
update_note(mw=self.mw, note=self.note)
|
||||
|
||||
def fonts(self) -> List[Tuple[str, int, bool]]:
|
||||
return [
|
||||
|
|
|
@ -21,6 +21,7 @@ from typing import (
|
|||
List,
|
||||
Literal,
|
||||
Optional,
|
||||
Protocol,
|
||||
Sequence,
|
||||
TextIO,
|
||||
Tuple,
|
||||
|
@ -44,7 +45,8 @@ from anki.collection import (
|
|||
Checkpoint,
|
||||
Collection,
|
||||
Config,
|
||||
OperationInfo,
|
||||
OpChanges,
|
||||
OpChangesWithCount,
|
||||
ReviewUndo,
|
||||
UndoResult,
|
||||
UndoStatus,
|
||||
|
@ -90,7 +92,19 @@ from aqt.utils import (
|
|||
tr,
|
||||
)
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
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()
|
||||
|
||||
|
@ -704,13 +718,17 @@ class AnkiQt(QMainWindow):
|
|||
|
||||
def perform_op(
|
||||
self,
|
||||
op: Callable[[], T],
|
||||
op: Callable[[], ResultWithChanges],
|
||||
*,
|
||||
success: Optional[Callable[[T], None]] = None,
|
||||
failure: Optional[Callable[[BaseException], None]] = None,
|
||||
success: PerformOpOptionalSuccessCallback = None,
|
||||
failure: Optional[Callable[[Exception], Any]] = 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
|
||||
|
@ -731,42 +749,62 @@ class AnkiQt(QMainWindow):
|
|||
gui_hooks.operation_will_execute()
|
||||
|
||||
def wrapped_done(future: Future) -> None:
|
||||
try:
|
||||
# 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()
|
||||
try:
|
||||
result = future.result()
|
||||
if success:
|
||||
success(future.result())
|
||||
success(result)
|
||||
finally:
|
||||
# update undo status
|
||||
status = self.col.undo_status()
|
||||
self._update_undo_actions_for_status_and_save(status)
|
||||
print("last op", status.last_op)
|
||||
gui_hooks.operation_did_execute(status.last_op)
|
||||
# fire legacy hook so old code notices changes
|
||||
gui_hooks.state_did_reset()
|
||||
# fire change hooks
|
||||
self._fire_change_hooks_after_op_performed(result)
|
||||
|
||||
self.taskman.with_progress(op, wrapped_done)
|
||||
|
||||
def _fire_change_hooks_after_op_performed(self, result: ResultWithChanges) -> 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()
|
||||
|
||||
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 = OperationInfo()
|
||||
for field in op.changes.DESCRIPTOR.fields:
|
||||
setattr(op.changes, field.name, True)
|
||||
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, op: OperationInfo) -> None:
|
||||
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(op, focused)
|
||||
dirty = self.reviewer.op_executed(changes, focused)
|
||||
elif self.state == "overview":
|
||||
dirty = self.overview.op_executed(op, focused)
|
||||
dirty = self.overview.op_executed(changes, focused)
|
||||
elif self.state == "deckBrowser":
|
||||
dirty = self.deckBrowser.op_executed(op, focused)
|
||||
dirty = self.deckBrowser.op_executed(changes, focused)
|
||||
else:
|
||||
dirty = False
|
||||
|
||||
|
|
74
qt/aqt/note_ops.py
Normal file
74
qt/aqt/note_ops.py
Normal file
|
@ -0,0 +1,74 @@
|
|||
# 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 Optional, Sequence
|
||||
|
||||
from anki.lang import TR
|
||||
from anki.notes import Note
|
||||
from aqt import AnkiQt
|
||||
from aqt.main import PerformOpOptionalSuccessCallback
|
||||
from aqt.qt import QDialog
|
||||
from aqt.utils import show_invalid_search_error, showInfo, tr
|
||||
|
||||
|
||||
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) -> None:
|
||||
mw.perform_op(lambda: mw.col.update_note(note))
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def add_tags(*, mw: AnkiQt, note_ids: Sequence[int], space_separated_tags: str) -> None:
|
||||
mw.perform_op(lambda: mw.col.tags.bulk_add(note_ids, space_separated_tags))
|
||||
|
||||
|
||||
def remove_tags(
|
||||
*, mw: AnkiQt, note_ids: Sequence[int], space_separated_tags: str
|
||||
) -> None:
|
||||
mw.perform_op(lambda: mw.col.tags.bulk_remove(note_ids, space_separated_tags))
|
||||
|
||||
|
||||
def find_and_replace(
|
||||
*,
|
||||
mw: AnkiQt,
|
||||
parent: QDialog,
|
||||
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: showInfo(
|
||||
tr(TR.FINDREPLACE_NOTES_UPDATED, changed=out.count, total=len(note_ids)),
|
||||
parent=parent,
|
||||
),
|
||||
failure=lambda exc: show_invalid_search_error(exc, parent=parent),
|
||||
)
|
|
@ -6,7 +6,7 @@ from dataclasses import dataclass
|
|||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
|
||||
import aqt
|
||||
from anki.collection import OperationInfo
|
||||
from anki.collection import OpChanges
|
||||
from aqt import gui_hooks
|
||||
from aqt.sound import av_player
|
||||
from aqt.toolbar import BottomBar
|
||||
|
@ -63,8 +63,8 @@ class Overview:
|
|||
if self._refresh_needed:
|
||||
self.refresh()
|
||||
|
||||
def op_executed(self, op: OperationInfo, focused: bool) -> bool:
|
||||
if self.mw.col.op_affects_study_queue(op):
|
||||
def op_executed(self, changes: OpChanges, focused: bool) -> bool:
|
||||
if self.mw.col.op_affects_study_queue(changes):
|
||||
self._refresh_needed = True
|
||||
|
||||
if focused:
|
||||
|
|
|
@ -13,12 +13,20 @@ from PyQt5.QtCore import Qt
|
|||
|
||||
from anki import hooks
|
||||
from anki.cards import Card
|
||||
from anki.collection import Config, OperationInfo
|
||||
from anki.collection import Config, OpChanges
|
||||
from anki.utils import stripHTML
|
||||
from aqt import AnkiQt, gui_hooks
|
||||
from aqt.card_ops import set_card_flag
|
||||
from aqt.note_ops import add_tags, remove_notes, remove_tags
|
||||
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.theme import theme_manager
|
||||
from aqt.toolbar import BottomBar
|
||||
|
@ -94,20 +102,19 @@ class Reviewer:
|
|||
self._refresh_needed = False
|
||||
self.mw.fade_in_webview()
|
||||
|
||||
def op_executed(self, op: OperationInfo, focused: bool) -> bool:
|
||||
|
||||
if op.kind == OperationInfo.UPDATE_NOTE_TAGS:
|
||||
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 op.kind == OperationInfo.SET_CARD_FLAG:
|
||||
elif changes.card and changes.kind == OpChanges.SET_CARD_FLAG:
|
||||
# fixme: v3 mtime check
|
||||
self.card.load()
|
||||
self._update_flag_icon()
|
||||
elif op.kind == OperationInfo.UPDATE_NOTE:
|
||||
elif changes.note and changes.kind == OpChanges.UPDATE_NOTE:
|
||||
self._redraw_current_card()
|
||||
elif self.mw.col.op_affects_study_queue(op):
|
||||
elif self.mw.col.op_affects_study_queue(changes):
|
||||
self._refresh_needed = True
|
||||
elif op.changes.note or op.changes.notetype or op.changes.tag:
|
||||
elif changes.note or changes.notetype or changes.tag:
|
||||
self._redraw_current_card()
|
||||
|
||||
if focused and self._refresh_needed:
|
||||
|
@ -819,26 +826,21 @@ time = %(time)d;
|
|||
self.mw.onDeckConf(self.mw.col.decks.get(self.card.odid or self.card.did))
|
||||
|
||||
def set_flag_on_current_card(self, desired_flag: int) -> None:
|
||||
def op() -> None:
|
||||
# need to toggle off?
|
||||
if self.card.user_flag() == desired_flag:
|
||||
flag = 0
|
||||
else:
|
||||
flag = desired_flag
|
||||
self.mw.col.set_user_flag_for_cards(flag, [self.card.id])
|
||||
|
||||
self.mw.perform_op(op)
|
||||
set_card_flag(mw=self.mw, card_ids=[self.card.id], flag=flag)
|
||||
|
||||
def toggle_mark_on_current_note(self) -> None:
|
||||
def op() -> None:
|
||||
tag = "marked"
|
||||
note = self.card.note()
|
||||
if note.has_tag(tag):
|
||||
self.mw.col.tags.bulk_remove([note.id], tag)
|
||||
remove_tags(mw=self.mw, note_ids=[note.id], space_separated_tags=tag)
|
||||
else:
|
||||
self.mw.col.tags.bulk_add([note.id], tag)
|
||||
|
||||
self.mw.perform_op(op)
|
||||
add_tags(mw=self.mw, note_ids=[note.id], space_separated_tags=tag)
|
||||
|
||||
def on_set_due(self) -> None:
|
||||
if self.mw.state != "review" or not self.card:
|
||||
|
@ -852,29 +854,31 @@ time = %(time)d;
|
|||
)
|
||||
|
||||
def suspend_current_note(self) -> None:
|
||||
self.mw.perform_op(
|
||||
lambda: self.mw.col.sched.suspend_cards(
|
||||
[c.id for c in self.card.note().cards()]
|
||||
),
|
||||
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.perform_op(
|
||||
lambda: self.mw.col.sched.suspend_cards([self.card.id]),
|
||||
suspend_cards(
|
||||
mw=self.mw,
|
||||
card_ids=[self.card.id],
|
||||
success=lambda _: tooltip(tr(TR.STUDYING_CARD_SUSPENDED)),
|
||||
)
|
||||
|
||||
def bury_current_card(self) -> None:
|
||||
self.mw.perform_op(
|
||||
lambda: self.mw.col.sched.bury_cards([self.card.id]),
|
||||
success=lambda _: tooltip(tr(TR.STUDYING_CARD_BURIED)),
|
||||
def bury_current_note(self) -> None:
|
||||
bury_note(
|
||||
mw=self.mw,
|
||||
note_id=self.card.nid,
|
||||
success=lambda _: tooltip(tr(TR.STUDYING_NOTE_BURIED)),
|
||||
)
|
||||
|
||||
def bury_current_note(self) -> None:
|
||||
self.mw.perform_op(
|
||||
lambda: self.mw.col.sched.bury_note(self.card.note()),
|
||||
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:
|
||||
|
@ -882,10 +886,13 @@ time = %(time)d;
|
|||
# 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.perform_op(
|
||||
lambda: self.mw.col.remove_notes([self.card.note().id]),
|
||||
remove_notes(
|
||||
mw=self.mw,
|
||||
note_ids=[self.card.nid],
|
||||
success=lambda _: tooltip(
|
||||
tr(TR.STUDYING_NOTE_AND_ITS_CARD_DELETED, count=cnt)
|
||||
),
|
||||
|
|
|
@ -3,11 +3,13 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional
|
||||
from typing import List, Optional, Sequence
|
||||
|
||||
import aqt
|
||||
from anki.collection import Config
|
||||
from anki.lang import TR
|
||||
from aqt import AnkiQt
|
||||
from aqt.main import PerformOpOptionalSuccessCallback
|
||||
from aqt.qt import *
|
||||
from aqt.utils import getText, tooltip, tr
|
||||
|
||||
|
@ -59,3 +61,49 @@ def forget_cards(*, mw: aqt.AnkiQt, parent: QDialog, card_ids: List[int]) -> Non
|
|||
tr(TR.SCHEDULING_FORGOT_CARDS, cards=len(card_ids)), 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),
|
||||
)
|
|
@ -16,6 +16,7 @@ from anki.tags import TagTreeNode
|
|||
from anki.types import assert_exhaustive
|
||||
from aqt import colors, gui_hooks
|
||||
from aqt.clayout import CardLayout
|
||||
from aqt.deck_ops import remove_decks
|
||||
from aqt.main import ResetReason
|
||||
from aqt.models import Models
|
||||
from aqt.qt import *
|
||||
|
@ -1166,22 +1167,7 @@ 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
|
||||
###########################
|
||||
|
@ -1218,7 +1204,7 @@ class SidebarTreeView(QTreeView):
|
|||
|
||||
def do_rename() -> int:
|
||||
self.mw.col.tags.remove(old_name)
|
||||
return self.col.tags.rename(old_name, new_name)
|
||||
return self.col.tags.rename(old_name, new_name).count
|
||||
|
||||
def on_done(fut: Future) -> None:
|
||||
self.setUpdatesEnabled(True)
|
||||
|
|
|
@ -138,12 +138,12 @@ 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[QDialog] = 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(
|
||||
|
|
|
@ -9,6 +9,15 @@ 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.winpaths]
|
||||
disallow_untyped_defs=false
|
||||
[mypy-aqt.mpv]
|
||||
|
|
|
@ -408,7 +408,7 @@ hooks = [
|
|||
Hook(
|
||||
name="operation_did_execute",
|
||||
args=[
|
||||
"op: anki.collection.OperationInfo",
|
||||
"changes: anki.collection.OpChanges",
|
||||
],
|
||||
doc="""Called after an operation completes.
|
||||
Changes can be inspected to determine whether the UI needs updating.
|
||||
|
|
|
@ -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 ScheduleCardsAsNew(ScheduleCardsAsNewIn) returns (OpChanges);
|
||||
rpc SetDueDate(SetDueDateIn) returns (OpChanges);
|
||||
rpc SortCards(SortCardsIn) returns (Empty);
|
||||
rpc SortDeck(SortDeckIn) returns (Empty);
|
||||
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,23 @@ 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 AddNoteTags(AddNoteTagsIn) returns (OpChangesWithCount);
|
||||
rpc UpdateNoteTags(UpdateNoteTagsIn) returns (OpChangesWithCount);
|
||||
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 +184,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 {
|
||||
|
@ -227,7 +232,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,10 +269,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 SetFlag(SetFlagIn) returns (Empty);
|
||||
rpc SetDeck(SetDeckIn) returns (OpChanges);
|
||||
rpc SetFlag(SetFlagIn) returns (OpChanges);
|
||||
}
|
||||
|
||||
// Protobuf stored in .anki2 files
|
||||
|
@ -971,6 +976,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;
|
||||
|
@ -1443,15 +1453,7 @@ message GetQueuedCardsOut {
|
|||
}
|
||||
}
|
||||
|
||||
message OperationInfo {
|
||||
message Changes {
|
||||
bool card = 1;
|
||||
bool note = 2;
|
||||
bool deck = 3;
|
||||
bool tag = 4;
|
||||
bool notetype = 5;
|
||||
bool preference = 6;
|
||||
}
|
||||
message OpChanges {
|
||||
// this is not an exhaustive list; we can add more cases as we need them
|
||||
enum Kind {
|
||||
OTHER = 0;
|
||||
|
@ -1461,13 +1463,17 @@ message OperationInfo {
|
|||
}
|
||||
|
||||
Kind kind = 1;
|
||||
Changes changes = 2;
|
||||
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;
|
||||
OperationInfo last_op = 3;
|
||||
}
|
||||
|
||||
message DefaultsForAddingIn {
|
||||
|
|
|
@ -21,22 +21,17 @@ impl CardsService for Backend {
|
|||
})
|
||||
}
|
||||
|
||||
fn update_card(&self, input: pb::UpdateCardIn) -> Result<pb::Empty> {
|
||||
fn update_card(&self, input: pb::UpdateCardIn) -> Result<pb::OpChanges> {
|
||||
self.with_col(|col| {
|
||||
let op = if input.skip_undo_entry {
|
||||
None
|
||||
} else {
|
||||
Some(Op::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<pb::Empty> {
|
||||
self.with_col(|col| {
|
||||
col.transact(None, |col| {
|
||||
col.transact_no_undo(|col| {
|
||||
col.remove_cards_and_orphaned_notes(
|
||||
&input
|
||||
.card_ids
|
||||
|
@ -49,13 +44,13 @@ impl CardsService for Backend {
|
|||
})
|
||||
}
|
||||
|
||||
fn set_deck(&self, input: pb::SetDeckIn) -> Result<pb::Empty> {
|
||||
fn set_deck(&self, input: pb::SetDeckIn) -> Result<pb::OpChanges> {
|
||||
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<pb::Empty> {
|
||||
fn set_flag(&self, input: pb::SetFlagIn) -> Result<pb::OpChanges> {
|
||||
self.with_col(|col| {
|
||||
col.set_card_flag(&to_card_ids(input.card_ids), input.flag)
|
||||
.map(Into::into)
|
||||
|
|
|
@ -61,7 +61,7 @@ impl ConfigService for Backend {
|
|||
|
||||
fn set_config_json(&self, input: pb::SetConfigJsonIn) -> Result<pb::Empty> {
|
||||
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<pb::Empty> {
|
||||
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,7 +92,9 @@ impl ConfigService for Backend {
|
|||
}
|
||||
|
||||
fn set_config_bool(&self, input: pb::SetConfigBoolIn) -> Result<pb::Empty> {
|
||||
self.with_col(|col| col.transact(None, |col| col.set_bool(input.key().into(), input.value)))
|
||||
self.with_col(|col| {
|
||||
col.transact_no_undo(|col| col.set_bool(input.key().into(), input.value))
|
||||
})
|
||||
.map(Into::into)
|
||||
}
|
||||
|
||||
|
@ -106,7 +108,7 @@ impl ConfigService for Backend {
|
|||
|
||||
fn set_config_string(&self, input: pb::SetConfigStringIn) -> Result<pb::Empty> {
|
||||
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<pb::Empty> {
|
||||
fn set_preferences(&self, input: pb::Preferences) -> Result<pb::OpChanges> {
|
||||
self.with_col(|col| col.set_preferences(input))
|
||||
.map(Into::into)
|
||||
}
|
||||
|
|
|
@ -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<pb::Empty> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<pb::UInt32> {
|
||||
fn remove_decks(&self, input: pb::DeckIDs) -> Result<pb::OpChangesWithCount> {
|
||||
self.with_col(|col| col.remove_decks_and_child_decks(&Into::<Vec<DeckID>>::into(input)))
|
||||
.map(Into::into)
|
||||
}
|
||||
|
||||
fn drag_drop_decks(&self, input: pb::DragDropDecksIn) -> Result<pb::Empty> {
|
||||
fn drag_drop_decks(&self, input: pb::DragDropDecksIn) -> Result<pb::OpChanges> {
|
||||
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<pb::Empty> {
|
||||
fn rename_deck(&self, input: pb::RenameDeckIn) -> Result<pb::OpChanges> {
|
||||
self.with_col(|col| col.rename_deck(input.deck_id.into(), &input.new_name))
|
||||
.map(Into::into)
|
||||
}
|
||||
|
|
|
@ -80,3 +80,12 @@ impl From<Vec<String>> for pb::StringList {
|
|||
pb::StringList { vals }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<OpOutput<usize>> for pb::OpChangesWithCount {
|
||||
fn from(out: OpOutput<usize>) -> Self {
|
||||
pb::OpChangesWithCount {
|
||||
count: out.output as u32,
|
||||
changes: Some(out.changes.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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<pb::Note> {
|
||||
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<pb::NoteId> {
|
||||
fn add_note(&self, input: pb::AddNoteIn) -> Result<pb::AddNoteOut> {
|
||||
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<pb::Empty> {
|
||||
fn update_note(&self, input: pb::UpdateNoteIn) -> Result<pb::OpChanges> {
|
||||
self.with_col(|col| {
|
||||
let op = if input.skip_undo_entry {
|
||||
None
|
||||
} else {
|
||||
Some(Op::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<pb::Empty> {
|
||||
fn remove_notes(&self, input: pb::RemoveNotesIn) -> Result<pb::OpChanges> {
|
||||
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::<Vec<_>>(),
|
||||
)?;
|
||||
}
|
||||
if !input.card_ids.is_empty() {
|
||||
)
|
||||
} else {
|
||||
let nids = col.storage.note_ids_of_cards(
|
||||
&input
|
||||
.card_ids
|
||||
|
@ -87,21 +81,20 @@ impl NotesService for Backend {
|
|||
.map(Into::into)
|
||||
.collect::<Vec<_>>(),
|
||||
)?;
|
||||
col.remove_notes(&nids.into_iter().collect::<Vec<_>>())?
|
||||
col.remove_notes(&nids.into_iter().collect::<Vec<_>>())
|
||||
}
|
||||
Ok(().into())
|
||||
.map(Into::into)
|
||||
})
|
||||
}
|
||||
|
||||
fn add_note_tags(&self, input: pb::AddNoteTagsIn) -> Result<pb::UInt32> {
|
||||
fn add_note_tags(&self, input: pb::AddNoteTagsIn) -> Result<pb::OpChangesWithCount> {
|
||||
self.with_col(|col| {
|
||||
col.add_tags_to_notes(&to_note_ids(input.nids), &input.tags)
|
||||
.map(|n| n as u32)
|
||||
})
|
||||
.map(Into::into)
|
||||
})
|
||||
}
|
||||
|
||||
fn update_note_tags(&self, input: pb::UpdateNoteTagsIn) -> Result<pb::UInt32> {
|
||||
fn update_note_tags(&self, input: pb::UpdateNoteTagsIn) -> Result<pb::OpChangesWithCount> {
|
||||
self.with_col(|col| {
|
||||
col.replace_tags_for_notes(
|
||||
&to_note_ids(input.nids),
|
||||
|
@ -109,7 +102,7 @@ impl NotesService for Backend {
|
|||
&input.replacement,
|
||||
input.regex,
|
||||
)
|
||||
.map(|n| (n as u32).into())
|
||||
.map(Into::into)
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -123,16 +116,14 @@ impl NotesService for Backend {
|
|||
})
|
||||
}
|
||||
|
||||
fn after_note_updates(&self, input: pb::AfterNoteUpdatesIn) -> Result<pb::Empty> {
|
||||
fn after_note_updates(&self, input: pb::AfterNoteUpdatesIn) -> Result<pb::OpChanges> {
|
||||
self.with_col(|col| {
|
||||
col.transact(None, |col| {
|
||||
col.after_note_updates(
|
||||
&to_note_ids(input.nids),
|
||||
input.generate_cards,
|
||||
input.mark_notes_modified,
|
||||
)?;
|
||||
Ok(pb::Empty {})
|
||||
})
|
||||
)
|
||||
.map(Into::into)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -1,22 +1,9 @@
|
|||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
use pb::operation_info::{Changes, Kind};
|
||||
use pb::op_changes::Kind;
|
||||
|
||||
use crate::{backend_proto as pb, ops::StateChanges, prelude::*, undo::UndoStatus};
|
||||
|
||||
impl From<StateChanges> for Changes {
|
||||
fn from(c: StateChanges) -> Self {
|
||||
Changes {
|
||||
card: c.card,
|
||||
note: c.note,
|
||||
deck: c.deck,
|
||||
tag: c.tag,
|
||||
notetype: c.notetype,
|
||||
preference: c.preference,
|
||||
}
|
||||
}
|
||||
}
|
||||
use crate::{backend_proto as pb, ops::OpChanges, prelude::*, undo::UndoStatus};
|
||||
|
||||
impl From<Op> for Kind {
|
||||
fn from(o: Op) -> Self {
|
||||
|
@ -29,11 +16,16 @@ impl From<Op> for Kind {
|
|||
}
|
||||
}
|
||||
|
||||
impl From<Op> for pb::OperationInfo {
|
||||
fn from(op: Op) -> Self {
|
||||
pb::OperationInfo {
|
||||
changes: Some(op.state_changes().into()),
|
||||
kind: Kind::from(op) as i32,
|
||||
impl From<OpChanges> 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -43,7 +35,12 @@ impl 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(),
|
||||
last_op: self.undo.map(Into::into),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<OpOutput<()>> for pb::OpChanges {
|
||||
fn from(o: OpOutput<()>) -> Self {
|
||||
o.changes.into()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -39,7 +39,7 @@ impl SchedulingService for Backend {
|
|||
|
||||
fn update_stats(&self, input: pb::UpdateStatsIn) -> Result<pb::Empty> {
|
||||
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<pb::Empty> {
|
||||
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<pb::Empty> {
|
||||
fn restore_buried_and_suspended_cards(&self, input: pb::CardIDs) -> Result<pb::OpChanges> {
|
||||
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<pb::Empty> {
|
||||
fn bury_or_suspend_cards(&self, input: pb::BuryOrSuspendCardsIn) -> Result<pb::OpChanges> {
|
||||
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<pb::Empty> {
|
||||
fn schedule_cards_as_new(&self, input: pb::ScheduleCardsAsNewIn) -> Result<pb::OpChanges> {
|
||||
self.with_col(|col| {
|
||||
let cids: Vec<_> = input.card_ids.into_iter().map(CardID).collect();
|
||||
let log = input.log;
|
||||
|
@ -111,7 +111,7 @@ impl SchedulingService for Backend {
|
|||
})
|
||||
}
|
||||
|
||||
fn set_due_date(&self, input: pb::SetDueDateIn) -> Result<pb::Empty> {
|
||||
fn set_due_date(&self, input: pb::SetDueDateIn) -> Result<pb::OpChanges> {
|
||||
let config = input.config_key.map(Into::into);
|
||||
let days = input.days;
|
||||
let cids: Vec<_> = input.card_ids.into_iter().map(CardID).collect();
|
||||
|
@ -161,13 +161,13 @@ impl SchedulingService for Backend {
|
|||
Ok(state.leeched().into())
|
||||
}
|
||||
|
||||
fn answer_card(&self, input: pb::AnswerCardIn) -> Result<pb::Empty> {
|
||||
fn answer_card(&self, input: pb::AnswerCardIn) -> Result<pb::OpChanges> {
|
||||
self.with_col(|col| col.answer_card(&input.into()))
|
||||
.map(Into::into)
|
||||
}
|
||||
|
||||
fn upgrade_scheduler(&self, _input: pb::Empty) -> Result<pb::Empty> {
|
||||
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)
|
||||
}
|
||||
|
||||
|
|
|
@ -68,7 +68,7 @@ impl SearchService for Backend {
|
|||
Ok(replace_search_node(existing, replacement).into())
|
||||
}
|
||||
|
||||
fn find_and_replace(&self, input: pb::FindAndReplaceIn) -> Result<pb::UInt32> {
|
||||
fn find_and_replace(&self, input: pb::FindAndReplaceIn) -> Result<pb::OpChangesWithCount> {
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,7 +7,7 @@ pub(super) use pb::tags_service::Service as TagsService;
|
|||
|
||||
impl TagsService for Backend {
|
||||
fn clear_unused_tags(&self, _input: pb::Empty) -> Result<pb::Empty> {
|
||||
self.with_col(|col| col.transact(None, |col| col.clear_unused_tags().map(Into::into)))
|
||||
self.with_col(|col| col.transact_no_undo(|col| col.clear_unused_tags().map(Into::into)))
|
||||
}
|
||||
|
||||
fn all_tags(&self, _input: pb::Empty) -> Result<pb::StringList> {
|
||||
|
@ -29,7 +29,7 @@ impl TagsService for Backend {
|
|||
|
||||
fn set_tag_expanded(&self, input: pb::SetTagExpandedIn) -> Result<pb::Empty> {
|
||||
self.with_col(|col| {
|
||||
col.transact(None, |col| {
|
||||
col.transact_no_undo(|col| {
|
||||
col.set_tag_expanded(&input.name, input.expanded)?;
|
||||
Ok(().into())
|
||||
})
|
||||
|
@ -38,7 +38,7 @@ impl TagsService for Backend {
|
|||
|
||||
fn clear_tag(&self, tag: pb::String) -> Result<pb::Empty> {
|
||||
self.with_col(|col| {
|
||||
col.transact(None, |col| {
|
||||
col.transact_no_undo(|col| {
|
||||
col.storage.clear_tag_and_children(tag.val.as_str())?;
|
||||
Ok(().into())
|
||||
})
|
||||
|
|
|
@ -3,13 +3,13 @@
|
|||
|
||||
pub(crate) mod undo;
|
||||
|
||||
use crate::define_newtype;
|
||||
use crate::err::{AnkiError, Result};
|
||||
use crate::notes::NoteID;
|
||||
use crate::{
|
||||
collection::Collection, config::SchedulerVersion, prelude::*, timestamp::TimestampSecs,
|
||||
types::Usn,
|
||||
};
|
||||
use crate::{define_newtype, ops::StateChanges};
|
||||
|
||||
use crate::{deckconf::DeckConf, decks::DeckID};
|
||||
use num_enum::TryFromPrimitive;
|
||||
|
@ -149,9 +149,31 @@ impl Card {
|
|||
}
|
||||
|
||||
impl Collection {
|
||||
pub(crate) fn update_card_with_op(&mut self, card: &mut Card, op: Option<Op>) -> Result<()> {
|
||||
pub(crate) fn update_card_maybe_undoable(
|
||||
&mut self,
|
||||
card: &mut Card,
|
||||
undoable: bool,
|
||||
) -> Result<OpOutput<()>> {
|
||||
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)]
|
||||
|
@ -209,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<OpOutput<()>> {
|
||||
let deck = self.get_deck(deck_id)?.ok_or(AnkiError::NotFound)?;
|
||||
if deck.is_filtered() {
|
||||
return Err(AnkiError::DeckIsFiltered);
|
||||
|
@ -217,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(Op::SetDeck), |col| {
|
||||
self.transact(Op::SetDeck, |col| {
|
||||
for mut card in col.storage.all_searched_cards()? {
|
||||
if card.deck_id == deck_id {
|
||||
continue;
|
||||
|
@ -230,7 +252,7 @@ impl Collection {
|
|||
})
|
||||
}
|
||||
|
||||
pub fn set_card_flag(&mut self, cards: &[CardID], flag: u32) -> Result<()> {
|
||||
pub fn set_card_flag(&mut self, cards: &[CardID], flag: u32) -> Result<OpOutput<()>> {
|
||||
if flag > 4 {
|
||||
return Err(AnkiError::invalid_input("invalid flag"));
|
||||
}
|
||||
|
@ -238,7 +260,7 @@ impl Collection {
|
|||
|
||||
self.storage.set_search_table_to_card_ids(cards, false)?;
|
||||
let usn = self.usn()?;
|
||||
self.transact(Some(Op::SetFlag), |col| {
|
||||
self.transact(Op::SetFlag, |col| {
|
||||
for mut card in col.storage.all_searched_cards()? {
|
||||
let original = card.clone();
|
||||
card.set_flag(flag);
|
||||
|
|
|
@ -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<P: Into<PathBuf>>(
|
||||
|
@ -83,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<F, R>(&mut self, op: Option<Op>, func: F) -> Result<R>
|
||||
fn transact_inner<F, R>(&mut self, op: Option<Op>, func: F) -> Result<OpOutput<R>>
|
||||
where
|
||||
F: FnOnce(&mut Collection) -> Result<R>,
|
||||
{
|
||||
|
@ -102,14 +100,49 @@ impl Collection {
|
|||
}
|
||||
}
|
||||
|
||||
if res.is_err() {
|
||||
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()?;
|
||||
} else {
|
||||
self.end_undoable_operation();
|
||||
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<F, R>(&mut self, op: Op, func: F) -> Result<OpOutput<R>>
|
||||
where
|
||||
F: FnOnce(&mut Collection) -> Result<R>,
|
||||
{
|
||||
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<F, R>(&mut self, func: F) -> Result<R>
|
||||
where
|
||||
F: FnOnce(&mut Collection) -> Result<R>,
|
||||
{
|
||||
self.transact_inner(None, func).map(|out| out.output)
|
||||
}
|
||||
|
||||
pub(crate) fn close(self, downgrade: bool) -> Result<()> {
|
||||
|
@ -123,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()?;
|
||||
|
|
|
@ -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(Op::Bury);
|
||||
let op = Op::Bury;
|
||||
// test key
|
||||
let key = BoolKey::NormalizeNoteText;
|
||||
|
||||
|
|
|
@ -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<F>(&mut self, mut progress_fn: F) -> Result<CheckDatabaseOutput>
|
||||
|
|
|
@ -267,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<OpOutput<()>> {
|
||||
if deck.id.0 == 0 {
|
||||
self.add_deck(deck)
|
||||
} else {
|
||||
|
@ -276,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<OpOutput<()>> {
|
||||
if deck.id.0 != 0 {
|
||||
return Err(AnkiError::invalid_input("deck to add must have id 0"));
|
||||
}
|
||||
|
||||
self.transact(Some(Op::AddDeck), |col| {
|
||||
self.transact(Op::AddDeck, |col| {
|
||||
let usn = col.usn()?;
|
||||
col.prepare_deck_for_update(deck, usn)?;
|
||||
deck.set_modified(usn);
|
||||
|
@ -290,15 +290,15 @@ impl Collection {
|
|||
})
|
||||
}
|
||||
|
||||
pub fn update_deck(&mut self, deck: &mut Deck) -> Result<()> {
|
||||
self.transact(Some(Op::UpdateDeck), |col| {
|
||||
pub fn update_deck(&mut self, deck: &mut Deck) -> Result<OpOutput<()>> {
|
||||
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(Op::RenameDeck), |col| {
|
||||
pub fn rename_deck(&mut self, did: DeckID, new_human_name: &str) -> Result<OpOutput<()>> {
|
||||
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);
|
||||
|
@ -464,9 +464,9 @@ impl Collection {
|
|||
self.storage.get_deck_id(&machine_name)
|
||||
}
|
||||
|
||||
pub fn remove_decks_and_child_decks(&mut self, dids: &[DeckID]) -> Result<usize> {
|
||||
pub fn remove_decks_and_child_decks(&mut self, dids: &[DeckID]) -> Result<OpOutput<usize>> {
|
||||
self.transact(Op::RemoveDeck, |col| {
|
||||
let mut card_count = 0;
|
||||
self.transact(Some(Op::RemoveDeck), |col| {
|
||||
let usn = col.usn()?;
|
||||
for did in dids {
|
||||
if let Some(deck) = col.storage.get_deck(*did)? {
|
||||
|
@ -481,9 +481,8 @@ impl Collection {
|
|||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(card_count)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn remove_single_deck(&mut self, deck: &Deck, usn: Usn) -> Result<usize> {
|
||||
|
@ -623,9 +622,9 @@ impl Collection {
|
|||
&mut self,
|
||||
source_decks: &[DeckID],
|
||||
target: Option<DeckID>,
|
||||
) -> Result<()> {
|
||||
) -> Result<OpOutput<()>> {
|
||||
let usn = self.usn()?;
|
||||
self.transact(Some(Op::RenameDeck), |col| {
|
||||
self.transact(Op::RenameDeck, |col| {
|
||||
let target_deck;
|
||||
let mut target_name = None;
|
||||
if let Some(target) = target {
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
|
|
|
@ -45,8 +45,8 @@ impl Collection {
|
|||
search_re: &str,
|
||||
repl: &str,
|
||||
field_name: Option<String>,
|
||||
) -> Result<usize> {
|
||||
self.transact(None, |col| {
|
||||
) -> Result<OpOutput<usize>> {
|
||||
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
|
||||
|
|
|
@ -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()
|
||||
})?;
|
||||
|
|
|
@ -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,6 +15,9 @@ 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};
|
||||
|
@ -305,8 +307,8 @@ impl Collection {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
pub fn add_note(&mut self, note: &mut Note, did: DeckID) -> Result<()> {
|
||||
self.transact(Some(Op::AddNote), |col| {
|
||||
pub fn add_note(&mut self, note: &mut Note, did: DeckID) -> Result<OpOutput<()>> {
|
||||
self.transact(Op::AddNote, |col| {
|
||||
let nt = col
|
||||
.get_notetype(note.notetype_id)?
|
||||
.ok_or_else(|| AnkiError::invalid_input("missing note type"))?;
|
||||
|
@ -334,25 +336,49 @@ impl Collection {
|
|||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn update_note(&mut self, note: &mut Note) -> Result<()> {
|
||||
self.update_note_with_op(note, Some(Op::UpdateNote))
|
||||
pub(crate) fn update_note(&mut self, note: &mut Note) -> Result<OpOutput<()>> {
|
||||
self.update_note_maybe_undoable(note, true)
|
||||
}
|
||||
|
||||
pub(crate) fn update_note_with_op(&mut self, note: &mut Note, op: Option<Op>) -> Result<()> {
|
||||
pub(crate) fn update_note_maybe_undoable(
|
||||
&mut self,
|
||||
note: &mut Note,
|
||||
undoable: bool,
|
||||
) -> Result<OpOutput<()>> {
|
||||
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
|
||||
let nt = self
|
||||
.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 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(
|
||||
|
@ -388,13 +414,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<OpOutput<()>> {
|
||||
let usn = self.usn()?;
|
||||
self.transact(Some(Op::RemoveNote), |col| {
|
||||
self.transact(Op::RemoveNote, |col| {
|
||||
for nid in nids {
|
||||
let nid = *nid;
|
||||
if let Some(_existing_note) = col.storage.get_note(nid)? {
|
||||
|
@ -404,20 +430,20 @@ 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| {
|
||||
) -> Result<OpOutput<()>> {
|
||||
self.transact(Op::UpdateNote, |col| {
|
||||
col.transform_notes(nids, |_note, _nt| {
|
||||
Ok(TransformNoteOutput {
|
||||
changed: true,
|
||||
generate_cards,
|
||||
|
@ -425,6 +451,7 @@ impl Collection {
|
|||
})
|
||||
})
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn transform_notes<F>(
|
||||
|
|
|
@ -21,7 +21,7 @@ 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),
|
||||
|
@ -31,17 +31,8 @@ impl Collection {
|
|||
|
||||
/// 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) {
|
||||
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 +48,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)?;
|
||||
|
@ -86,22 +102,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 == Op::UpdateNote
|
||||
&& op.timestamp.elapsed_secs() < 60
|
||||
} else {
|
||||
false
|
||||
}
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -376,7 +376,7 @@ impl From<NoteType> 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)?;
|
||||
|
|
|
@ -9,6 +9,7 @@ pub enum Op {
|
|||
AddNote,
|
||||
AnswerCard,
|
||||
Bury,
|
||||
FindAndReplace,
|
||||
RemoveDeck,
|
||||
RemoveNote,
|
||||
RenameDeck,
|
||||
|
@ -46,85 +47,11 @@ impl Op {
|
|||
Op::UpdateTag => TR::UndoUpdateTag,
|
||||
Op::SetDeck => TR::BrowsingChangeDeck,
|
||||
Op::SetFlag => TR::UndoSetFlag,
|
||||
Op::FindAndReplace => TR::BrowsingFindAndReplace,
|
||||
};
|
||||
|
||||
i18n.tr(key).to_string()
|
||||
}
|
||||
|
||||
/// Used internally to decide whether the study queues need to be invalidated.
|
||||
pub(crate) fn needs_study_queue_reset(self) -> bool {
|
||||
let changes = self.state_changes();
|
||||
self != Op::AnswerCard && (changes.card || changes.deck || changes.preference)
|
||||
}
|
||||
|
||||
pub fn state_changes(self) -> StateChanges {
|
||||
let default = Default::default;
|
||||
match self {
|
||||
Op::ScheduleAsNew
|
||||
| Op::SetDueDate
|
||||
| Op::Suspend
|
||||
| Op::UnburyUnsuspend
|
||||
| Op::UpdateCard
|
||||
| Op::SetDeck
|
||||
| Op::Bury
|
||||
| Op::SetFlag => StateChanges {
|
||||
card: true,
|
||||
..default()
|
||||
},
|
||||
Op::AnswerCard => StateChanges {
|
||||
card: true,
|
||||
// this also modifies the daily counts stored in the
|
||||
// deck, but the UI does not care about that
|
||||
..default()
|
||||
},
|
||||
Op::AddDeck => StateChanges {
|
||||
deck: true,
|
||||
..default()
|
||||
},
|
||||
Op::AddNote => StateChanges {
|
||||
card: true,
|
||||
note: true,
|
||||
tag: true,
|
||||
..default()
|
||||
},
|
||||
Op::RemoveDeck => StateChanges {
|
||||
card: true,
|
||||
note: true,
|
||||
deck: true,
|
||||
..default()
|
||||
},
|
||||
Op::RemoveNote => StateChanges {
|
||||
card: true,
|
||||
note: true,
|
||||
..default()
|
||||
},
|
||||
Op::RenameDeck => StateChanges {
|
||||
deck: true,
|
||||
..default()
|
||||
},
|
||||
Op::UpdateDeck => StateChanges {
|
||||
deck: true,
|
||||
..default()
|
||||
},
|
||||
Op::UpdateNote => StateChanges {
|
||||
note: true,
|
||||
// edits may result in new cards being generated
|
||||
card: true,
|
||||
// and may result in new tags being added
|
||||
tag: true,
|
||||
..default()
|
||||
},
|
||||
Op::UpdatePreferences => StateChanges {
|
||||
preference: true,
|
||||
..default()
|
||||
},
|
||||
Op::UpdateTag => StateChanges {
|
||||
note: true,
|
||||
tag: true,
|
||||
..default()
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
|
@ -136,3 +63,14 @@ pub struct StateChanges {
|
|||
pub notetype: bool,
|
||||
pub preference: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct OpChanges {
|
||||
pub op: Op,
|
||||
pub changes: StateChanges,
|
||||
}
|
||||
|
||||
pub struct OpOutput<T> {
|
||||
pub output: T,
|
||||
pub changes: OpChanges,
|
||||
}
|
||||
|
|
|
@ -23,16 +23,13 @@ impl Collection {
|
|||
})
|
||||
}
|
||||
|
||||
pub fn set_preferences(&mut self, prefs: Preferences) -> Result<()> {
|
||||
self.transact(Some(Op::UpdatePreferences), |col| {
|
||||
pub fn set_preferences(&mut self, prefs: Preferences) -> Result<OpOutput<()>> {
|
||||
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)?;
|
||||
}
|
||||
|
|
|
@ -11,7 +11,7 @@ pub use crate::{
|
|||
i18n::{tr_args, tr_strs, I18n, TR},
|
||||
notes::{Note, NoteID},
|
||||
notetype::{NoteType, NoteTypeID},
|
||||
ops::Op,
|
||||
ops::{Op, OpChanges, OpOutput},
|
||||
revlog::RevlogID,
|
||||
timestamp::{TimestampMillis, TimestampSecs},
|
||||
types::Usn,
|
||||
|
|
|
@ -240,8 +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(Op::AnswerCard), |col| col.answer_card_inner(answer))
|
||||
pub fn answer_card(&mut self, answer: &CardAnswer) -> Result<OpOutput<()>> {
|
||||
self.transact(Op::AnswerCard, |col| col.answer_card_inner(answer))
|
||||
}
|
||||
|
||||
fn answer_card_inner(&mut self, answer: &CardAnswer) -> Result<()> {
|
||||
|
@ -272,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<()> {
|
||||
|
|
|
@ -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(Op::UnburyUnsuspend), |col| {
|
||||
pub fn unbury_or_unsuspend_cards(&mut self, cids: &[CardID]) -> Result<OpOutput<()>> {
|
||||
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<OpOutput<()>> {
|
||||
let op = match mode {
|
||||
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)
|
||||
})
|
||||
|
|
|
@ -103,10 +103,10 @@ fn nids_in_preserved_order(cards: &[Card]) -> Vec<NoteID> {
|
|||
}
|
||||
|
||||
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<OpOutput<()>> {
|
||||
let usn = self.usn()?;
|
||||
let mut position = self.get_next_card_position();
|
||||
self.transact(Some(Op::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 +119,7 @@ impl Collection {
|
|||
position += 1;
|
||||
}
|
||||
col.set_next_card_position(position)?;
|
||||
col.storage.clear_searched_cards_table()?;
|
||||
Ok(())
|
||||
col.storage.clear_searched_cards_table()
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -133,7 +132,7 @@ impl Collection {
|
|||
shift: bool,
|
||||
) -> Result<()> {
|
||||
let usn = self.usn()?;
|
||||
self.transact(None, |col| {
|
||||
self.transact_no_undo(|col| {
|
||||
col.sort_cards_inner(cids, starting_from, step, order, shift, usn)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -93,13 +93,13 @@ impl Collection {
|
|||
cids: &[CardID],
|
||||
days: &str,
|
||||
context: Option<StringKey>,
|
||||
) -> Result<()> {
|
||||
) -> Result<OpOutput<()>> {
|
||||
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(Op::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();
|
||||
|
|
|
@ -210,7 +210,8 @@ impl SyncServer for LocalServer {
|
|||
_col_folder: Option<&Path>,
|
||||
) -> Result<NamedTempFile> {
|
||||
// bump usn/mod & close
|
||||
self.col.transact(None, |col| col.storage.increment_usn())?;
|
||||
self.col
|
||||
.transact_no_undo(|col| col.storage.increment_usn())?;
|
||||
let col_path = self.col.col_path.clone();
|
||||
self.col.close(true)?;
|
||||
|
||||
|
|
|
@ -297,7 +297,7 @@ impl Collection {
|
|||
let tag_group = format!("({})", regex::escape(tags.trim()).replace(' ', "|"));
|
||||
let nids = self.nids_for_tags(&tag_group)?;
|
||||
let re = Regex::new(&format!("(?i)^{}(::.*)?$", tag_group))?;
|
||||
self.transact(None, |col| {
|
||||
self.transact_no_undo(|col| {
|
||||
col.storage.clear_tag_group(&tag_group)?;
|
||||
col.transform_notes(&nids, |note, _nt| {
|
||||
Ok(TransformNoteOutput {
|
||||
|
@ -340,8 +340,8 @@ impl Collection {
|
|||
nids: &[NoteID],
|
||||
tags: &[Regex],
|
||||
mut repl: R,
|
||||
) -> Result<usize> {
|
||||
self.transact(Some(Op::UpdateTag), |col| {
|
||||
) -> Result<OpOutput<usize>> {
|
||||
self.transact(Op::UpdateTag, |col| {
|
||||
col.transform_notes(nids, |note, _nt| {
|
||||
let mut changed = false;
|
||||
for re in tags {
|
||||
|
@ -367,7 +367,7 @@ impl Collection {
|
|||
tags: &str,
|
||||
repl: &str,
|
||||
regex: bool,
|
||||
) -> Result<usize> {
|
||||
) -> Result<OpOutput<usize>> {
|
||||
// generate regexps
|
||||
let tags = split_tags(tags)
|
||||
.map(|tag| {
|
||||
|
@ -383,7 +383,7 @@ impl Collection {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn add_tags_to_notes(&mut self, nids: &[NoteID], tags: &str) -> Result<usize> {
|
||||
pub fn add_tags_to_notes(&mut self, nids: &[NoteID], tags: &str) -> Result<OpOutput<usize>> {
|
||||
let tags: Vec<_> = split_tags(tags).collect();
|
||||
let matcher = regex::RegexSet::new(
|
||||
tags.iter()
|
||||
|
@ -392,7 +392,7 @@ impl Collection {
|
|||
)
|
||||
.map_err(|_| AnkiError::invalid_input("invalid regex"))?;
|
||||
|
||||
self.transact(Some(Op::UpdateTag), |col| {
|
||||
self.transact(Op::UpdateTag, |col| {
|
||||
col.transform_notes(nids, |note, _nt| {
|
||||
let mut need_to_add = true;
|
||||
let mut match_count = 0;
|
||||
|
@ -476,7 +476,7 @@ impl Collection {
|
|||
}
|
||||
|
||||
// update notes
|
||||
self.transact(None, |col| {
|
||||
self.transact_no_undo(|col| {
|
||||
// clear the existing original tags
|
||||
for (source_tag, _) in &source_tags_and_outputs {
|
||||
col.storage.clear_tag_and_children(source_tag)?;
|
||||
|
@ -578,14 +578,14 @@ mod test {
|
|||
let note = col.storage.get_note(note.id)?.unwrap();
|
||||
assert_eq!(note.tags[0], "baz");
|
||||
|
||||
let cnt = col.add_tags_to_notes(&[note.id], "cee aye")?;
|
||||
assert_eq!(cnt, 1);
|
||||
let out = col.add_tags_to_notes(&[note.id], "cee aye")?;
|
||||
assert_eq!(out.output, 1);
|
||||
let note = col.storage.get_note(note.id)?.unwrap();
|
||||
assert_eq!(¬e.tags, &["aye", "baz", "cee"]);
|
||||
|
||||
// if all tags already on note, it doesn't get updated
|
||||
let cnt = col.add_tags_to_notes(&[note.id], "cee aye")?;
|
||||
assert_eq!(cnt, 0);
|
||||
let out = col.add_tags_to_notes(&[note.id], "cee aye")?;
|
||||
assert_eq!(out.output, 0);
|
||||
|
||||
// empty replacement deletes tag
|
||||
col.replace_tags_for_notes(&[note.id], "b.* .*ye", "", true)?;
|
||||
|
|
|
@ -6,7 +6,10 @@ mod changes;
|
|||
pub use crate::ops::Op;
|
||||
pub(crate) use changes::UndoableChange;
|
||||
|
||||
use crate::prelude::*;
|
||||
use crate::{
|
||||
ops::{OpChanges, StateChanges},
|
||||
prelude::*,
|
||||
};
|
||||
use std::collections::VecDeque;
|
||||
|
||||
const UNDO_LIMIT: usize = 30;
|
||||
|
@ -86,13 +89,6 @@ impl UndoManager {
|
|||
println!("ended, undo steps count now {}", self.undo_steps.len());
|
||||
}
|
||||
|
||||
fn current_step_requires_study_queue_reset(&self) -> bool {
|
||||
self.current_step
|
||||
.as_ref()
|
||||
.map(|s| s.kind.needs_study_queue_reset())
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
fn can_undo(&self) -> Option<Op> {
|
||||
self.undo_steps.front().map(|s| s.kind)
|
||||
}
|
||||
|
@ -101,9 +97,38 @@ impl UndoManager {
|
|||
self.redo_steps.last().map(|s| s.kind)
|
||||
}
|
||||
|
||||
pub(crate) fn previous_op(&self) -> Option<&UndoableOp> {
|
||||
fn previous_op(&self) -> Option<&UndoableOp> {
|
||||
self.undo_steps.front()
|
||||
}
|
||||
|
||||
fn current_op(&self) -> Option<&UndoableOp> {
|
||||
self.current_step.as_ref()
|
||||
}
|
||||
|
||||
fn op_changes(&self) -> OpChanges {
|
||||
let current_op = self
|
||||
.current_step
|
||||
.as_ref()
|
||||
.expect("current_changes() called when no op set");
|
||||
|
||||
let mut changes = StateChanges::default();
|
||||
for change in ¤t_op.changes {
|
||||
match change {
|
||||
UndoableChange::Card(_) => changes.card = true,
|
||||
UndoableChange::Note(_) => changes.note = true,
|
||||
UndoableChange::Deck(_) => changes.deck = true,
|
||||
UndoableChange::Tag(_) => changes.tag = true,
|
||||
UndoableChange::Revlog(_) => {}
|
||||
UndoableChange::Queue(_) => {}
|
||||
UndoableChange::Config(_) => {} // fixme: preferences?
|
||||
}
|
||||
}
|
||||
|
||||
OpChanges {
|
||||
op: current_op.kind,
|
||||
changes,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Collection {
|
||||
|
@ -115,36 +140,38 @@ impl Collection {
|
|||
self.state.undo.can_redo()
|
||||
}
|
||||
|
||||
pub fn undo(&mut self) -> Result<()> {
|
||||
pub fn undo(&mut self) -> Result<OpOutput<()>> {
|
||||
if let Some(step) = self.state.undo.undo_steps.pop_front() {
|
||||
let changes = step.changes;
|
||||
self.state.undo.mode = UndoMode::Undoing;
|
||||
let res = self.transact(Some(step.kind), |col| {
|
||||
let res = self.transact(step.kind, |col| {
|
||||
for change in changes.into_iter().rev() {
|
||||
change.undo(col)?;
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
self.state.undo.mode = UndoMode::NormalOp;
|
||||
res?;
|
||||
res
|
||||
} else {
|
||||
Err(AnkiError::invalid_input("no undo available"))
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn redo(&mut self) -> Result<()> {
|
||||
pub fn redo(&mut self) -> Result<OpOutput<()>> {
|
||||
if let Some(step) = self.state.undo.redo_steps.pop() {
|
||||
let changes = step.changes;
|
||||
self.state.undo.mode = UndoMode::Redoing;
|
||||
let res = self.transact(Some(step.kind), |col| {
|
||||
let res = self.transact(step.kind, |col| {
|
||||
for change in changes.into_iter().rev() {
|
||||
change.undo(col)?;
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
self.state.undo.mode = UndoMode::NormalOp;
|
||||
res?;
|
||||
res
|
||||
} else {
|
||||
Err(AnkiError::invalid_input("no redo available"))
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn undo_status(&self) -> UndoStatus {
|
||||
|
@ -162,9 +189,6 @@ impl Collection {
|
|||
/// Called at the end of a successful transaction.
|
||||
/// In most instances, this will also clear the study queues.
|
||||
pub(crate) fn end_undoable_operation(&mut self) {
|
||||
if self.state.undo.current_step_requires_study_queue_reset() {
|
||||
self.clear_study_queues();
|
||||
}
|
||||
self.state.undo.end_step();
|
||||
}
|
||||
|
||||
|
@ -183,9 +207,30 @@ impl Collection {
|
|||
self.state.undo.save(item.into());
|
||||
}
|
||||
|
||||
pub(crate) fn current_undo_op(&self) -> Option<&UndoableOp> {
|
||||
self.state.undo.current_op()
|
||||
}
|
||||
|
||||
pub(crate) fn previous_undo_op(&self) -> Option<&UndoableOp> {
|
||||
self.state.undo.previous_op()
|
||||
}
|
||||
|
||||
/// Used for coalescing successive note updates.
|
||||
pub(crate) fn pop_last_change(&mut self) -> Option<UndoableChange> {
|
||||
self.state
|
||||
.undo
|
||||
.current_step
|
||||
.as_mut()
|
||||
.expect("no operation active")
|
||||
.changes
|
||||
.pop()
|
||||
}
|
||||
|
||||
/// Return changes made by the current op. Must only be called in a transaction,
|
||||
/// when an operation was passed to transact().
|
||||
pub(crate) fn op_changes(&self) -> Result<OpChanges> {
|
||||
Ok(self.state.undo.op_changes())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
@ -218,7 +263,7 @@ mod test {
|
|||
|
||||
// record a few undo steps
|
||||
for i in 3..=4 {
|
||||
col.transact(Some(Op::UpdateCard), |col| {
|
||||
col.transact(Op::UpdateCard, |col| {
|
||||
col.get_and_update_card(cid, |card| {
|
||||
card.interval = i;
|
||||
Ok(())
|
||||
|
@ -264,7 +309,7 @@ mod test {
|
|||
assert_eq!(col.can_redo(), Some(Op::UpdateCard));
|
||||
|
||||
// if any action is performed, it should clear the redo queue
|
||||
col.transact(Some(Op::UpdateCard), |col| {
|
||||
col.transact(Op::UpdateCard, |col| {
|
||||
col.get_and_update_card(cid, |card| {
|
||||
card.interval = 5;
|
||||
Ok(())
|
||||
|
@ -278,7 +323,7 @@ mod test {
|
|||
assert_eq!(col.can_redo(), None);
|
||||
|
||||
// and any action that doesn't support undoing will clear both queues
|
||||
col.transact(None, |_col| Ok(())).unwrap();
|
||||
col.transact_no_undo(|_col| Ok(())).unwrap();
|
||||
assert_eq!(col.can_undo(), None);
|
||||
assert_eq!(col.can_redo(), None);
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue