update find&replace, and remove perform_op()

This commit is contained in:
Damien Elmes 2021-04-06 17:07:38 +10:00
parent 84fe309583
commit 5676ad5101
11 changed files with 108 additions and 191 deletions

View file

@ -3,11 +3,13 @@
from __future__ import annotations from __future__ import annotations
from typing import List, Optional, Sequence from typing import List, Sequence
import aqt import aqt
from anki.notes import NoteId from anki.notes import NoteId
from aqt import AnkiQt, QWidget from aqt import AnkiQt, QWidget
from aqt.operations.note import find_and_replace
from aqt.operations.tag import find_and_replace_tag
from aqt.qt import QDialog, Qt from aqt.qt import QDialog, Qt
from aqt.utils import ( from aqt.utils import (
HelpPage, HelpPage,
@ -22,63 +24,10 @@ from aqt.utils import (
save_combo_index_for_session, save_combo_index_for_session,
save_is_checked, save_is_checked,
saveGeom, saveGeom,
tooltip,
tr, tr,
) )
def find_and_replace(
*,
mw: AnkiQt,
parent: QWidget,
note_ids: Sequence[NoteId],
search: str,
replacement: str,
regex: bool,
field_name: Optional[str],
match_case: bool,
) -> None:
mw.perform_op(
lambda: mw.col.find_and_replace(
note_ids=note_ids,
search=search,
replacement=replacement,
regex=regex,
field_name=field_name,
match_case=match_case,
),
success=lambda out: tooltip(
tr.findreplace_notes_updated(changed=out.count, total=len(note_ids)),
parent=parent,
),
)
def find_and_replace_tag(
*,
mw: AnkiQt,
parent: QWidget,
note_ids: Sequence[int],
search: str,
replacement: str,
regex: bool,
match_case: bool,
) -> None:
mw.perform_op(
lambda: mw.col.tags.find_and_replace(
note_ids=note_ids,
search=search,
replacement=replacement,
regex=regex,
match_case=match_case,
),
success=lambda out: tooltip(
tr.findreplace_notes_updated(changed=out.count, total=len(note_ids)),
parent=parent,
),
)
class FindAndReplaceDialog(QDialog): class FindAndReplaceDialog(QDialog):
COMBO_NAME = "BrowserFindAndReplace" COMBO_NAME = "BrowserFindAndReplace"
@ -146,10 +95,9 @@ class FindAndReplaceDialog(QDialog):
save_is_checked(self.form.re, self.COMBO_NAME + "Regex") save_is_checked(self.form.re, self.COMBO_NAME + "Regex")
save_is_checked(self.form.ignoreCase, self.COMBO_NAME + "ignoreCase") save_is_checked(self.form.ignoreCase, self.COMBO_NAME + "ignoreCase")
# tags?
if self.form.field.currentIndex() == 1: if self.form.field.currentIndex() == 1:
# tags
find_and_replace_tag( find_and_replace_tag(
mw=self.mw,
parent=self.parentWidget(), parent=self.parentWidget(),
note_ids=self.note_ids, note_ids=self.note_ids,
search=search, search=search,
@ -157,15 +105,14 @@ class FindAndReplaceDialog(QDialog):
regex=regex, regex=regex,
match_case=match_case, match_case=match_case,
) )
return else:
# fields
if self.form.field.currentIndex() == 0: if self.form.field.currentIndex() == 0:
field = None field = None
else: else:
field = self.field_names[self.form.field.currentIndex() - 2] field = self.field_names[self.form.field.currentIndex() - 2]
find_and_replace( find_and_replace(
mw=self.mw,
parent=self.parentWidget(), parent=self.parentWidget(),
note_ids=self.note_ids, note_ids=self.note_ids,
search=search, search=search,

View file

@ -52,11 +52,6 @@ from aqt.emptycards import show_empty_cards
from aqt.legacy import install_pylib_legacy from aqt.legacy import install_pylib_legacy
from aqt.mediacheck import check_media_db from aqt.mediacheck import check_media_db
from aqt.mediasync import MediaSyncer from aqt.mediasync import MediaSyncer
from aqt.operations import (
CollectionOpFailureCallback,
CollectionOpSuccessCallback,
ResultWithChanges,
)
from aqt.operations.collection import undo from aqt.operations.collection import undo
from aqt.profiles import ProfileManager as ProfileManagerType from aqt.profiles import ProfileManager as ProfileManagerType
from aqt.qt import * from aqt.qt import *
@ -96,9 +91,6 @@ MainWindowState = Literal[
T = TypeVar("T") T = TypeVar("T")
PerformOpOptionalSuccessCallback = Optional[CollectionOpSuccessCallback]
PerformOpOptionalFailureCallback = Optional[CollectionOpFailureCallback]
class AnkiQt(QMainWindow): class AnkiQt(QMainWindow):
col: Collection col: Collection
@ -710,10 +702,9 @@ class AnkiQt(QMainWindow):
) -> None: ) -> None:
"""Run an operation that queries the DB on a background thread. """Run an operation that queries the DB on a background thread.
Similar interface to perform_op(), but intended to be used for operations Intended to be used for operations that do not change collection
that do not change collection state. Undo status will not be changed, state. Undo status will not be changed, and `operation_did_execute`
and `operation_did_execute` will not fire. No progress window will will not fire. No progress window will be shown either.
be shown either.
`operations_will|did_execute` will still fire, so the UI can defer `operations_will|did_execute` will still fire, so the UI can defer
updates during a background task. updates during a background task.
@ -743,66 +734,6 @@ class AnkiQt(QMainWindow):
# Resetting state # Resetting state
########################################################################## ##########################################################################
def perform_op(
self,
op: Callable[[], ResultWithChanges],
*,
success: PerformOpOptionalSuccessCallback = None,
failure: PerformOpOptionalFailureCallback = None,
handler: Optional[object] = None,
) -> None:
"""Run the provided operation on a background thread.
op() should either return OpChanges, or an object with a 'changes'
property. The changes will be passed to `operation_did_execute` so that
the UI can decide whether it needs to update itself.
- Shows progress popup for the duration of the op.
- Ensures the browser doesn't try to redraw during the operation, which can lead
to a frozen UI
- Updates undo state at the end of the operation
- Commits changes
- Fires the `operation_(will|did)_reset` hooks
- Fires the legacy `state_did_reset` hook
Be careful not to call any UI routines in `op`, as that may crash Qt.
This includes things select .selectedCards() in the browse screen.
success() will be called with the return value of op().
If op() throws an exception, it will be shown in a popup, or
passed to failure() if it is provided.
"""
self._increase_background_ops()
def wrapped_done(future: Future) -> None:
self._decrease_background_ops()
# did something go wrong?
if exception := future.exception():
if isinstance(exception, Exception):
if failure:
failure(exception)
else:
showWarning(str(exception))
return
else:
# BaseException like SystemExit; rethrow it
future.result()
result = future.result()
try:
if success:
success(result)
finally:
# update undo status
status = self.col.undo_status()
self._update_undo_actions_for_status_and_save(status)
# fire change hooks
self._fire_change_hooks_after_op_performed(result, handler)
self.taskman.with_progress(op, wrapped_done)
def _increase_background_ops(self) -> None: def _increase_background_ops(self) -> None:
if not self._background_op_count: if not self._background_op_count:
gui_hooks.backend_will_block() gui_hooks.backend_will_block()
@ -814,24 +745,6 @@ class AnkiQt(QMainWindow):
gui_hooks.backend_did_block() gui_hooks.backend_did_block()
assert self._background_op_count >= 0 assert self._background_op_count >= 0
def _fire_change_hooks_after_op_performed(
self,
result: ResultWithChanges,
handler: Optional[object],
) -> 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, handler)
# 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: def _synthesize_op_did_execute_from_reset(self) -> None:
"""Fire the `operation_did_execute` hook with everything marked as changed, """Fire the `operation_did_execute` hook with everything marked as changed,
after legacy code has called .reset()""" after legacy code has called .reset()"""
@ -879,7 +792,7 @@ class AnkiQt(QMainWindow):
def reset(self, unused_arg: bool = False) -> None: def reset(self, unused_arg: bool = False) -> None:
"""Legacy method of telling UI to refresh after changes made to DB. """Legacy method of telling UI to refresh after changes made to DB.
New code should use mw.perform_op() instead.""" New code should use CollectionOp() instead."""
if self.col: if self.col:
# fire new `operation_did_execute` hook first. If the overview # fire new `operation_did_execute` hook first. If the overview
# or review screen are currently open, they will rebuild the study # or review screen are currently open, they will rebuild the study

View file

@ -36,9 +36,6 @@ ResultWithChanges = TypeVar(
], ],
) )
CollectionOpSuccessCallback = Callable[[ResultWithChanges], Any]
CollectionOpFailureCallback = Optional[Callable[[Exception], Any]]
class CollectionOp(Generic[ResultWithChanges]): class CollectionOp(Generic[ResultWithChanges]):
"""Helper to perform a mutating DB operation on a background thread, and update UI. """Helper to perform a mutating DB operation on a background thread, and update UI.
@ -65,7 +62,7 @@ class CollectionOp(Generic[ResultWithChanges]):
""" """
_success: Optional[Callable[[ResultWithChanges], Any]] = None _success: Optional[Callable[[ResultWithChanges], Any]] = None
_failure: Optional[CollectionOpFailureCallback] = None _failure: Optional[Optional[Callable[[Exception], Any]]] = None
def __init__(self, parent: QWidget, op: Callable[[Collection], ResultWithChanges]): def __init__(self, parent: QWidget, op: Callable[[Collection], ResultWithChanges]):
self._parent = parent self._parent = parent
@ -78,19 +75,25 @@ class CollectionOp(Generic[ResultWithChanges]):
return self return self
def failure( def failure(
self, failure: Optional[CollectionOpFailureCallback] self, failure: Optional[Optional[Callable[[Exception], Any]]]
) -> CollectionOp[ResultWithChanges]: ) -> CollectionOp[ResultWithChanges]:
self._failure = failure self._failure = failure
return self return self
def run_in_background(self, *, initiator: Optional[object] = None) -> None: def run_in_background(self, *, initiator: Optional[object] = None) -> None:
aqt.mw._increase_background_ops() from aqt import mw
assert mw
mw._increase_background_ops()
def wrapped_op() -> ResultWithChanges: def wrapped_op() -> ResultWithChanges:
return self._op(aqt.mw.col) assert mw
return self._op(mw.col)
def wrapped_done(future: Future) -> None: def wrapped_done(future: Future) -> None:
aqt.mw._decrease_background_ops() assert mw
mw._decrease_background_ops()
# did something go wrong? # did something go wrong?
if exception := future.exception(): if exception := future.exception():
if isinstance(exception, Exception): if isinstance(exception, Exception):
@ -109,18 +112,22 @@ class CollectionOp(Generic[ResultWithChanges]):
self._success(result) self._success(result)
finally: finally:
# update undo status # update undo status
status = aqt.mw.col.undo_status() status = mw.col.undo_status()
aqt.mw._update_undo_actions_for_status_and_save(status) mw._update_undo_actions_for_status_and_save(status)
# fire change hooks # fire change hooks
self._fire_change_hooks_after_op_performed(result, initiator) self._fire_change_hooks_after_op_performed(result, initiator)
aqt.mw.taskman.with_progress(wrapped_op, wrapped_done) mw.taskman.with_progress(wrapped_op, wrapped_done)
def _fire_change_hooks_after_op_performed( def _fire_change_hooks_after_op_performed(
self, self,
result: ResultWithChanges, result: ResultWithChanges,
handler: Optional[object], handler: Optional[object],
) -> None: ) -> None:
from aqt import mw
assert mw
if isinstance(result, OpChanges): if isinstance(result, OpChanges):
changes = result changes = result
else: else:
@ -131,5 +138,5 @@ class CollectionOp(Generic[ResultWithChanges]):
print(changes) print(changes)
aqt.gui_hooks.operation_did_execute(changes, handler) aqt.gui_hooks.operation_did_execute(changes, handler)
# fire legacy hook so old code notices changes # fire legacy hook so old code notices changes
if aqt.mw.col.op_made_changes(changes): if mw.col.op_made_changes(changes):
aqt.gui_hooks.state_did_reset() aqt.gui_hooks.state_did_reset()

