more reset refactoring

'card modified' covers the common case where we need to rebuild the
study queue, but is also set when changing the card flags. We want to
avoid a queue rebuild in that case, as it causes UI flicker, and may
result in a different card being shown. Note marking doesn't trigger
a queue build, but still causes flicker, and may return the user back
to the front side when they were looking at the answer.

I still think entity-based change tracking is the simplest in the
common case, but to solve the above, I've introduced an enum describing
the last operation that was taken. This currently is not trying to list
out all possible operations, and just describes the ones we want to
special-case.

Other changes:

- Fire the old 'state_did_reset' hook after an operation is performed,
so legacy code can refresh itself after an operation is performed.
- Fire the new `operation_did_execute` hook when mw.reset() is called,
so that as the UI is updated to the use the new hook, it will still
be able to refresh after legacy code calls mw.reset()
- Update the deck browser, overview and review screens to listen to
the new hook, instead of relying on the main window to call moveToState()
- Add a 'set flag' backend action, so we can distinguish it from a
normal card update.
- Drop the separate added/modified entries in the change list in
favour of a single entry per entity.
- Add typing to mw.state
- Tweak perform_op()
- Convert a few more actions to use perform_op()
This commit is contained in:
Damien Elmes 2021-03-14 19:54:15 +10:00
parent 112cbe8b59
commit 1e849316be
19 changed files with 397 additions and 296 deletions

View File

@ -18,3 +18,4 @@ undo-update-note = Update Note
undo-update-card = Update Card undo-update-card = Update Card
undo-update-deck = Update Deck undo-update-deck = Update Deck
undo-forget-card = Forget Card undo-forget-card = Forget Card
undo-set-flag = Set Flag

View File

@ -196,7 +196,8 @@ class Card:
return self.flags & 0b111 return self.flags & 0b111
def set_user_flag(self, flag: int) -> None: def set_user_flag(self, flag: int) -> None:
assert 0 <= flag <= 7 print("use col.set_user_flag_for_cards() instead")
assert 0 <= flag <= 4
self.flags = (self.flags & ~0b111) | flag self.flags = (self.flags & ~0b111) | flag
# legacy # legacy

View File

@ -54,7 +54,7 @@ GraphPreferences = _pb.GraphPreferences
BuiltinSort = _pb.SortOrder.Builtin BuiltinSort = _pb.SortOrder.Builtin
Preferences = _pb.Preferences Preferences = _pb.Preferences
UndoStatus = _pb.UndoStatus UndoStatus = _pb.UndoStatus
StateChanges = _pb.StateChanges OperationInfo = _pb.OperationInfo
DefaultsForAdding = _pb.DeckAndNotetype DefaultsForAdding = _pb.DeckAndNotetype
@ -783,8 +783,6 @@ table.review-log {{ {revlog_style} }}
assert_exhaustive(self._undo) assert_exhaustive(self._undo)
assert False assert False
return status
def clear_python_undo(self) -> None: def clear_python_undo(self) -> None:
"""Clear the Python undo state. """Clear the Python undo state.
The backend will automatically clear backend undo state when The backend will automatically clear backend undo state when
@ -812,6 +810,11 @@ table.review-log {{ {revlog_style} }}
assert_exhaustive(self._undo) assert_exhaustive(self._undo)
assert False assert False
def op_affects_study_queue(self, op: OperationInfo) -> bool:
if op.kind == op.SET_CARD_FLAG:
return False
return op.changes.card or op.changes.deck or op.changes.preference
def _check_backend_undo_status(self) -> Optional[UndoStatus]: def _check_backend_undo_status(self) -> Optional[UndoStatus]:
"""Return undo status if undo available on backend. """Return undo status if undo available on backend.
If backend has undo available, clear the Python undo state.""" If backend has undo available, clear the Python undo state."""
@ -981,21 +984,10 @@ table.review-log {{ {revlog_style} }}
self._logHnd.close() self._logHnd.close()
self._logHnd = None self._logHnd = None
# Card Flags
########################################################################## ##########################################################################
def set_user_flag_for_cards(self, flag: int, cids: List[int]) -> None: def set_user_flag_for_cards(self, flag: int, cids: List[int]) -> None:
assert 0 <= flag <= 7 self._backend.set_flag(card_ids=cids, flag=flag)
self.db.execute(
"update cards set flags = (flags & ~?) | ?, usn=?, mod=? where id in %s"
% ids2str(cids),
0b111,
flag,
self.usn(),
intTime(),
)
##########################################################################
def set_wants_abort(self) -> None: def set_wants_abort(self) -> None:
self._backend.set_wants_abort() self._backend.set_wants_abort()

View File

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

View File

