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:
Damien Elmes 2021-03-16 14:26:42 +10:00
parent 30c7cf1fdd
commit 6b0fe4b381
57 changed files with 918 additions and 619 deletions

View file

@ -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 op.changes.card or op.changes.deck or op.changes.preference
return changes.card or changes.deck or changes.preference
def op_made_changes(self, changes: OpChanges) -> bool:
for field in changes.DESCRIPTOR.fields:
if field.name != "kind":
if getattr(changes, field.name, False):
return True
return False
def _check_backend_undo_status(self) -> Optional[UndoStatus]:
"""Return undo status if undo available on backend.
@ -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()

View file

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

View file

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

View file

@ -178,4 +178,5 @@ def _errMsg(col: anki.collection.Collection, type: str, texpath: str) -> Any:
return msg
hooks.card_did_render.append(on_card_did_render)
def setup_hook() -> None:
hooks.card_did_render.append(on_card_did_render)

View file

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

View file

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

View file

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

View file

@ -8,7 +8,7 @@ ignored-modules=win32file,pywintypes,socket,win32pipe,winrt,pyaudio
ignored-classes=
SearchNode,
Config,
OperationInfo
OpChanges
[REPORTS]
output-format=colorized

View file

@ -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,23 +191,24 @@ class AddCards(QDialog):
return
target_deck_id = self.deck_chooser.selected_deck_id
self.mw.col.add_note(note, target_deck_id)
# only used for detecting changed sticky fields on close
self._last_added_note = note
def on_success(changes: OpChanges) -> None:
# only used for detecting changed sticky fields on close
self._last_added_note = note
self.addHistory(note)
self.mw.requireReset(reason=ResetReason.AddCardsAddNote, context=self)
self.addHistory(note)
# workaround for PyQt focus bug
self.editor.hideCompleters()
# workaround for PyQt focus bug
self.editor.hideCompleters()
tooltip(tr(TR.ADDING_ADDED), period=500)
av_player.stop_and_clear_queue()
self._load_new_note(sticky_fields_from=note)
self.mw.col.autosave() # fixme:
tooltip(tr(TR.ADDING_ADDED), period=500)
av_player.stop_and_clear_queue()
self._load_new_note(sticky_fields_from=note)
gui_hooks.add_cards_did_add_note(note)
gui_hooks.add_cards_did_add_note(note)
add_note(
mw=self.mw, note=note, target_deck_id=target_deck_id, success=on_success
)
def _note_can_be_added(self, note: Note) -> bool:
result = note.duplicate_or_empty()

View file

@ -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:
(tags, ok) = getTag(self, self.col, prompt)
if not ok:
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)
nids = self.selectedNotes()
self.mw.perform_op(lambda: func(nids, tags))
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 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,38 +1541,21 @@ 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),
parent=self,
)
self.mw.taskman.run_in_background(do_search, on_done)
find_and_replace(
mw=self.mw,
parent=self,
note_ids=nids,
search=search,
replacement=replace,
regex=regex,
field_name=field,
match_case=match_case,
)
def onFindReplaceHelp(self) -> None:
openHelp(HelpPage.BROWSING_FIND_AND_REPLACE)

16
qt/aqt/card_ops.py Normal file
View 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
View 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
),
)

View file

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

View file

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

View file

@ -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:
if exception := future.exception():
# did something go wrong?
if exception := future.exception():
if isinstance(exception, Exception):
if failure:
failure(exception)
else:
showWarning(str(exception))
return
else:
if success:
success(future.result())
# BaseException like SystemExit; rethrow it
future.result()
try:
result = future.result()
if success:
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
View 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),
)

View file

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

View file

@ -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])
# need to toggle off?
if self.card.user_flag() == desired_flag:
flag = 0
else:
flag = desired_flag
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)
else:
self.mw.col.tags.bulk_add([note.id], tag)
self.mw.perform_op(op)
tag = "marked"
note = self.card.note()
if note.has_tag(tag):
remove_tags(mw=self.mw, note_ids=[note.id], space_separated_tags=tag)
else:
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)
),

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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,8 +92,10 @@ 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)))
.map(Into::into)
self.with_col(|col| {
col.transact_no_undo(|col| col.set_bool(input.key().into(), input.value))
})
.map(Into::into)
}
fn get_config_string(&self, input: pb::config::String) -> Result<pb::String> {
@ -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)
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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)
})
.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 {})
})
col.after_note_updates(
&to_note_ids(input.nids),
input.generate_cards,
input.mark_notes_modified,
)
.map(Into::into)
})
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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() {
self.discard_undo_and_study_queues();
self.storage.rollback_rust_trx()?;
} else {
self.end_undoable_operation();
match res {
Ok(output) => {
let changes = if op.is_some() {
let changes = self.op_changes()?;
self.maybe_clear_study_queues_after_op(changes);
self.maybe_coalesce_note_undo_entry(changes);
changes
} else {
self.clear_study_queues();
// dummy value, not used by transact_no_undo(). only required
// until we can migrate all the code to undoable ops
OpChanges {
op: Op::SetFlag,
changes: StateChanges::default(),
}
};
self.end_undoable_operation();
Ok(OpOutput { output, changes })
}
Err(err) => {
self.discard_undo_and_study_queues();
self.storage.rollback_rust_trx()?;
Err(err)
}
}
}
res
/// Execute the provided closure in a transaction, rolling back if
/// an error is returned. Records undo state, and returns changes.
pub(crate) fn transact<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()?;

View file

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

View file

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

View file

@ -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> {
let mut card_count = 0;
self.transact(Some(Op::RemoveDeck), |col| {
pub fn remove_decks_and_child_decks(&mut self, dids: &[DeckID]) -> Result<OpOutput<usize>> {
self.transact(Op::RemoveDeck, |col| {
let mut card_count = 0;
let usn = col.usn()?;
for did in dids {
if let Some(deck) = col.storage.get_deck(*did)? {
@ -481,9 +481,8 @@ impl Collection {
}
}
}
Ok(())
})?;
Ok(card_count)
Ok(card_count)
})
}
pub(crate) fn remove_single_deck(&mut self, deck: &Deck, usn: Usn) -> Result<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 {

View file

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

View file

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

View file

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

View file

@ -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
.get_notetype(note.notetype_id)?
.ok_or_else(|| AnkiError::invalid_input("missing note type"))?;
let ctx = CardGenContext::new(&nt, col.usn()?);
let norm = col.get_bool(BoolKey::NormalizeNoteText);
col.update_note_inner_generating_cards(&ctx, note, &existing_note, true, norm)
})
let nt = self
.get_notetype(note.notetype_id)?
.ok_or_else(|| AnkiError::invalid_input("missing note type"))?;
let ctx = CardGenContext::new(&nt, self.usn()?);
let norm = self.get_bool(BoolKey::NormalizeNoteText);
self.update_note_inner_generating_cards(&ctx, note, &existing_note, true, norm)?;
Ok(())
}
pub(crate) fn update_note_inner_generating_cards(
@ -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,27 +430,28 @@ impl Collection {
col.remove_note_only_undoable(nid, usn)?;
}
}
Ok(())
})
}
/// Update cards and field cache after notes modified externally.
/// If gencards is false, skip card generation.
pub(crate) fn after_note_updates(
pub fn after_note_updates(
&mut self,
nids: &[NoteID],
generate_cards: bool,
mark_notes_modified: bool,
) -> Result<()> {
self.transform_notes(nids, |_note, _nt| {
Ok(TransformNoteOutput {
changed: true,
generate_cards,
mark_modified: mark_notes_modified,
) -> Result<OpOutput<()>> {
self.transact(Op::UpdateNote, |col| {
col.transform_notes(nids, |_note, _nt| {
Ok(TransformNoteOutput {
changed: true,
generate_cards,
mark_modified: mark_notes_modified,
})
})
.map(|_| ())
})
.map(|_| ())
}
pub(crate) fn transform_notes<F>(

View file

@ -21,7 +21,7 @@ impl Collection {
.storage
.get_note(note.id)?
.ok_or_else(|| AnkiError::invalid_input("note disappeared"))?;
self.update_note_undoable(&note, &current, false)
self.update_note_undoable(&note, &current)
}
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) {
self.save_undo(UndoableNoteChange::Updated(Box::new(original.clone())));
}
pub(super) fn update_note_undoable(&mut self, note: &Note, original: &Note) -> Result<()> {
self.save_undo(UndoableNoteChange::Updated(Box::new(original.clone())));
self.storage.update_note(note)?;
Ok(())
@ -57,6 +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)
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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!(&note.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)?;

View file

@ -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 &current_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);
}