View file

@ -31,6 +31,9 @@ def undo(*, parent: QWidget) -> None:
def _legacy_undo(*, parent: QWidget) -> None: def _legacy_undo(*, parent: QWidget) -> None:
from aqt import mw from aqt import mw
assert mw
assert mw.col
reviewing = mw.state == "review" reviewing = mw.state == "review"
just_refresh_reviewer = False just_refresh_reviewer = False

View file

@ -3,7 +3,7 @@
from __future__ import annotations from __future__ import annotations
from typing import Sequence from typing import Optional, Sequence
from anki.collection import OpChanges, OpChangesWithCount from anki.collection import OpChanges, OpChangesWithCount
from anki.decks import DeckId from anki.decks import DeckId
@ -34,3 +34,31 @@ def remove_notes(
return CollectionOp(parent, lambda col: col.remove_notes(note_ids)).success( return CollectionOp(parent, lambda col: col.remove_notes(note_ids)).success(
lambda out: tooltip(tr.browsing_cards_deleted(count=out.count)), lambda out: tooltip(tr.browsing_cards_deleted(count=out.count)),
) )
def find_and_replace(
*,
parent: QWidget,
note_ids: Sequence[NoteId],
search: str,
replacement: str,
regex: bool,
field_name: Optional[str],
match_case: bool,
) -> CollectionOp[OpChangesWithCount]:
return CollectionOp(
parent,
lambda col: col.find_and_replace(
note_ids=note_ids,
search=search,
replacement=replacement,
regex=regex,
field_name=field_name,
match_case=match_case,
),
).success(
lambda out: tooltip(
tr.findreplace_notes_updated(changed=out.count, total=len(note_ids)),
parent=parent,
)
)

View file

@ -28,6 +28,7 @@ def set_due_date_dialog(
card_ids: Sequence[CardId], card_ids: Sequence[CardId],
config_key: Optional[Config.String.Key.V], config_key: Optional[Config.String.Key.V],
) -> Optional[CollectionOp[OpChanges]]: ) -> Optional[CollectionOp[OpChanges]]:
assert aqt.mw
if not card_ids: if not card_ids:
return None return None
@ -76,7 +77,9 @@ def reposition_new_cards_dialog(
) -> Optional[CollectionOp[OpChangesWithCount]]: ) -> Optional[CollectionOp[OpChangesWithCount]]:
from aqt import mw from aqt import mw
assert mw
assert mw.col.db assert mw.col.db
row = mw.col.db.first( row = mw.col.db.first(
f"select min(due), max(due) from cards where type={CARD_TYPE_NEW} and odid=0" f"select min(due), max(due) from cards where type={CARD_TYPE_NEW} and odid=0"
) )