@ -12,7 +12,7 @@ from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union,
import aqt import aqt
import aqt.forms import aqt.forms
from anki.cards import Card from anki.cards import Card
from anki.collection import Collection, Config, SearchNode, StateChanges from anki.collection import Collection, Config, OperationInfo, SearchNode
from anki.consts import * from anki.consts import *
from anki.errors import InvalidInput, NotFoundError from anki.errors import InvalidInput, NotFoundError
from anki.lang import without_unicode_isolation from anki.lang import without_unicode_isolation
@ -281,13 +281,8 @@ class DataModel(QAbstractTableModel):
else: else:
tv.selectRow(0) tv.selectRow(0)
def maybe_redraw_after_operation(self, changes: StateChanges) -> None: def maybe_redraw_after_operation(self, op: OperationInfo) -> None:
if ( if op.changes.card or op.changes.note or op.changes.deck or op.changes.notetype:
changes.card_modified
or changes.note_modified
or changes.deck_modified
or changes.notetype_modified
):
self.reset() self.reset()
# Column data # Column data
@ -493,9 +488,9 @@ class Browser(QMainWindow):
# as that will block the UI # as that will block the UI
self.setUpdatesEnabled(False) self.setUpdatesEnabled(False)
def on_operation_did_execute(self, changes: StateChanges) -> None: def on_operation_did_execute(self, op: OperationInfo) -> None:
self.setUpdatesEnabled(True) self.setUpdatesEnabled(True)
self.model.maybe_redraw_after_operation(changes) self.model.maybe_redraw_after_operation(op)
def setupMenus(self) -> None: def setupMenus(self) -> None:
# pylint: disable=unnecessary-lambda # pylint: disable=unnecessary-lambda
@ -1159,14 +1154,10 @@ where id in %s"""
# select the next card if there is one # select the next card if there is one
self._onNextCard() self._onNextCard()
def do_remove() -> None: self.mw.perform_op(
self.col.remove_notes(nids) lambda: self.col.remove_notes(nids),
success=lambda _: tooltip(tr(TR.BROWSING_NOTE_DELETED, count=len(nids))),
def on_done(fut: Future) -> None: )
fut.result()
tooltip(tr(TR.BROWSING_NOTE_DELETED, count=len(nids)))
self.perform_op(do_remove, on_done, reset_model=True)
# legacy # legacy
@ -1196,14 +1187,7 @@ where id in %s"""
return return
did = self.col.decks.id(ret.name) did = self.col.decks.id(ret.name)
def do_move() -> None: self.mw.perform_op(lambda: self.col.set_deck(cids, did))
self.col.set_deck(cids, did)
def on_done(fut: Future) -> None:
fut.result()
self.mw.requireReset(reason=ResetReason.BrowserSetDeck, context=self)
self.perform_op(do_move, on_done)
# legacy # legacy
@ -1247,9 +1231,8 @@ where id in %s"""
if not ok: if not ok:
return return
self.model.beginReset() nids = self.selectedNotes()
func(self.selectedNotes(), tags) self.mw.perform_op(lambda: func(nids, tags))
self.model.endReset()
self.mw.requireReset(reason=ResetReason.BrowserAddTags, context=self) self.mw.requireReset(reason=ResetReason.BrowserAddTags, context=self)
def clearUnusedTags(self) -> None: def clearUnusedTags(self) -> None:
@ -1304,8 +1287,9 @@ where id in %s"""
# flag needs toggling off? # flag needs toggling off?
if n == self.card.user_flag(): if n == self.card.user_flag():
n = 0 n = 0
self.col.set_user_flag_for_cards(n, self.selectedCards())
self.model.reset() cids = self.selectedCards()
self.mw.perform_op(lambda: self.col.set_user_flag_for_cards(n, cids))
def _updateFlagsMenu(self) -> None: def _updateFlagsMenu(self) -> None:
flag = self.card and self.card.user_flag() flag = self.card and self.card.user_flag()
@ -1382,11 +1366,6 @@ where id in %s"""
# Scheduling # Scheduling
###################################################################### ######################################################################
def _after_schedule(self) -> None:
self.model.reset()
# updates undo status
self.mw.requireReset(reason=ResetReason.BrowserReschedule, context=self)
def set_due_date(self) -> None: def set_due_date(self) -> None:
self.editor.saveNow( self.editor.saveNow(
lambda: set_due_date_dialog( lambda: set_due_date_dialog(
@ -1394,7 +1373,6 @@ where id in %s"""
parent=self, parent=self,
card_ids=self.selectedCards(), card_ids=self.selectedCards(),
config_key=Config.String.SET_DUE_BROWSER, config_key=Config.String.SET_DUE_BROWSER,
on_done=self._after_schedule,
) )
) )
@ -1404,7 +1382,6 @@ where id in %s"""
mw=self.mw, mw=self.mw,
parent=self, parent=self,
card_ids=self.selectedCards(), card_ids=self.selectedCards(),
on_done=self._after_schedule,
) )
) )

View File

@ -8,6 +8,7 @@ from dataclasses import dataclass
from typing import Any from typing import Any
import aqt import aqt
from anki.collection import OperationInfo
from anki.decks import DeckTreeNode from anki.decks import DeckTreeNode
from anki.errors import DeckIsFilteredError from anki.errors import DeckIsFilteredError
from anki.utils import intTime from anki.utils import intTime
@ -61,6 +62,7 @@ class DeckBrowser:
self.bottom = BottomBar(mw, mw.bottomWeb) self.bottom = BottomBar(mw, mw.bottomWeb)
self.scrollPos = QPoint(0, 0) self.scrollPos = QPoint(0, 0)
self._v1_message_dismissed_at = 0 self._v1_message_dismissed_at = 0
gui_hooks.operation_did_execute.append(self.on_operation_did_execute)
def show(self) -> None: def show(self) -> None:
av_player.stop_and_clear_queue() av_player.stop_and_clear_queue()
@ -72,6 +74,13 @@ class DeckBrowser:
def refresh(self) -> None: def refresh(self) -> None:
self._renderPage() self._renderPage()
def on_operation_did_execute(self, op: OperationInfo) -> None:
if self.mw.state != "deckBrowser":
return
if self.mw.col.op_affects_study_queue(op):
self.refresh()
# Event handlers # Event handlers
########################################################################## ##########################################################################

View File

@ -19,6 +19,7 @@ from typing import (
Callable, Callable,
Dict, Dict,
List, List,
Literal,
Optional, Optional,
Sequence, Sequence,
TextIO, TextIO,
@ -43,6 +44,7 @@ from anki.collection import (
Checkpoint, Checkpoint,
Collection, Collection,
Config, Config,
OperationInfo,
ReviewUndo, ReviewUndo,
UndoResult, UndoResult,
UndoStatus, UndoStatus,
@ -112,6 +114,11 @@ class ResetRequired:
self.mw = mw self.mw = mw
MainWindowState = Literal[
"startup", "deckBrowser", "overview", "review", "resetRequired", "profileManager"
]
class AnkiQt(QMainWindow): class AnkiQt(QMainWindow):
col: Collection col: Collection
pm: ProfileManagerType pm: ProfileManagerType
@ -128,7 +135,7 @@ class AnkiQt(QMainWindow):
) -> None: ) -> None:
QMainWindow.__init__(self) QMainWindow.__init__(self)
self.backend = backend self.backend = backend
self.state = "startup" self.state: MainWindowState = "startup"
self.opts = opts self.opts = opts
self.col: Optional[Collection] = None self.col: Optional[Collection] = None
self.taskman = TaskManager(self) self.taskman = TaskManager(self)
@ -664,12 +671,12 @@ class AnkiQt(QMainWindow):
self.pm.save() self.pm.save()
self.progress.finish() self.progress.finish()
# State machine # Tracking main window state (deck browser, reviewer, etc)
########################################################################## ##########################################################################
def moveToState(self, state: str, *args: Any) -> None: def moveToState(self, state: MainWindowState, *args: Any) -> None:
# print("-> move from", self.state, "to", state) # print("-> move from", self.state, "to", state)
oldState = self.state or "dummy" oldState = self.state
cleanup = getattr(self, f"_{oldState}Cleanup", None) cleanup = getattr(self, f"_{oldState}Cleanup", None)
if cleanup: if cleanup:
# pylint: disable=not-callable # pylint: disable=not-callable
@ -711,20 +718,27 @@ class AnkiQt(QMainWindow):
def perform_op( def perform_op(
self, self,
op: Callable[[], T], op: Callable[[], T],
on_success: Optional[Callable[[T], None]] = None, *,
on_exception: Optional[Callable[[BaseException], None]] = None, success: Optional[Callable[[T], None]] = None,
failure: Optional[Callable[[BaseException], None]] = None,
) -> None: ) -> None:
"""Run the provided operation on a background thread. """Run the provided operation on a background thread.
- Ensures any changes in the editor have been saved.
- Shows progress popup for the duration of the op. - Shows progress popup for the duration of the op.
- Ensures the browser doesn't try to redraw during the operation, which can lead - Ensures the browser doesn't try to redraw during the operation, which can lead
to a frozen UI to a frozen UI
- Updates undo state at the end of the operation - Updates undo state at the end of the operation
- Commits changes
- Fires the `operation_(will|did)_reset` hooks
- Fires the legacy `state_did_reset` hook
on_success() will be called with the return value of op() Be careful not to call any UI routines in `op`, as that may crash Qt.
if op() threw an exception, on_exception() will be called with it, This includes things select .selectedCards() in the browse screen.
if it was provided
on_success() will be called with the return value of op().
If op() throws an exception, it will be shown in a popup, or
passed to on_exception() if it is provided.
""" """
gui_hooks.operation_will_execute() gui_hooks.operation_will_execute()
@ -732,28 +746,48 @@ class AnkiQt(QMainWindow):
def wrapped_done(future: Future) -> None: def wrapped_done(future: Future) -> None:
try: try:
if exception := future.exception(): if exception := future.exception():
if on_exception: if failure:
on_exception(exception) failure(exception)
else: else:
showWarning(str(exception)) showWarning(str(exception))
else: else:
if on_success: if success:
on_success(future.result()) success(future.result())
finally: finally:
status = self.col.undo_status() status = self.col.undo_status()
self._update_undo_actions_for_status(status) self._update_undo_actions_for_status_and_save(status)
gui_hooks.operation_did_execute(status.changes) 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()
self.taskman.with_progress(op, wrapped_done) self.taskman.with_progress(op, wrapped_done)
def reset(self, guiOnly: bool = False) -> None: def _synthesize_op_did_execute_from_reset(self) -> None:
"Called for non-trivial edits. Rebuilds queue and updates UI." """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)
gui_hooks.operation_did_execute(op)
def reset(self, unused_arg: bool = False) -> None:
"""Legacy method of telling UI to refresh after changes made to DB.
New code should use mw.perform_op() instead."""
if self.col: if self.col:
if not guiOnly: # fire new `operation_did_execute` hook first. If the overview
self.col.reset() # or review screen are currently open, they will rebuild the study
# queues (via mw.col.reset())
self._synthesize_op_did_execute_from_reset()
# fire the old reset hook
gui_hooks.state_did_reset() gui_hooks.state_did_reset()
self.update_undo_actions() self.update_undo_actions()
self.moveToState(self.state)
# fixme: double-check
# self.moveToState(self.state)
def requireReset( def requireReset(
self, self,
@ -784,7 +818,7 @@ class AnkiQt(QMainWindow):
# windows # windows
self.progress.timer(100, self.maybeReset, False) self.progress.timer(100, self.maybeReset, False)
def _resetRequiredState(self, oldState: str) -> None: def _resetRequiredState(self, oldState: MainWindowState) -> None:
if oldState != "resetRequired": if oldState != "resetRequired":
self.returnState = oldState self.returnState = oldState
if self.resetModal: if self.resetModal:
@ -1160,7 +1194,7 @@ title="%s" %s>%s</button>""" % (
self.form.actionUndo.setEnabled(False) self.form.actionUndo.setEnabled(False)
gui_hooks.undo_state_did_change(False) gui_hooks.undo_state_did_change(False)
def _update_undo_actions_for_status(self, status: UndoStatus) -> None: def _update_undo_actions_for_status_and_save(self, status: UndoStatus) -> None:
"""Update menu text and enable/disable menu item as appropriate. """Update menu text and enable/disable menu item as appropriate.
Plural as this may handle redo in the future too.""" Plural as this may handle redo in the future too."""
undo_action = status.undo undo_action = status.undo
@ -1175,6 +1209,8 @@ title="%s" %s>%s</button>""" % (
self.form.actionUndo.setEnabled(False) self.form.actionUndo.setEnabled(False)
gui_hooks.undo_state_did_change(False) gui_hooks.undo_state_did_change(False)
self.col.autosave()
def checkpoint(self, name: str) -> None: def checkpoint(self, name: str) -> None:
self.col.save(name) self.col.save(name)
self.update_undo_actions() self.update_undo_actions()

View File

@ -6,6 +6,7 @@ from dataclasses import dataclass
from typing import Any, Callable, Dict, List, Optional, Tuple from typing import Any, Callable, Dict, List, Optional, Tuple
import aqt import aqt
from anki.collection import OperationInfo
from aqt import gui_hooks from aqt import gui_hooks
from aqt.sound import av_player from aqt.sound import av_player
from aqt.toolbar import BottomBar from aqt.toolbar import BottomBar
@ -42,6 +43,7 @@ class Overview:
self.mw = mw self.mw = mw
self.web = mw.web self.web = mw.web
self.bottom = BottomBar(mw, mw.bottomWeb) self.bottom = BottomBar(mw, mw.bottomWeb)
gui_hooks.operation_did_execute.append(self.on_operation_did_execute)
def show(self) -> None: def show(self) -> None:
av_player.stop_and_clear_queue() av_player.stop_and_clear_queue()
@ -56,6 +58,14 @@ class Overview:
self.mw.web.setFocus() self.mw.web.setFocus()
gui_hooks.overview_did_refresh(self) gui_hooks.overview_did_refresh(self)
def on_operation_did_execute(self, op: OperationInfo) -> None:
if self.mw.state != "overview":
return
if self.mw.col.op_affects_study_queue(op):
# will also cover the deck description modified case
self.refresh()
# Handlers # Handlers
############################################################ ############################################################

View File

@ -13,7 +13,7 @@ from PyQt5.QtCore import Qt
from anki import hooks from anki import hooks
from anki.cards import Card from anki.cards import Card
from anki.collection import Config, StateChanges from anki.collection import Config, OperationInfo
from anki.utils import stripHTML from anki.utils import stripHTML
from aqt import AnkiQt, gui_hooks from aqt import AnkiQt, gui_hooks
from aqt.profiles import VideoDriver from aqt.profiles import VideoDriver
@ -87,17 +87,26 @@ class Reviewer:
gui_hooks.reviewer_will_end() gui_hooks.reviewer_will_end()
self.card = None self.card = None
def on_operation_did_execute(self, changes: StateChanges) -> None: def on_operation_did_execute(self, op: OperationInfo) -> None:
need_queue_rebuild = ( if self.mw.state != "review":
changes.card_added return
or changes.card_modified
or changes.deck_modified
or changes.preference_modified
)
if need_queue_rebuild: if op.kind == OperationInfo.UPDATE_NOTE_TAGS:
self.card.load()
self._update_mark_icon()
elif op.kind == OperationInfo.SET_CARD_FLAG:
# fixme: v3 mtime check
self.card.load()
self._update_flag_icon()
elif self.mw.col.op_affects_study_queue(op):
# need queue rebuild
self.mw.col.reset() self.mw.col.reset()
self.nextCard() self.nextCard()
return
elif op.changes.note or op.changes.notetype or op.changes.tag:
# need redraw of current card
self.card.load()
self._showQuestion()
# Fetching a card # Fetching a card
########################################################################## ##########################################################################
@ -795,24 +804,27 @@ time = %(time)d;
def onOptions(self) -> None: def onOptions(self) -> None:
self.mw.onDeckConf(self.mw.col.decks.get(self.card.odid or self.card.did)) self.mw.onDeckConf(self.mw.col.decks.get(self.card.odid or self.card.did))
def set_flag_on_current_card(self, flag: int) -> None: def set_flag_on_current_card(self, desired_flag: int) -> None:
# need to toggle off? def op() -> None:
if self.card.user_flag() == flag: # need to toggle off?
flag = 0 if self.card.user_flag() == desired_flag:
self.card.set_user_flag(flag) flag = 0
self.mw.col.update_card(self.card) else:
self.mw.update_undo_actions() flag = desired_flag
self._update_flag_icon() self.mw.col.set_user_flag_for_cards(flag, [self.card.id])
self.mw.perform_op(op)
def toggle_mark_on_current_note(self) -> None: def toggle_mark_on_current_note(self) -> None:
note = self.card.note() def op() -> None:
if note.has_tag("marked"): tag = "marked"
note.remove_tag("marked") note = self.card.note()
else: if note.has_tag(tag):
note.add_tag("marked") self.mw.col.tags.bulk_remove([note.id], tag)
self.mw.col.update_note(note) else:
self.mw.update_undo_actions() self.mw.col.tags.bulk_add([note.id], tag)
self._update_mark_icon()
self.mw.perform_op(op)
def on_set_due(self) -> None: def on_set_due(self) -> None:
if self.mw.state != "review" or not self.card: if self.mw.state != "review" or not self.card:
@ -823,28 +835,33 @@ time = %(time)d;
parent=self.mw, parent=self.mw,
card_ids=[self.card.id], card_ids=[self.card.id],
config_key=Config.String.SET_DUE_REVIEWER, config_key=Config.String.SET_DUE_REVIEWER,
on_done=self.mw.reset,
) )
def suspend_current_note(self) -> None: def suspend_current_note(self) -> None:
self.mw.col.sched.suspend_cards([c.id for c in self.card.note().cards()]) self.mw.perform_op(
self.mw.reset() lambda: self.mw.col.sched.suspend_cards(
tooltip(tr(TR.STUDYING_NOTE_SUSPENDED)) [c.id for c in self.card.note().cards()]
),
success=lambda _: tooltip(tr(TR.STUDYING_NOTE_SUSPENDED)),
)
def suspend_current_card(self) -> None: def suspend_current_card(self) -> None:
self.mw.col.sched.suspend_cards([self.card.id]) self.mw.perform_op(
self.mw.reset() lambda: self.mw.col.sched.suspend_cards([self.card.id]),
tooltip(tr(TR.STUDYING_CARD_SUSPENDED)) success=lambda _: tooltip(tr(TR.STUDYING_CARD_SUSPENDED)),
)
def bury_current_card(self) -> None: def bury_current_card(self) -> None:
self.mw.col.sched.bury_cards([self.card.id]) self.mw.perform_op(
self.mw.reset() lambda: self.mw.col.sched.bury_cards([self.card.id]),
tooltip(tr(TR.STUDYING_CARD_BURIED)) success=lambda _: tooltip(tr(TR.STUDYING_CARD_BURIED)),
)
def bury_current_note(self) -> None: def bury_current_note(self) -> None:
self.mw.col.sched.bury_note(self.card.note()) self.mw.perform_op(
self.mw.reset() lambda: self.mw.col.sched.bury_note(self.card.note()),
tooltip(tr(TR.STUDYING_NOTE_BURIED)) success=lambda _: tooltip(tr(TR.STUDYING_NOTE_BURIED)),
)
def delete_current_note(self) -> None: def delete_current_note(self) -> None:
# need to check state because the shortcut is global to the main # need to check state because the shortcut is global to the main
@ -855,7 +872,9 @@ time = %(time)d;
self.mw.perform_op( self.mw.perform_op(
lambda: self.mw.col.remove_notes([self.card.note().id]), lambda: self.mw.col.remove_notes([self.card.note().id]),
lambda _: tooltip(tr(TR.STUDYING_NOTE_AND_ITS_CARD_DELETED, count=cnt)), success=lambda _: tooltip(
tr(TR.STUDYING_NOTE_AND_ITS_CARD_DELETED, count=cnt)
),
) )
def onRecordVoice(self) -> None: def onRecordVoice(self) -> None:

View File

@ -3,14 +3,13 @@
from __future__ import annotations from __future__ import annotations
from concurrent.futures import Future
from typing import List, Optional from typing import List, Optional
import aqt import aqt
from anki.collection import Config from anki.collection import Config
from anki.lang import TR from anki.lang import TR
from aqt.qt import * from aqt.qt import *
from aqt.utils import getText, showWarning, tooltip, tr from aqt.utils import getText, tooltip, tr
def set_due_date_dialog( def set_due_date_dialog(
@ -19,12 +18,13 @@ def set_due_date_dialog(
parent: QDialog, parent: QDialog,
card_ids: List[int], card_ids: List[int],
config_key: Optional[Config.String.Key.V], config_key: Optional[Config.String.Key.V],
on_done: Callable[[], None],
) -> None: ) -> None:
if not card_ids: if not card_ids:
return return
default = mw.col.get_config_string(config_key) if config_key is not None else "" default_text = (
mw.col.get_config_string(config_key) if config_key is not None else ""
)
prompt = "\n".join( prompt = "\n".join(
[ [
tr(TR.SCHEDULING_SET_DUE_DATE_PROMPT, cards=len(card_ids)), tr(TR.SCHEDULING_SET_DUE_DATE_PROMPT, cards=len(card_ids)),
@ -34,49 +34,28 @@ def set_due_date_dialog(
(days, success) = getText( (days, success) = getText(
prompt=prompt, prompt=prompt,
parent=parent, parent=parent,
default=default, default=default_text,
title=tr(TR.ACTIONS_SET_DUE_DATE), title=tr(TR.ACTIONS_SET_DUE_DATE),
) )
if not success or not days.strip(): if not success or not days.strip():
return return
def set_due() -> None: mw.perform_op(
mw.col.sched.set_due_date(card_ids, days, config_key) lambda: mw.col.sched.set_due_date(card_ids, days, config_key),
success=lambda _: tooltip(
def after_set(fut: Future) -> None:
try:
fut.result()
except Exception as e:
showWarning(str(e))
on_done()
return
tooltip(
tr(TR.SCHEDULING_SET_DUE_DATE_DONE, cards=len(card_ids)), tr(TR.SCHEDULING_SET_DUE_DATE_DONE, cards=len(card_ids)),
parent=parent, parent=parent,
) ),
)
on_done()
mw.taskman.with_progress(set_due, after_set)
def forget_cards( def forget_cards(*, mw: aqt.AnkiQt, parent: QDialog, card_ids: List[int]) -> None:
*, mw: aqt.AnkiQt, parent: QDialog, card_ids: List[int], on_done: Callable[[], None]
) -> None:
if not card_ids: if not card_ids:
return return
def on_done_wrapper(fut: Future) -> None: mw.perform_op(
try: lambda: mw.col.sched.schedule_cards_as_new(card_ids),
fut.result() success=lambda _: tooltip(
except Exception as e: tr(TR.SCHEDULING_FORGOT_CARDS, cards=len(card_ids)), parent=parent
showWarning(str(e)) ),
else:
tooltip(tr(TR.SCHEDULING_FORGOT_CARDS, cards=len(card_ids)), parent=parent)
on_done()
mw.taskman.with_progress(
lambda: mw.col.sched.schedule_cards_as_new(card_ids), on_done_wrapper
) )

View File

@ -394,7 +394,10 @@ hooks = [
Hook( Hook(
name="state_did_reset", name="state_did_reset",
legacy_hook="reset", legacy_hook="reset",
doc="Called when the interface needs to be redisplayed after non-trivial changes have been made.", doc="""Legacy 'reset' hook. Called by mw.reset() and mw.perform_op() to redraw the UI.
New code should use `operation_did_execute` instead.
""",
), ),
Hook( Hook(
name="operation_will_execute", name="operation_will_execute",
@ -405,10 +408,14 @@ hooks = [
Hook( Hook(
name="operation_did_execute", name="operation_did_execute",
args=[ args=[
"changes: anki.collection.StateChanges", "op: anki.collection.OperationInfo",
], ],
doc="""Called after an operation completes. doc="""Called after an operation completes.
Changes can be inspected to determine whether the UI needs updating.""", Changes can be inspected to determine whether the UI needs updating.
This will also be called when the legacy mw.reset() is used. When called via
mw.reset(), `operation_will_execute` will not be called.
""",
), ),
# Webview # Webview
################### ###################

View File

@ -267,6 +267,7 @@ service CardsService {
rpc UpdateCard(UpdateCardIn) returns (Empty); rpc UpdateCard(UpdateCardIn) returns (Empty);
rpc RemoveCards(RemoveCardsIn) returns (Empty); rpc RemoveCards(RemoveCardsIn) returns (Empty);
rpc SetDeck(SetDeckIn) returns (Empty); rpc SetDeck(SetDeckIn) returns (Empty);
rpc SetFlag(SetFlagIn) returns (Empty);
} }
// Protobuf stored in .anki2 files // Protobuf stored in .anki2 files
@ -1442,22 +1443,30 @@ message GetQueuedCardsOut {
} }
} }
message StateChanges { message OperationInfo {
bool card_added = 1; message Changes {
bool card_modified = 2; bool card = 1;
bool note_added = 3; bool note = 2;
bool note_modified = 4; bool deck = 3;
bool deck_added = 5; bool tag = 4;
bool deck_modified = 6; bool notetype = 5;
bool tag_modified = 7; bool preference = 6;
bool notetype_modified = 8; }
bool preference_modified = 9; // this is not an exhaustive list; we can add more cases as we need them
enum Kind {
OTHER = 0;
UPDATE_NOTE_TAGS = 1;
SET_CARD_FLAG = 2;
}
Kind kind = 1;
Changes changes = 2;
} }
message UndoStatus { message UndoStatus {
string undo = 1; string undo = 1;
string redo = 2; string redo = 2;
StateChanges changes = 3; OperationInfo last_op = 3;
} }
message DefaultsForAddingIn { message DefaultsForAddingIn {
@ -1472,4 +1481,9 @@ message DeckAndNotetype {
message RenameDeckIn { message RenameDeckIn {
int64 deck_id = 1; int64 deck_id = 1;
string new_name = 2; string new_name = 2;
} }
message SetFlagIn {
repeated int64 card_ids = 1;
uint32 flag = 2;
}

View File

@ -54,6 +54,13 @@ impl CardsService for Backend {
let deck_id = input.deck_id.into(); let deck_id = input.deck_id.into();
self.with_col(|col| col.set_deck(&cids, deck_id).map(Into::into)) self.with_col(|col| col.set_deck(&cids, deck_id).map(Into::into))
} }
fn set_flag(&self, input: pb::SetFlagIn) -> Result<pb::Empty> {
self.with_col(|col| {
col.set_card_flag(&to_card_ids(input.card_ids), input.flag)
.map(Into::into)
})
}
} }
impl TryFrom<pb::Card> for Card { impl TryFrom<pb::Card> for Card {
@ -111,3 +118,7 @@ impl From<Card> for pb::Card {
} }
} }
} }
fn to_card_ids(v: Vec<i64>) -> Vec<CardID> {
v.into_iter().map(CardID).collect()
}

View File

@ -85,20 +85,20 @@ impl CollectionService for Backend {
} }
fn get_undo_status(&self, _input: pb::Empty) -> Result<pb::UndoStatus> { fn get_undo_status(&self, _input: pb::Empty) -> Result<pb::UndoStatus> {
self.with_col(|col| Ok(col.undo_status())) self.with_col(|col| Ok(col.undo_status().into_protobuf(&col.i18n)))
} }
fn undo(&self, _input: pb::Empty) -> Result<pb::UndoStatus> { fn undo(&self, _input: pb::Empty) -> Result<pb::UndoStatus> {
self.with_col(|col| { self.with_col(|col| {
col.undo()?; col.undo()?;
Ok(col.undo_status()) Ok(col.undo_status().into_protobuf(&col.i18n))
}) })
} }
fn redo(&self, _input: pb::Empty) -> Result<pb::UndoStatus> { fn redo(&self, _input: pb::Empty) -> Result<pb::UndoStatus> {
self.with_col(|col| { self.with_col(|col| {
col.redo()?; col.redo()?;
Ok(col.undo_status()) Ok(col.undo_status().into_protobuf(&col.i18n))
}) })
} }
} }

View File

@ -95,7 +95,7 @@ impl NotesService for Backend {
fn add_note_tags(&self, input: pb::AddNoteTagsIn) -> Result<pb::UInt32> { fn add_note_tags(&self, input: pb::AddNoteTagsIn) -> Result<pb::UInt32> {
self.with_col(|col| { self.with_col(|col| {
col.add_tags_to_notes(&to_nids(input.nids), &input.tags) col.add_tags_to_notes(&to_note_ids(input.nids), &input.tags)
.map(|n| n as u32) .map(|n| n as u32)
}) })
.map(Into::into) .map(Into::into)
@ -104,7 +104,7 @@ impl NotesService for Backend {
fn update_note_tags(&self, input: pb::UpdateNoteTagsIn) -> Result<pb::UInt32> { fn update_note_tags(&self, input: pb::UpdateNoteTagsIn) -> Result<pb::UInt32> {
self.with_col(|col| { self.with_col(|col| {
col.replace_tags_for_notes( col.replace_tags_for_notes(
&to_nids(input.nids), &to_note_ids(input.nids),
&input.tags, &input.tags,
&input.replacement, &input.replacement,
input.regex, input.regex,
@ -127,7 +127,7 @@ impl NotesService for Backend {
self.with_col(|col| { self.with_col(|col| {
col.transact(None, |col| { col.transact(None, |col| {
col.after_note_updates( col.after_note_updates(
&to_nids(input.nids), &to_note_ids(input.nids),
input.generate_cards, input.generate_cards,
input.mark_notes_modified, input.mark_notes_modified,
)?; )?;
@ -167,6 +167,6 @@ impl NotesService for Backend {
} }
} }
fn to_nids(ids: Vec<i64>) -> Vec<NoteID> { fn to_note_ids(ids: Vec<i64>) -> Vec<NoteID> {
ids.into_iter().map(NoteID).collect() ids.into_iter().map(NoteID).collect()
} }

View File

@ -1,20 +1,48 @@
// Copyright: Ankitects Pty Ltd and contributors // Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use crate::{backend_proto as pb, ops::StateChanges}; use pb::operation_info::{Changes, Kind};
impl From<StateChanges> for pb::StateChanges { use crate::{backend_proto as pb, ops::StateChanges, prelude::*, undo::UndoStatus};
impl From<StateChanges> for Changes {
fn from(c: StateChanges) -> Self { fn from(c: StateChanges) -> Self {
pb::StateChanges { Changes {
card_added: c.card_added, card: c.card,
card_modified: c.card_modified, note: c.note,
note_added: c.note_added, deck: c.deck,
note_modified: c.note_modified, tag: c.tag,
deck_added: c.deck_added, notetype: c.notetype,
deck_modified: c.deck_modified, preference: c.preference,
tag_modified: c.tag_modified, }
notetype_modified: c.notetype_modified, }
preference_modified: c.preference_modified, }
impl From<Op> for Kind {
fn from(o: Op) -> Self {
match o {
Op::SetFlag => Kind::SetCardFlag,
Op::UpdateTag => Kind::UpdateNoteTags,
_ => Kind::Other,
}
}
}
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 UndoStatus {
pub(crate) fn into_protobuf(self, i18n: &I18n) -> pb::UndoStatus {
pb::UndoStatus {
undo: self.undo.map(|op| op.describe(i18n)).unwrap_or_default(),
redo: self.redo.map(|op| op.describe(i18n)).unwrap_or_default(),
last_op: self.undo.map(Into::into),
} }
} }
} }

View File

@ -111,6 +111,15 @@ impl Card {
self.deck_id = deck; self.deck_id = deck;
} }
fn set_flag(&mut self, flag: u8) {
// we currently only allow 4 flags
assert!(flag < 5);
// but reserve space for 7, preserving the rest of
// the flags (up to a byte)
self.flags = (self.flags & !0b111) | flag
}
/// Return the total number of steps left to do, ignoring the /// Return the total number of steps left to do, ignoring the
/// "steps today" number packed into the DB representation. /// "steps today" number packed into the DB representation.
pub fn remaining_steps(&self) -> u32 { pub fn remaining_steps(&self) -> u32 {
@ -221,6 +230,24 @@ impl Collection {
}) })
} }
pub fn set_card_flag(&mut self, cards: &[CardID], flag: u32) -> Result<()> {
if flag > 4 {
return Err(AnkiError::invalid_input("invalid flag"));
}
let flag = flag as u8;
self.storage.set_search_table_to_card_ids(cards, false)?;
let usn = self.usn()?;
self.transact(Some(Op::SetFlag), |col| {
for mut card in col.storage.all_searched_cards()? {
let original = card.clone();
card.set_flag(flag);
col.update_card_inner(&mut card, original, usn)?;
}
Ok(())
})
}
/// Get deck config for the given card. If missing, return default values. /// Get deck config for the given card. If missing, return default values.
#[allow(dead_code)] #[allow(dead_code)]
pub(crate) fn deck_config_for_card(&mut self, card: &Card) -> Result<DeckConf> { pub(crate) fn deck_config_for_card(&mut self, card: &Card) -> Result<DeckConf> {

View File

@ -13,7 +13,9 @@ pub enum Op {
RemoveNote, RemoveNote,
RenameDeck, RenameDeck,
ScheduleAsNew, ScheduleAsNew,
SetDeck,
SetDueDate, SetDueDate,
SetFlag,
Suspend, Suspend,
UnburyUnsuspend, UnburyUnsuspend,
UpdateCard, UpdateCard,
@ -21,91 +23,11 @@ pub enum Op {
UpdateNote, UpdateNote,
UpdatePreferences, UpdatePreferences,
UpdateTag, UpdateTag,
SetDeck,
} }
impl Op { impl Op {
/// Used internally to decide whether the study queues need to be invalidated. pub fn describe(self, i18n: &I18n) -> String {
pub(crate) fn needs_study_queue_reset(self) -> bool { let key = match self {
let changes = self.state_changes();
self != Op::AnswerCard
&& (changes.card_added
|| changes.card_modified
|| changes.deck_modified
|| changes.preference_modified)
}
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 => StateChanges {
card_modified: true,
..default()
},
Op::AnswerCard => StateChanges {
card_modified: true,
// this also modifies the daily counts stored in the
// deck, but the UI does not care about that
..default()
},
Op::AddDeck => StateChanges {
deck_added: true,
..default()
},
Op::AddNote => StateChanges {
card_added: true,
note_added: true,
tag_modified: true,
..default()
},
Op::RemoveDeck => StateChanges {
card_modified: true,
note_modified: true,
deck_modified: true,
..default()
},
Op::RemoveNote => StateChanges {
card_modified: true,
note_modified: true,
..default()
},
Op::RenameDeck => StateChanges {
deck_modified: true,
..default()
},
Op::UpdateDeck => StateChanges {
deck_modified: true,
..default()
},
Op::UpdateNote => StateChanges {
note_modified: true,
// edits may result in new cards being generated
card_added: true,
// and may result in new tags being added
tag_modified: true,
..default()
},
Op::UpdatePreferences => StateChanges {
preference_modified: true,
..default()
},
Op::UpdateTag => StateChanges {
tag_modified: true,
..default()
},
}
}
}
impl Collection {
pub fn describe_op_kind(&self, op: Op) -> String {
let key = match op {
Op::AddDeck => TR::UndoAddDeck, Op::AddDeck => TR::UndoAddDeck,
Op::AddNote => TR::UndoAddNote, Op::AddNote => TR::UndoAddNote,
Op::AnswerCard => TR::UndoAnswerCard, Op::AnswerCard => TR::UndoAnswerCard,
@ -123,21 +45,94 @@ impl Collection {
Op::UpdatePreferences => TR::PreferencesPreferences, Op::UpdatePreferences => TR::PreferencesPreferences,
Op::UpdateTag => TR::UndoUpdateTag, Op::UpdateTag => TR::UndoUpdateTag,
Op::SetDeck => TR::BrowsingChangeDeck, Op::SetDeck => TR::BrowsingChangeDeck,
Op::SetFlag => TR::UndoSetFlag,
}; };
self.i18n.tr(key).to_string() 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)] #[derive(Debug, Default, Clone, Copy)]
pub struct StateChanges { pub struct StateChanges {
pub card_added: bool, pub card: bool,
pub card_modified: bool, pub note: bool,
pub note_added: bool, pub deck: bool,
pub note_modified: bool, pub tag: bool,
pub deck_added: bool, pub notetype: bool,
pub deck_modified: bool, pub preference: bool,
pub tag_modified: bool,
pub notetype_modified: bool,
pub preference_modified: bool,
} }

View File

@ -6,7 +6,6 @@ mod changes;
pub use crate::ops::Op; pub use crate::ops::Op;
pub(crate) use changes::UndoableChange; pub(crate) use changes::UndoableChange;
use crate::backend_proto as pb;
use crate::prelude::*; use crate::prelude::*;
use std::collections::VecDeque; use std::collections::VecDeque;
@ -32,6 +31,11 @@ impl Default for UndoMode {
} }
} }
pub struct UndoStatus {
pub undo: Option<Op>,
pub redo: Option<Op>,
}
#[derive(Debug, Default)] #[derive(Debug, Default)]
pub(crate) struct UndoManager { pub(crate) struct UndoManager {
// undo steps are added to the front of a double-ended queue, so we can // undo steps are added to the front of a double-ended queue, so we can
@ -75,6 +79,8 @@ impl UndoManager {
self.undo_steps.truncate(UNDO_LIMIT - 1); self.undo_steps.truncate(UNDO_LIMIT - 1);
self.undo_steps.push_front(step); self.undo_steps.push_front(step);
} }
} else {
println!("no undo changes, discarding step");
} }
} }
println!("ended, undo steps count now {}", self.undo_steps.len()); println!("ended, undo steps count now {}", self.undo_steps.len());
@ -141,22 +147,10 @@ impl Collection {
Ok(()) Ok(())
} }
pub fn undo_status(&self) -> pb::UndoStatus { pub fn undo_status(&self) -> UndoStatus {
pb::UndoStatus { UndoStatus {
undo: self undo: self.can_undo(),
.can_undo() redo: self.can_redo(),
.map(|op| self.describe_op_kind(op))
.unwrap_or_default(),
redo: self
.can_redo()
.map(|op| self.describe_op_kind(op))
.unwrap_or_default(),
changes: Some(
self.can_undo()
.map(|op| op.state_changes())
.unwrap_or_default()
.into(),
),
} }
} }