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 typing import List, Optional, Sequence
from typing import List, Sequence
import aqt
from anki.notes import NoteId
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.utils import (
HelpPage,
@ -22,63 +24,10 @@ from aqt.utils import (
save_combo_index_for_session,
save_is_checked,
saveGeom,
tooltip,
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):
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.ignoreCase, self.COMBO_NAME + "ignoreCase")
# tags?
if self.form.field.currentIndex() == 1:
# tags
find_and_replace_tag(
mw=self.mw,
parent=self.parentWidget(),
note_ids=self.note_ids,
search=search,
@ -157,23 +105,22 @@ class FindAndReplaceDialog(QDialog):
regex=regex,
match_case=match_case,
)
return
if self.form.field.currentIndex() == 0:
field = None
else:
field = self.field_names[self.form.field.currentIndex() - 2]
# fields
if self.form.field.currentIndex() == 0:
field = None
else:
field = self.field_names[self.form.field.currentIndex() - 2]
find_and_replace(
mw=self.mw,
parent=self.parentWidget(),
note_ids=self.note_ids,
search=search,
replacement=replace,
regex=regex,
field_name=field,
match_case=match_case,
)
find_and_replace(
parent=self.parentWidget(),
note_ids=self.note_ids,
search=search,
replacement=replace,
regex=regex,
field_name=field,
match_case=match_case,
)
super().accept()

View file

@ -52,11 +52,6 @@ from aqt.emptycards import show_empty_cards
from aqt.legacy import install_pylib_legacy
from aqt.mediacheck import check_media_db
from aqt.mediasync import MediaSyncer
from aqt.operations import (
CollectionOpFailureCallback,
CollectionOpSuccessCallback,
ResultWithChanges,
)
from aqt.operations.collection import undo
from aqt.profiles import ProfileManager as ProfileManagerType
from aqt.qt import *
@ -96,9 +91,6 @@ MainWindowState = Literal[
T = TypeVar("T")
PerformOpOptionalSuccessCallback = Optional[CollectionOpSuccessCallback]
PerformOpOptionalFailureCallback = Optional[CollectionOpFailureCallback]
class AnkiQt(QMainWindow):
col: Collection
@ -710,10 +702,9 @@ class AnkiQt(QMainWindow):
) -> None:
"""Run an operation that queries the DB on a background thread.
Similar interface to perform_op(), but intended to be used for operations
that do not change collection state. Undo status will not be changed,
and `operation_did_execute` will not fire. No progress window will
be shown either.
Intended to be used for operations that do not change collection
state. Undo status will not be changed, and `operation_did_execute`
will not fire. No progress window will be shown either.
`operations_will|did_execute` will still fire, so the UI can defer
updates during a background task.
@ -743,66 +734,6 @@ class AnkiQt(QMainWindow):
# 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:
if not self._background_op_count:
gui_hooks.backend_will_block()
@ -814,24 +745,6 @@ class AnkiQt(QMainWindow):
gui_hooks.backend_did_block()
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:
"""Fire the `operation_did_execute` hook with everything marked as changed,
after legacy code has called .reset()"""
@ -879,7 +792,7 @@ class AnkiQt(QMainWindow):
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."""
New code should use CollectionOp() instead."""
if self.col:
# fire new `operation_did_execute` hook first. If the overview
# 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]):
"""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
_failure: Optional[CollectionOpFailureCallback] = None
_failure: Optional[Optional[Callable[[Exception], Any]]] = None
def __init__(self, parent: QWidget, op: Callable[[Collection], ResultWithChanges]):
self._parent = parent
@ -78,19 +75,25 @@ class CollectionOp(Generic[ResultWithChanges]):
return self
def failure(
self, failure: Optional[CollectionOpFailureCallback]
self, failure: Optional[Optional[Callable[[Exception], Any]]]
) -> CollectionOp[ResultWithChanges]:
self._failure = failure
return self
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:
return self._op(aqt.mw.col)
assert mw
return self._op(mw.col)
def wrapped_done(future: Future) -> None:
aqt.mw._decrease_background_ops()
assert mw
mw._decrease_background_ops()
# did something go wrong?
if exception := future.exception():
if isinstance(exception, Exception):
@ -109,18 +112,22 @@ class CollectionOp(Generic[ResultWithChanges]):
self._success(result)
finally:
# update undo status
status = aqt.mw.col.undo_status()
aqt.mw._update_undo_actions_for_status_and_save(status)
status = mw.col.undo_status()
mw._update_undo_actions_for_status_and_save(status)
# fire change hooks
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(
self,
result: ResultWithChanges,
handler: Optional[object],
) -> None:
from aqt import mw
assert mw
if isinstance(result, OpChanges):
changes = result
else:
@ -131,5 +138,5 @@ class CollectionOp(Generic[ResultWithChanges]):
print(changes)
aqt.gui_hooks.operation_did_execute(changes, handler)
# 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()

View file

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

View file

@ -3,7 +3,7 @@
from __future__ import annotations
from typing import Sequence
from typing import Optional, Sequence
from anki.collection import OpChanges, OpChangesWithCount
from anki.decks import DeckId
@ -34,3 +34,31 @@ def remove_notes(
return CollectionOp(parent, lambda col: col.remove_notes(note_ids)).success(
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],
config_key: Optional[Config.String.Key.V],
) -> Optional[CollectionOp[OpChanges]]:
assert aqt.mw
if not card_ids:
return None
@ -76,7 +77,9 @@ def reposition_new_cards_dialog(
) -> Optional[CollectionOp[OpChangesWithCount]]:
from aqt import mw
assert mw
assert mw.col.db
row = mw.col.db.first(
f"select min(due), max(due) from cards where type={CARD_TYPE_NEW} and odid=0"
)

View file

@ -90,3 +90,29 @@ def set_tag_collapsed(
return CollectionOp(
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.
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

View file

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

View file

@ -451,7 +451,7 @@ hooks = [
Hook(
name="state_did_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.
""",
@ -476,7 +476,7 @@ hooks = [
),
Hook(
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
completes, as doing so will freeze the UI until the long-running operation
@ -485,7 +485,7 @@ hooks = [
),
Hook(
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
there are no outstanding ops.
""",

View file

@ -163,7 +163,7 @@ impl From<String> for Variable {
let kind = match name.as_str() {
"cards" | "notes" | "count" | "amount" | "reviews" | "total" | "selected"
| "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,
"val" | "found" | "expected" | "part" | "percent" | "day" | "number" | "up"
| "down" | "seconds" | "megs" => VariableKind::Any,