View file

@ -90,3 +90,29 @@ def set_tag_collapsed(
return CollectionOp( return CollectionOp(
parent, lambda col: col.tags.set_collapsed(tag=tag, collapsed=collapsed) parent, lambda col: col.tags.set_collapsed(tag=tag, collapsed=collapsed)
) )
def find_and_replace_tag(
*,
parent: QWidget,
note_ids: Sequence[int],
search: str,
replacement: str,
regex: bool,
match_case: bool,
) -> CollectionOp[OpChangesWithCount]:
return CollectionOp(
parent,
lambda col: col.tags.find_and_replace(
note_ids=note_ids,
search=search,
replacement=replacement,
regex=regex,
match_case=match_case,
),
).success(
lambda out: tooltip(
tr.findreplace_notes_updated(changed=out.count, total=len(note_ids)),
parent=parent,
),
)

View file

@ -4,7 +4,7 @@
""" """
Helper for running tasks on background threads. Helper for running tasks on background threads.
See mw.query_op() and mw.perform_op() for slightly higher-level routines. See mw.query_op() and CollectionOp() for higher-level routines.
""" """
from __future__ import annotations from __future__ import annotations

View file

@ -9,17 +9,7 @@ check_untyped_defs = true
disallow_untyped_defs = True disallow_untyped_defs = True
strict_equality = true strict_equality = true
[mypy-aqt.scheduling_ops] [mypy-aqt.operations.*]
no_strict_optional = false
[mypy-aqt.note_ops]
no_strict_optional = false
[mypy-aqt.card_ops]
no_strict_optional = false
[mypy-aqt.deck_ops]
no_strict_optional = false
[mypy-aqt.find_and_replace]
no_strict_optional = false
[mypy-aqt.tag_ops]
no_strict_optional = false no_strict_optional = false
[mypy-aqt.winpaths] [mypy-aqt.winpaths]

View file

@ -451,7 +451,7 @@ hooks = [
Hook( Hook(
name="state_did_reset", name="state_did_reset",
legacy_hook="reset", legacy_hook="reset",
doc="""Legacy 'reset' hook. Called by mw.reset() and mw.perform_op() to redraw the UI. doc="""Legacy 'reset' hook. Called by mw.reset() and CollectionOp() to redraw the UI.
New code should use `operation_did_execute` instead. New code should use `operation_did_execute` instead.
""", """,
@ -476,7 +476,7 @@ hooks = [
), ),
Hook( Hook(
name="backend_will_block", name="backend_will_block",
doc="""Called before one or more operations are executed with mw.perform_op(). doc="""Called before one or more DB tasks are run in the background.
Subscribers can use this to set a flag to avoid DB queries until the operation Subscribers can use this to set a flag to avoid DB queries until the operation
completes, as doing so will freeze the UI until the long-running operation completes, as doing so will freeze the UI until the long-running operation
@ -485,7 +485,7 @@ hooks = [
), ),
Hook( Hook(
name="backend_did_block", name="backend_did_block",
doc="""Called after one or more operations are executed with mw.perform_op(). doc="""Called after one or more DB tasks finish running in the background.
Called regardless of the success of individual operations, and only called when Called regardless of the success of individual operations, and only called when
there are no outstanding ops. there are no outstanding ops.
""", """,

View file

@ -163,7 +163,7 @@ impl From<String> for Variable {
let kind = match name.as_str() { let kind = match name.as_str() {
"cards" | "notes" | "count" | "amount" | "reviews" | "total" | "selected" "cards" | "notes" | "count" | "amount" | "reviews" | "total" | "selected"
| "kilobytes" | "daysStart" | "daysEnd" | "days" | "secs-per-card" | "remaining" | "kilobytes" | "daysStart" | "daysEnd" | "days" | "secs-per-card" | "remaining"
| "hourStart" | "hourEnd" | "correct" | "decks" => VariableKind::Int, | "hourStart" | "hourEnd" | "correct" | "decks" | "changed" => VariableKind::Int,
"average-seconds" | "cards-per-minute" => VariableKind::Float, "average-seconds" | "cards-per-minute" => VariableKind::Float,
"val" | "found" | "expected" | "part" | "percent" | "day" | "number" | "up" "val" | "found" | "expected" | "part" | "percent" | "day" | "number" | "up"
| "down" | "seconds" | "megs" => VariableKind::Any, | "down" | "seconds" | "megs" => VariableKind::Any,