anki/qt/aqt/operations/__init__.py
Abdo 98715e593a
Improve presentation of importing results (#2568)
* Implement import log screen in Svelte

* Show filename in import log screen title

* Remove unused NoteRow property

* Show number of imported notes

* Use a single nid expression

* Use 'count' as variable name for consistency

* Import from @tslib/backend instead

* Fix summary_template typing

* Fix clippy warning

* Apply suggestions from code review

* Fix imports

* Contents -> Fields

* Increase max length of browser search bar

https://github.com/ankitects/anki/pull/2568/files#r1255227035

* Fix race condition in Bootstrap tooltip destruction

https://github.com/twbs/bootstrap/issues/37474

* summary_template -> summaryTemplate

* Make show link a button

* Run import ops on Svelte side

* Fix geometry not being restored in CSV Import page

* Make VirtualTable fill available height

* Keep CSV dialog modal

* Reword importing-existing-notes-skipped

* Avoid mentioning matching based on first field

* Change tick and cross icons

* List skipped notes last

* Pure CSS spinner

* Move set_wants_abort() call to relevant dialogs

* Show number of imported cards

* Remove bold from first sentence and indent summaries

* Update UI after import operations

* Add close button to import log page

Also make virtual table react to resize event.

* Fix typing

* Make CSV dialog non-modal again

Otherwise user can't interact with browser window.

* Update window modality after import

* Commit DB and update undo actions after import op

* Split frontend proto into separate file, so backend can ignore it

Currently the automatically-generated frontend RPC methods get placed in
'backend.js' with all the backend methods; we could optionally split them
into a separate 'frontend.js' file in the future.

* Migrate import_done from a bridgecmd to a HTTP request

* Update plural form of importing-notes-added

* Move import response handling to mediasrv

* Move task callback to script section

* Avoid unnecessary :global()

* .log cannot be missing if result exists

* Move import log search handling to mediasrv

* Type common params of ImportLogDialog

* Use else if

* Remove console.log()

* Add way to test apkg imports in new log screen

* Remove unused import

* Get actual card count for CSV imports

* Use import type

* Fix typing error

* Ignore import log when checking for changes in Python layer

* Apply suggestions from code review

* Remove imported card count for now

* Avoid non-null assertion in assignment

* Change showInBrowser to take an array of notes

* Use dataclasses for import log args

* Simplify ResultWithChanges in TS

* Only abort import when window is modal

* Fix ResultWithChanges typing

* Fix Rust warnings

* Only log one duplicate per incoming note

* Update wording about note updates

* Remove caveat about found_notes

* Reduce font size

* Remove redundant map

* Give credit to loading.io

* Remove unused line

---------

Co-authored-by: RumovZ <gp5glkw78@relay.firefox.com>
2023-08-02 20:29:44 +10:00

273 lines
8.4 KiB
Python

# 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 concurrent.futures._base import Future
from typing import Any, Callable, Generic, Protocol, TypeVar, Union
import aqt
import aqt.gui_hooks
import aqt.main
from anki.collection import (
Collection,
ImportLogWithChanges,
OpChanges,
OpChangesAfterUndo,
OpChangesOnly,
OpChangesWithCount,
OpChangesWithId,
Progress,
)
from aqt.errors import show_exception
from aqt.progress import ProgressUpdate
from aqt.qt import QWidget
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,
OpChangesOnly,
OpChangesWithCount,
OpChangesWithId,
OpChangesAfterUndo,
ImportLogWithChanges,
HasChangesProperty,
],
)
class CollectionOp(Generic[ResultWithChanges]):
"""Helper to perform a mutating DB operation on a background thread, and update UI.
`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.
"""
_success: Callable[[ResultWithChanges], Any] | None = None
_failure: Callable[[Exception], Any] | None = None
_progress_update: Callable[[Progress, ProgressUpdate], None] | None = None
def __init__(self, parent: QWidget, op: Callable[[Collection], ResultWithChanges]):
self._parent = parent
self._op = op
def success(
self, success: Callable[[ResultWithChanges], Any] | None
) -> CollectionOp[ResultWithChanges]:
self._success = success
return self
def failure(
self, failure: Callable[[Exception], Any] | None
) -> CollectionOp[ResultWithChanges]:
self._failure = failure
return self
def with_backend_progress(
self, progress_update: Callable[[Progress, ProgressUpdate], None] | None
) -> CollectionOp[ResultWithChanges]:
self._progress_update = progress_update
return self
def run_in_background(self, *, initiator: object | None = None) -> None:
from aqt import mw
assert mw
mw._increase_background_ops()
def wrapped_op() -> ResultWithChanges:
assert mw
return self._op(mw.col)
def wrapped_done(future: Future) -> None:
assert mw
mw._decrease_background_ops()
# did something go wrong?
if exception := future.exception():
if isinstance(exception, Exception):
if self._failure:
self._failure(exception)
else:
show_exception(parent=self._parent, exception=exception)
return
else:
# BaseException like SystemExit; rethrow it
future.result()
result = future.result()
try:
if self._success:
self._success(result)
finally:
on_op_finished(mw, result, initiator)
self._run(mw, wrapped_op, wrapped_done)
def _run(
self,
mw: aqt.main.AnkiQt,
op: Callable[[], ResultWithChanges],
on_done: Callable[[Future], None],
) -> None:
if self._progress_update:
mw.taskman.with_backend_progress(
op, self._progress_update, on_done=on_done, parent=self._parent
)
else:
mw.taskman.with_progress(op, on_done, parent=self._parent)
def on_op_finished(
mw: aqt.main.AnkiQt, result: ResultWithChanges, initiator: object | None
) -> None:
mw.update_undo_actions()
mw.autosave()
if isinstance(result, OpChanges):
changes = result
else:
changes = result.changes # type: ignore[union-attr]
# fire new hook
aqt.gui_hooks.operation_did_execute(changes, initiator)
# fire legacy hook so old code notices changes
if mw.col.op_made_changes(changes):
aqt.gui_hooks.state_did_reset()
T = TypeVar("T")
class QueryOp(Generic[T]):
"""Helper to perform an operation on a background thread.
QueryOp is primarily used for read-only requests (reading information
from the database, fetching data from the network, etc), but can also
be used for mutable requests outside of the collection undo system
(eg adding/deleting files, calling a collection method that doesn't support
undo, etc). For operations that support undo, use CollectionOp instead.
- Optionally 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
Be careful not to call any UI routines in `op`, as that may crash Qt.
This includes things like .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.
"""
_failure: Callable[[Exception], Any] | None = None
_progress: bool | str = False
_progress_update: Callable[[Progress, ProgressUpdate], None] | None = None
def __init__(
self,
*,
parent: QWidget,
op: Callable[[Collection], T],
success: Callable[[T], Any],
):
self._parent = parent
self._op = op
self._success = success
def failure(self, failure: Callable[[Exception], Any] | None) -> QueryOp[T]:
self._failure = failure
return self
def with_progress(
self,
label: str | None = None,
) -> QueryOp[T]:
"If label not provided, will default to 'Processing...'"
self._progress = label or True
return self
def with_backend_progress(
self, progress_update: Callable[[Progress, ProgressUpdate], None] | None
) -> QueryOp[T]:
self._progress_update = progress_update
return self
def run_in_background(self) -> None:
from aqt import mw
assert mw
mw._increase_background_ops()
def wrapped_op() -> T:
assert mw
return self._op(mw.col)
def wrapped_done(future: Future) -> None:
assert mw
mw._decrease_background_ops()
# did something go wrong?
if exception := future.exception():
if isinstance(exception, Exception):
if self._failure:
self._failure(exception)
else:
show_exception(parent=self._parent, exception=exception)
return
else:
# BaseException like SystemExit; rethrow it
future.result()
self._success(future.result())
self._run(mw, wrapped_op, wrapped_done)
def _run(
self,
mw: aqt.main.AnkiQt,
op: Callable[[], T],
on_done: Callable[[Future], None],
) -> None:
label = self._progress if isinstance(self._progress, str) else None
if self._progress_update:
mw.taskman.with_backend_progress(
op,
self._progress_update,
on_done=on_done,
start_label=label,
parent=self._parent,
)
elif self._progress:
mw.taskman.with_progress(op, on_done, label=label, parent=self._parent)
else:
mw.taskman.run_in_background(op, on_done)