anki/qt/aqt/browser/browser.py

993 lines
34 KiB
Python
Raw Normal View History

2019-02-05 04:59:03 +01:00
# Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
from __future__ import annotations
import json
import re
from typing import Callable, Sequence
import aqt
import aqt.browser
import aqt.editor
2019-12-20 10:19:03 +01:00
import aqt.forms
import aqt.operations
from anki._legacy import deprecated
from anki.cards import Card, CardId
from anki.collection import Collection, Config, OpChanges, SearchNode
2019-12-20 10:19:03 +01:00
from anki.consts import *
from anki.errors import NotFoundError
2020-11-18 04:48:23 +01:00
from anki.lang import without_unicode_isolation
from anki.notes import NoteId
from anki.scheduler.base import ScheduleCardsAsNew
2021-03-18 03:06:45 +01:00
from anki.tags import MARKED_TAG
from anki.utils import is_mac
from aqt import AnkiQt, gui_hooks
from aqt.editor import Editor
Add apkg import/export on backend (#1743) * Add apkg export on backend * Filter out missing media-paths at write time * Make TagMatcher::new() infallible * Gather export data instead of copying directly * Revert changes to rslib/src/tags/ * Reuse filename_is_safe/check_filename_safe() * Accept func to produce MediaIter in export_apkg() * Only store file folder once in MediaIter * Use temporary tables for gathering export_apkg() now accepts a search instead of a deck id. Decks are gathered according to the matched notes' cards. * Use schedule_as_new() to reset cards * ExportData → ExchangeData * Ignore ascii case when filtering system tags * search_notes_cards_into_table → search_cards_of_notes_into_table * Start on apkg importing on backend * Fix due dates in days for apkg export * Refactor import-export/package - Move media and meta code into appropriate modules. - Normalize/check for normalization when deserializing media entries. * Add SafeMediaEntry for deserialized MediaEntries * Prepare media based on checksums - Ensure all existing media files are hashed. - Hash incoming files during preparation to detect conflicts. - Uniquify names of conflicting files with hash (not notetype id). - Mark media files as used while importing notes. - Finally copy used media. * Handle encoding in `replace_media_refs()` * Add trait to keep down cow boilerplate * Add notetypes immediately instaed of preparing * Move target_col into Context * Add notes immediately instaed of preparing * Note id, not guid of conflicting notes * Add import_decks() * decks_configs → deck_configs * Add import_deck_configs() * Add import_cards(), import_revlog() * Use dyn instead of generic for media_fn Otherwise, would have to pass None with type annotation in the default case. * Fix signature of import_apkg() * Fix search_cards_of_notes_into_table() * Test new functions in text.rs * Add roundtrip test for apkg (stub) * Keep source id of imported cards (or skip) * Keep source ids of imported revlog (or skip) * Try to keep source ids of imported notes * Make adding notetype with id undoable * Wrap apkg import in transaction * Keep source ids of imported deck configs (or skip) * Handle card due dates and original due/did * Fix importing cards/revlog Card ids are manually uniquified. * Factor out card importing * Refactor card and revlog importing * Factor out card importing Also handle missing parents . * Factor out note importing * Factor out media importing * Maybe upgrade scheduler of apkg * Fix parent deck gathering * Unconditionally import static media * Fix deck importing edge cases Test those edge cases, and add some global test helpers. * Test note importing * Let import_apkg() take a progress func * Expand roundtrip apkg test * Use fat pointer to avoid propogating generics * Fix progress_fn type * Expose apkg export/import on backend * Return note log when importing apkg * Fix archived collection name on apkg import * Add CollectionOpWithBackendProgress * Fix wrong Interrupted Exception being checked * Add ClosedCollectionOp * Add note ids to log and strip HTML * Update progress when checking incoming media too * Conditionally enable new importing in GUI * Fix all_checksums() for media import Entries of deleted files are nulled, not removed. * Make apkg exporting on backend abortable * Return number of notes imported from apkg * Fix exception printing for QueryOp as well * Add QueryOpWithBackendProgress Also support backend exporting progress. * Expose new apkg and colpkg exporting * Open transaction in insert_data() Was slowing down exporting by several orders of magnitude. * Handle zstd-compressed apkg * Add legacy arg to ExportAnkiPackage Currently not exposed on the frontend * Remove unused import in proto file * Add symlink for typechecking of import_export_pb2 * Avoid kwargs in pb message creation, so typechecking is not lost Protobuf's behaviour is rather subtle and I had to dig through the docs to figure it out: set a field on a submessage to automatically assign the submessage to the parent, or call SetInParent() to persist a default version of the field you specified. * Avoid re-exporting protobuf msgs we only use internally * Stop after one test failure mypy often fails much faster than pylint * Avoid an extra allocation when extracting media checksums * Update progress after prepare_media() finishes Otherwise the bulk of the import ends up being shown as "Checked: 0" in the progress window. * Show progress of note imports Note import is the slowest part, so showing progress here makes the UI feel more responsive. * Reset filtered decks at import time Before this change, filtered decks exported with scheduling remained filtered on import, and maybe_remove_from_filtered_deck() moved cards into them as their home deck, leading to errors during review. We may still want to provide a way to preserve filtered decks on import, but to do that we'll need to ensure we don't rewrite the home decks of cards, and we'll need to ensure the home decks are included as part of the import (or give an error if they're not). https://github.com/ankitects/anki/pull/1743/files#r839346423 * Fix a corner-case where due dates were shifted by a day This issue existed in the old Python code as well. We need to include the user's UTC offset in the exported file, or days_elapsed falls back on the v1 cutoff calculation, which may be a day earlier or later than the v2 calculation. * Log conflicting note in remapped nt case * take_fields() → into_fields() * Alias `[u8; 20]` with `Sha1Hash` * Truncate logged fields * Rework apkg note import tests - Use macros for more helpful errors. - Split monolith into unit tests. - Fix some unknown error with the previous test along the way. (Was failing after 969484de4388d225c9f17d94534b3ba0094c3568.) * Fix sorting of imported decks Also adjust the test, so it fails without the patch. It was only passing before, because the parent deck happened to come before the inconsistently capitalised child alphabetically. But we want all parent decks to be imported before their child decks, so their children can adopt their capitalisation. * target[_id]s → existing_card[_id]s * export_collection_extracting_media() → ... export_into_collection_file() * target_already_exists→card_ordinal_already_exists * Add search_cards_of_notes_into_table.sql * Imrove type of apkg export selector/limit * Remove redundant call to mod_schema() * Parent tooltips to mw * Fix a crash when truncating note text String::truncate() is a bit of a footgun, and I've hit this before too :-) * Remove ExportLimit in favour of separate classes * Remove OpWithBackendProgress and ClosedCollectionOp Backend progress logic is now in ProgressManager. QueryOp can be used for running on closed collection. Also fix aborting of colpkg exports, which slipped through in #1817. * Tidy up import log * Avoid QDialog.exec() * Default to excluding scheuling for deck list deck * Use IncrementalProgress in whole import_export code * Compare checksums when importing colpkgs * Avoid registering changes if hashes are not needed * ImportProgress::Collection → ImportProgress::File * Make downgrading apkgs depend on meta version * Generalise IncrementableProgress And use it in entire import_export code instead. * Fix type complexity lint * Take count_map for IncrementableProgress::get_inner * Replace import/export env with Shift click * Accept all args from update() for backend progress * Pass fields of ProgressUpdate explicitly * Move update_interval into IncrementableProgress * Outsource incrementing into Incrementor * Mutate ProgressUpdate in progress_update callback * Switch import/export legacy toggle to profile setting Shift would have been nice, but the existing shortcuts complicate things. If the user triggers an import with ctrl+shift+i, shift is unlikely to have been released by the time our code runs, meaning the user accidentally triggers the new code. We could potentially wait a while before bringing up the dialog, but then we're forced to guess at how long it will take the user to release the key. One alternative would be to use alt instead of shift, but then we need to trigger our shortcut when that key is pressed as well, and it could potentially cause a conflict with an add-on that already uses that combination. * Show extension in export dialog * Continue to provide separate options for schema 11+18 colpkg export * Default to colpkg export when using File>Export * Improve appearance of combo boxes when switching between apkg/colpkg + Deal with long deck names * Convert newlines to spaces when showing fields from import Ensures each imported note appears on a separate line * Don't separate total note count from the other summary lines This may come down to personal preference, but I feel the other counts are equally as important, and separating them feels like it makes it a bit easier to ignore them. * Fix 'deck not normal' error when importing a filtered deck for the 2nd time * Fix [Identical] being shown on first import * Revert "Continue to provide separate options for schema 11+18 colpkg export" This reverts commit 8f0b2c175f4794d642823b60414d142a12768441. Will use a different approach * Move legacy support into a separate exporter option; add to apkg export * Adjust 'too new' message to also apply to .apkg import case * Show a better message when attempting to import new apkg into old code Previously the user could end seeing a message like: UnicodeDecodeError: 'utf-8' codec can't decode byte 0xb5 in position 1: invalid start byte Unfortunately we can't retroactively fix this for older clients. * Hide legacy support option in older exporting screen * Reflect change from paths to fnames in type & name * Make imported decks normal at once Then skip special casing in update_deck(). Also skip updating description if new one is empty. Co-authored-by: Damien Elmes <gpg@ankiweb.net>
2022-05-02 13:12:46 +02:00
from aqt.exporting import ExportDialog as LegacyExportDialog
from aqt.import_export.exporting import ExportDialog
2021-04-03 08:26:10 +02:00
from aqt.operations.card import set_card_deck, set_card_flag
from aqt.operations.collection import redo, undo
2021-04-03 08:26:10 +02:00
from aqt.operations.note import remove_notes
from aqt.operations.scheduling import (
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
2021-03-16 05:26:42 +01:00
forget_cards,
2021-03-18 02:46:11 +01:00
reposition_new_cards_dialog,
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
2021-03-16 05:26:42 +01:00
set_due_date_dialog,
suspend_cards,
unsuspend_cards,
)
2021-04-03 08:26:10 +02:00
from aqt.operations.tag import (
add_tags_to_notes,
clear_unused_tags,
remove_tags_from_notes,
)
from aqt.qt import *
from aqt.sound import av_player
2021-03-29 12:24:24 +02:00
from aqt.switch import Switch
from aqt.undo import UndoActionsInfo
2019-12-23 01:34:10 +01:00
from aqt.utils import (
HelpPage,
KeyboardModifiersPressed,
add_ellipsis_to_action_label,
current_window,
ensure_editor_saved,
2019-12-23 01:34:10 +01:00
getTag,
2021-04-26 08:46:08 +02:00
no_arg_trigger,
2019-12-23 01:34:10 +01:00
openHelp,
qtMenuShortcutWorkaround,
restoreGeom,
restoreSplitter,
restoreState,
saveGeom,
saveSplitter,
saveState,
showWarning,
skip_if_selection_is_empty,
tr,
2019-12-23 01:34:10 +01:00
)
from ..changenotetype import change_notetype_dialog
from .card_info import BrowserCardInfo
from .find_and_replace import FindAndReplaceDialog
from .previewer import BrowserPreviewer as PreviewDialog
from .previewer import Previewer
from .sidebar import SidebarTreeView
from .table import Table
class MockModel:
"""This class only exists to support some legacy aliases."""
def __init__(self, browser: aqt.browser.Browser) -> None:
self.browser = browser
@deprecated(replaced_by=aqt.operations.CollectionOp)
def beginReset(self) -> None:
self.browser.begin_reset()
@deprecated(replaced_by=aqt.operations.CollectionOp)
def endReset(self) -> None:
self.browser.end_reset()
@deprecated(replaced_by=aqt.operations.CollectionOp)
def reset(self) -> None:
self.browser.begin_reset()
self.browser.end_reset()
class Browser(QMainWindow):
2019-12-20 08:55:19 +01:00
mw: AnkiQt
2020-05-20 09:56:52 +02:00
col: Collection
editor: Editor | None
table: Table
def __init__(
self,
mw: AnkiQt,
card: Card | None = None,
search: tuple[str | SearchNode] | None = None,
) -> None:
2021-02-02 09:48:55 +01:00
"""
card -- try to select the provided card after executing "search" or
"deck:current" (if "search" was None)
search -- set and perform search; caller must ensure validity
2021-02-02 09:48:55 +01:00
"""
QMainWindow.__init__(self, None, Qt.WindowType.Window)
self.mw = mw
self.col = self.mw.col
self.lastFilter = ""
self.focusTo: int | None = None
self._previewer: Previewer | None = None
self._card_info = BrowserCardInfo(self.mw)
self._closeEventHasCleanedUp = False
self.form = aqt.forms.browser.Ui_Dialog()
self.form.setupUi(self)
restoreGeom(self, "editor", 0)
restoreState(self, "editor")
restoreSplitter(self.form.splitter, "editor3")
self.form.splitter.setChildrenCollapsible(False)
# set if exactly 1 row is selected; used by the previewer
self.card: Card | None = None
self.current_card: Card | None = None
self.setupSidebar()
self.setup_table()
self.setupMenus()
self.setupHooks()
self.setupEditor()
# disable undo/redo
self.on_undo_state_change(mw.undo_actions_info())
# legacy alias
self.model = MockModel(self)
2020-02-29 17:02:51 +01:00
gui_hooks.browser_will_show(self)
self.setupSearch(card, search)
self.show()
def on_operation_did_execute(
self, changes: OpChanges, handler: object | None
) -> None:
focused = current_window() == self
self.table.op_executed(changes, handler, focused)
self.sidebar.op_executed(changes, handler, focused)
if changes.note_text:
if handler is not self.editor:
# fixme: this will leave the splitter shown, but with no current
# note being edited
note = self.editor.note
if note:
try:
note.load()
except NotFoundError:
self.editor.set_note(None)
return
self.editor.set_note(note)
if changes.browser_table and changes.card:
self.card = self.table.get_single_selected_card()
self.current_card = self.table.get_current_card()
self._update_card_info()
self._update_current_actions()
2021-07-26 07:28:38 +02:00
# changes.card is required for updating flag icon
if changes.note_text or changes.card:
self._renderPreview()
def on_focus_change(self, new: QWidget | None, old: QWidget | None) -> None:
if current_window() == self:
self.setUpdatesEnabled(True)
self.table.redraw_cells()
self.sidebar.refresh_if_needed()
def setupMenus(self) -> None:
# actions
f = self.form
# edit
qconnect(f.actionUndo.triggered, self.undo)
qconnect(f.actionRedo.triggered, self.redo)
qconnect(f.actionInvertSelection.triggered, self.table.invert_selection)
qconnect(f.actionSelectNotes.triggered, self.selectNotes)
if not is_mac:
f.actionClose.setVisible(False)
qconnect(f.actionCreateFilteredDeck.triggered, self.createFilteredDeck)
f.actionCreateFilteredDeck.setShortcuts(["Ctrl+G", "Ctrl+Alt+G"])
# view
qconnect(f.actionFullScreen.triggered, self.mw.on_toggle_full_screen)
qconnect(
f.actionZoomIn.triggered,
lambda: self.editor.web.setZoomFactor(self.editor.web.zoomFactor() + 0.1),
)
qconnect(
f.actionZoomOut.triggered,
lambda: self.editor.web.setZoomFactor(self.editor.web.zoomFactor() - 0.1),
)
qconnect(
f.actionResetZoom.triggered,
lambda: self.editor.web.setZoomFactor(1),
)
# notes
qconnect(f.actionAdd.triggered, self.mw.onAddCard)
qconnect(f.actionCopy.triggered, self.on_create_copy)
2021-04-25 19:51:57 +02:00
qconnect(f.actionAdd_Tags.triggered, self.add_tags_to_selected_notes)
qconnect(f.actionRemove_Tags.triggered, self.remove_tags_from_selected_notes)
clear_unused_tags and browser redraw improvements - clear_unused_tags() is now undoable, and returns the number of removed notes - add a new mw.query_op() helper for immutable queries - decouple "freeze/unfreeze ui state" hooks from the "interface update required" hook, so that the former is fired even on error, and can be made re-entrant - use a 'block_updates' flag in Python, instead of setUpdatesEnabled(), as the latter has the side-effect of preventing child windows like tooltips from appearing, and forces a full redrawn when updates are enabled again. The new behaviour leads to the card list blanking out when a long-running op is running, but in the future if we cache the cell values we can just display them from the cache instead. - we were indiscriminately saving the note with saveNow(), due to the call to saveTags(). Changed so that it only saves when the tags field is focused. - drain the "on_done" queue on main before launching a new background task, to lower the chances of something in on_done making a small query to the DB and hanging until a long op finishes - the duplicate check in the editor was executed after the webview loads, leading to it hanging until the sidebar finishes loading. Run it at set_note() time instead, so that the editor loads first. - don't throw an error when a long-running op started with with_progress() finishes after the window it was launched from has closed - don't throw an error when the browser is closed before the sidebar has finished loading
2021-03-17 12:27:42 +01:00
qconnect(f.actionClear_Unused_Tags.triggered, self.clear_unused_tags)
2021-03-31 10:05:44 +02:00
qconnect(f.actionToggle_Mark.triggered, self.toggle_mark_of_selected_notes)
qconnect(f.actionChangeModel.triggered, self.onChangeModel)
qconnect(f.actionFindDuplicates.triggered, self.onFindDupes)
qconnect(f.actionFindReplace.triggered, self.onFindReplace)
qconnect(f.actionManage_Note_Types.triggered, self.mw.onNoteTypes)
qconnect(f.actionDelete.triggered, self.delete_selected_notes)
# cards
qconnect(f.actionChange_Deck.triggered, self.set_deck_of_selected_cards)
qconnect(f.action_Info.triggered, self.showCardInfo)
qconnect(f.actionReposition.triggered, self.reposition)
qconnect(f.action_set_due_date.triggered, self.set_due_date)
qconnect(f.action_forget.triggered, self.forget_cards)
2021-03-04 13:14:35 +01:00
qconnect(f.actionToggle_Suspend.triggered, self.suspend_selected_cards)
def set_flag_func(desired_flag: int) -> Callable:
return lambda: self.set_flag_of_selected_cards(desired_flag)
for flag in self.mw.flags.all():
qconnect(
getattr(self.form, flag.action).triggered, set_flag_func(flag.index)
)
self._update_flag_labels()
2021-04-25 19:51:57 +02:00
qconnect(f.actionExport.triggered, self._on_export_notes)
# jumps
qconnect(f.actionPreviousCard.triggered, self.onPreviousCard)
qconnect(f.actionNextCard.triggered, self.onNextCard)
qconnect(f.actionFirstCard.triggered, self.onFirstCard)
qconnect(f.actionLastCard.triggered, self.onLastCard)
qconnect(f.actionFind.triggered, self.onFind)
qconnect(f.actionNote.triggered, self.onNote)
qconnect(f.actionSidebar.triggered, self.focusSidebar)
qconnect(f.actionCardList.triggered, self.onCardList)
# help
qconnect(f.actionGuide.triggered, self.onHelp)
# keyboard shortcut for shift+home/end
self.pgUpCut = QShortcut(QKeySequence("Shift+Home"), self)
qconnect(self.pgUpCut.activated, self.onFirstCard)
self.pgDownCut = QShortcut(QKeySequence("Shift+End"), self)
qconnect(self.pgDownCut.activated, self.onLastCard)
# add-on hook
gui_hooks.browser_menus_did_init(self)
self.mw.maybeHideAccelerators(self)
add_ellipsis_to_action_label(f.actionCopy)
add_ellipsis_to_action_label(f.action_forget)
2021-02-01 00:39:55 +01:00
def closeEvent(self, evt: QCloseEvent) -> None:
if self._closeEventHasCleanedUp:
evt.accept()
return
self.editor.call_after_note_saved(self._closeWindow)
evt.ignore()
2021-02-01 00:39:55 +01:00
def _closeWindow(self) -> None:
self._cleanup_preview()
self._card_info.close()
self.editor.cleanup()
2021-04-11 12:28:11 +02:00
self.table.cleanup()
self.sidebar.cleanup()
saveSplitter(self.form.splitter, "editor3")
saveGeom(self, "editor")
saveState(self, "editor")
self.teardownHooks()
self.mw.maybeReset()
aqt.dialogs.markClosed("Browser")
self._closeEventHasCleanedUp = True
self.mw.deferred_delete_and_garbage_collect(self)
self.close()
@ensure_editor_saved
2021-02-01 00:39:55 +01:00
def closeWithCallback(self, onsuccess: Callable) -> None:
self._closeWindow()
onsuccess()
2021-02-01 00:39:55 +01:00
def keyPressEvent(self, evt: QKeyEvent) -> None:
if evt.key() == Qt.Key.Key_Escape:
self.close()
else:
super().keyPressEvent(evt)
def reopen(
self,
_mw: AnkiQt,
card: Card | None = None,
search: tuple[str | SearchNode] | None = None,
) -> None:
if search is not None:
self.search_for_terms(*search)
self.form.searchEdit.setFocus()
if card is not None:
if search is None:
# implicitly assume 'card' is in the current deck
self._default_search(card)
self.form.searchEdit.setFocus()
self.table.select_single_card(card.id)
# Searching
######################################################################
def setupSearch(
self,
card: Card | None = None,
search: tuple[str | SearchNode] | None = None,
) -> None:
qconnect(self.form.searchEdit.lineEdit().returnPressed, self.onSearchActivated)
self.form.searchEdit.setCompleter(None)
self.form.searchEdit.lineEdit().setPlaceholderText(
2021-03-26 04:48:26 +01:00
tr.browsing_search_bar_hint()
2019-12-23 01:34:10 +01:00
)
self.form.searchEdit.addItems([""] + self.mw.pm.profile["searchHistory"])
if search is not None:
self.search_for_terms(*search)
else:
self._default_search(card)
self.form.searchEdit.setFocus()
if card:
self.table.select_single_card(card.id)
# search triggered by user
@ensure_editor_saved
2021-02-01 00:39:55 +01:00
def onSearchActivated(self) -> None:
text = self.current_search()
try:
normed = self.col.build_search_string(text)
except Exception as err:
showWarning(str(err))
else:
self.search_for(normed)
self.update_history()
def search_for(self, search: str, prompt: str | None = None) -> None:
2021-01-30 11:05:48 +01:00
"""Keep track of search string so that we reuse identical search when
refreshing, rather than whatever is currently in the search field.
Optionally set the search bar to a different text than the actual search.
"""
2020-10-19 20:37:17 +02:00
self._lastSearchTxt = search
prompt = search if prompt == None else prompt
self.form.searchEdit.setCurrentIndex(-1)
self.form.searchEdit.lineEdit().setText(prompt)
self.search()
def current_search(self) -> str:
return self.form.searchEdit.lineEdit().text()
2021-02-01 00:39:55 +01:00
def search(self) -> None:
"""Search triggered programmatically. Caller must have saved note first."""
try:
self.table.search(self._lastSearchTxt)
except Exception as err:
showWarning(str(err))
2021-02-01 00:39:55 +01:00
def update_history(self) -> None:
2019-12-23 01:34:10 +01:00
sh = self.mw.pm.profile["searchHistory"]
if self._lastSearchTxt in sh:
sh.remove(self._lastSearchTxt)
sh.insert(0, self._lastSearchTxt)
sh = sh[:30]
self.form.searchEdit.clear()
self.form.searchEdit.addItems(sh)
2019-12-23 01:34:10 +01:00
self.mw.pm.profile["searchHistory"] = sh
2020-12-22 11:08:47 +01:00
def updateTitle(self) -> None:
selected = self.table.len_selection()
cur = self.table.len()
tr_title = (
tr.browsing_window_title_notes
if self.table.is_notes_mode()
else tr.browsing_window_title
)
2019-12-23 01:34:10 +01:00
self.setWindowTitle(
without_unicode_isolation(tr_title(total=cur, selected=selected))
2019-12-23 01:34:10 +01:00
)
def search_for_terms(self, *search_terms: str | SearchNode) -> None:
search = self.col.build_search_string(*search_terms)
self.form.searchEdit.setEditText(search)
self.onSearchActivated()
2021-01-30 11:05:48 +01:00
def _default_search(self, card: Card | None = None) -> None:
default = self.col.get_config_string(Config.String.DEFAULT_SEARCH_TEXT)
if default.strip():
search = default
prompt = default
else:
search = self.col.build_search_string(SearchNode(deck="current"))
prompt = ""
if card is not None:
search = gui_hooks.default_search(search, card)
self.search_for(search, prompt)
2021-02-01 00:39:55 +01:00
def onReset(self) -> None:
self.sidebar.refresh()
self.begin_reset()
self.end_reset()
# caller must have called editor.saveNow() before calling this or .reset()
def begin_reset(self) -> None:
self.editor.set_note(None, hide=False)
self.mw.progress.start()
self.table.begin_reset()
def end_reset(self) -> None:
self.table.end_reset()
self.mw.progress.finish()
# Table & Editor
######################################################################
def setup_table(self) -> None:
self.table = Table(self)
self.table.set_view(self.form.tableView)
2021-03-29 12:24:24 +02:00
switch = Switch(11, tr.browsing_card_initial(), tr.browsing_note_initial())
switch.setChecked(self.table.is_notes_mode())
2021-04-06 11:41:18 +02:00
switch.setToolTip(tr.browsing_toggle_showing_cards_notes())
2021-03-31 18:53:36 +02:00
qconnect(self.form.action_toggle_mode.triggered, switch.toggle)
2021-03-29 12:24:24 +02:00
qconnect(switch.toggled, self.on_table_state_changed)
self.form.gridLayout.addWidget(switch, 0, 0)
2021-02-01 00:39:55 +01:00
def setupEditor(self) -> None:
2021-07-17 04:17:28 +02:00
QShortcut(QKeySequence("Ctrl+Shift+P"), self, self.onTogglePreview)
def add_preview_button(editor: Editor) -> None:
editor._links["preview"] = lambda _editor: self.onTogglePreview()
gui_hooks.editor_did_init.append(add_preview_button)
self.editor = aqt.editor.Editor(
self.mw,
self.form.fieldsArea,
self,
2022-01-12 06:01:43 +01:00
editor_mode=aqt.editor.EditorMode.BROWSER,
)
gui_hooks.editor_did_init.remove(add_preview_button)
@ensure_editor_saved
def on_all_or_selected_rows_changed(self) -> None:
"""Called after the selected or all rows (searching, toggling mode) have
changed. Update window title, card preview, context actions, and editor.
"""
if self._closeEventHasCleanedUp:
return
self.updateTitle()
# if there is only one selected card, use it in the editor
# it might differ from the current card
self.card = self.table.get_single_selected_card()
self.singleCard = bool(self.card)
self.form.splitter.widget(1).setVisible(self.singleCard)
if self.singleCard:
self.editor.set_note(self.card.note(), focusTo=self.focusTo)
self.focusTo = None
self.editor.card = self.card
else:
self.editor.set_note(None)
2021-03-30 22:06:58 +02:00
self._renderPreview()
self._update_row_actions()
self._update_selection_actions()
gui_hooks.browser_did_change_row(self)
@deprecated(info="please use on_all_or_selected_rows_changed() instead.")
def onRowChanged(self, *args: Any) -> None:
self.on_all_or_selected_rows_changed()
def on_current_row_changed(self) -> None:
"""Called after the row of the current element has changed."""
if self._closeEventHasCleanedUp:
return
self.current_card = self.table.get_current_card()
self._update_current_actions()
self._update_card_info()
2021-03-31 10:05:44 +02:00
def _update_row_actions(self) -> None:
has_rows = bool(self.table.len())
self.form.actionSelectAll.setEnabled(has_rows)
self.form.actionInvertSelection.setEnabled(has_rows)
self.form.actionFirstCard.setEnabled(has_rows)
self.form.actionLastCard.setEnabled(has_rows)
def _update_selection_actions(self) -> None:
has_selection = bool(self.table.len_selection())
self.form.actionSelectNotes.setEnabled(has_selection)
self.form.actionExport.setEnabled(has_selection)
self.form.actionAdd_Tags.setEnabled(has_selection)
self.form.actionRemove_Tags.setEnabled(has_selection)
self.form.actionToggle_Mark.setEnabled(has_selection)
self.form.actionChangeModel.setEnabled(has_selection)
self.form.actionDelete.setEnabled(has_selection)
self.form.actionChange_Deck.setEnabled(has_selection)
self.form.action_set_due_date.setEnabled(has_selection)
self.form.action_forget.setEnabled(has_selection)
self.form.actionReposition.setEnabled(has_selection)
self.form.actionToggle_Suspend.setEnabled(has_selection)
self.form.menuFlag.setEnabled(has_selection)
def _update_current_actions(self) -> None:
self._update_flags_menu()
self._update_toggle_mark_action()
self._update_toggle_suspend_action()
self.form.actionCopy.setEnabled(self.table.has_current())
self.form.action_Info.setEnabled(self.table.has_current())
self.form.actionPreviousCard.setEnabled(self.table.has_previous())
self.form.actionNextCard.setEnabled(self.table.has_next())
2021-03-29 12:24:24 +02:00
@ensure_editor_saved
def on_table_state_changed(self, checked: bool) -> None:
self.mw.progress.start()
2021-03-29 12:24:24 +02:00
self.table.toggle_state(checked, self._lastSearchTxt)
self.mw.progress.finish()
# Sidebar
######################################################################
def setupSidebar(self) -> None:
2021-03-26 04:48:26 +01:00
dw = self.sidebarDockWidget = QDockWidget(tr.browsing_sidebar(), self)
dw.setFeatures(QDockWidget.DockWidgetFeature.NoDockWidgetFeatures)
dw.setObjectName("Sidebar")
dock_area = (
Qt.DockWidgetArea.RightDockWidgetArea
if self.layoutDirection() == Qt.LayoutDirection.RightToLeft
else Qt.DockWidgetArea.LeftDockWidgetArea
)
dw.setAllowedAreas(dock_area)
self.sidebar = SidebarTreeView(self)
self.sidebarTree = self.sidebar # legacy alias
dw.setWidget(self.sidebar)
qconnect(
self.form.actionSidebarFilter.triggered,
self.focusSidebarSearchBar,
)
grid = QGridLayout()
grid.addWidget(self.sidebar.searchBar, 0, 0)
grid.addWidget(self.sidebar.toolbar, 0, 1)
grid.addWidget(self.sidebar, 1, 0, 1, 2)
grid.setContentsMargins(0, 0, 0, 0)
grid.setSpacing(0)
w = QWidget()
w.setLayout(grid)
dw.setWidget(w)
self.sidebarDockWidget.setFloating(False)
self.sidebarDockWidget.setTitleBarWidget(QWidget())
self.addDockWidget(dock_area, dw)
# schedule sidebar to refresh after browser window has loaded, so the
# UI is more responsive
self.mw.progress.timer(10, self.sidebar.refresh, False, parent=self.sidebar)
def showSidebar(self) -> None:
self.sidebarDockWidget.setVisible(True)
def focusSidebar(self) -> None:
self.showSidebar()
self.sidebar.setFocus()
def focusSidebarSearchBar(self) -> None:
self.showSidebar()
self.sidebar.searchBar.setFocus()
2021-02-01 00:39:55 +01:00
def toggle_sidebar(self) -> None:
want_visible = not self.sidebarDockWidget.isVisible()
self.sidebarDockWidget.setVisible(want_visible)
if want_visible:
self.sidebar.refresh()
# legacy
2021-02-01 00:39:55 +01:00
def setFilter(self, *terms: str) -> None:
self.sidebar.update_search(*terms)
# Info
######################################################################
2021-02-01 00:39:55 +01:00
def showCardInfo(self) -> None:
self._card_info.toggle()
def _update_card_info(self) -> None:
self._card_info.set_card(self.current_card)
# Menu helpers
######################################################################
def selected_cards(self) -> Sequence[CardId]:
return self.table.get_selected_card_ids()
def selected_notes(self) -> Sequence[NoteId]:
return self.table.get_selected_note_ids()
def selectedNotesAsCards(self) -> Sequence[CardId]:
return self.table.get_card_ids_from_selected_note_ids()
2021-02-01 00:39:55 +01:00
def onHelp(self) -> None:
openHelp(HelpPage.BROWSING)
2021-03-18 02:46:11 +01:00
# legacy
selectedCards = selected_cards
selectedNotes = selected_notes
# Misc menu options
######################################################################
def on_create_copy(self) -> None:
if note := self.table.get_current_note():
deck_id = self.table.get_current_card().did
aqt.dialogs.open("AddCards", self.mw).set_note(note, deck_id)
2021-04-26 08:46:08 +02:00
@no_arg_trigger
@skip_if_selection_is_empty
@ensure_editor_saved
2021-02-01 00:39:55 +01:00
def onChangeModel(self) -> None:
ids = self.selected_notes()
change_notetype_dialog(parent=self, note_ids=ids)
def createFilteredDeck(self) -> None:
search = self.current_search()
2021-06-27 07:12:22 +02:00
if self.mw.col.sched_ver() != 1 and KeyboardModifiersPressed().alt:
2021-03-24 04:17:12 +01:00
aqt.dialogs.open("FilteredDeckConfigDialog", self.mw, search_2=search)
else:
2021-03-24 04:17:12 +01:00
aqt.dialogs.open("FilteredDeckConfigDialog", self.mw, search=search)
2013-05-03 10:52:46 +02:00
# Preview
######################################################################
2021-02-01 00:39:55 +01:00
def onTogglePreview(self) -> None:
if self._previewer:
self._previewer.close()
elif self.editor.note:
self._previewer = PreviewDialog(self, self.mw, self._on_preview_closed)
self._previewer.open()
self.toggle_preview_button_state(True)
2021-02-01 00:39:55 +01:00
def _renderPreview(self) -> None:
if self._previewer:
if self.singleCard:
self._previewer.render_card()
else:
self.onTogglePreview()
def toggle_preview_button_state(self, active: bool) -> None:
if self.editor.web:
Improved add-on extension API (#1626) * Add componentHook functionality * Register package NoteEditor * Rename OldEditorAdapter to NoteEditor * Expose instances in component-hook as well * Rename NoteTypeButtons to NotetypeButtons * Move PreviewButton initialization to BrowserEditor.svelte * Remove focusInRichText - Same thing can be done by inspecting activeInput * Satisfy formatter * Fix remaining rebase issues * Add .bazel to .prettierignore * Rename currentField and activeInput to focused{Field,Input} * Move identifier to lib and registration to sveltelib * Fix Dynamic component insertion * Simplify editingInputIsRichText * Give extra warning in svelte/svelte.ts - This was caused by doing a rename of a files, that only differed in case: NoteTypeButtons.svelte to NotetypeButtons.svelte - It was quite tough to figure out, and this console.log might make it easier if it ever happens again * Change signature of contextProperty * Add ts/typings for add-on definition files * Add Anki types in typings/common/index.d.ts * Export without .svelte suffix It conflicts with how Svelte types its packages * Fix left over .svelte import from editor.py * Rename NoteTypeButtons to unrelated to ensure case-only rename * Rename back to NotetypeButtons.svelte * Remove unused component-hook.ts, Fix typing in lifecycle-hooks * Merge runtime-require and register-package into one file + Give some preliminary types to require * Rename uiDidLoad to loaded * Fix eslint / svelte-check * Rename context imports to noteEditorContext * Fix import name mismatch - I wonder why these issues are not caught by svelte-check? * Rename two missed usages of uiDidLoad * Fix ButtonDropdown from having wrong border-radius * Uniformly rename libraries to packages - I don't have a strong opinion on whether to name them libraries or packages, I just think we should have a uniform name. - JS/TS only uses the terms "module" and "namespace", however `package` is a reserved keyword for future use, whereas `library` is not. * Refactor registration.ts into dynamic-slotting - This is part of an effort to refactor the dynamic slotting (extending buttons) functionality out of components like ButtonGroup. * Remove dynamically-slottable logic from ButtonToolbar * Use DynamicallySlottable in editor-toolbar * Fix no border radius on indentation button dropdown * Fix AddonButtons * Remove Item/ButtonGroupItem in deck-options, where it's not necessary * Remove unnecessary uses of Item and ButtonGroupItem * Fix remaining tests * Fix relative imports * Revert change return value of remapBinToSrcDir to ./bazel/out... * Remove typings directory * Adjust comments for dynamic-slottings
2022-02-03 05:52:11 +01:00
self.editor.web.eval(f"togglePreviewButtonState({json.dumps(active)});")
2021-02-01 00:39:55 +01:00
def _cleanup_preview(self) -> None:
if self._previewer:
2020-04-02 17:34:53 +02:00
self._previewer.cancel_timer()
self._previewer.close()
2021-02-01 00:39:55 +01:00
def _on_preview_closed(self) -> None:
av_player.stop_and_clear_queue()
self.toggle_preview_button_state(False)
self._previewer = None
# Card deletion
######################################################################
2021-04-26 08:46:08 +02:00
@no_arg_trigger
@skip_if_selection_is_empty
def delete_selected_notes(self) -> None:
# ensure deletion is not accidentally triggered when the user is focused
# in the editing screen or search bar
focus = self.focusWidget()
if focus != self.form.tableView:
return
nids = self.table.to_row_of_unselected_note()
remove_notes(parent=self, note_ids=nids).run_in_background()
# legacy
deleteNotes = delete_selected_notes
# Deck change
######################################################################
2021-04-26 08:46:08 +02:00
@no_arg_trigger
@skip_if_selection_is_empty
@ensure_editor_saved
def set_deck_of_selected_cards(self) -> None:
from aqt.studydeck import StudyDeck
2019-12-23 01:34:10 +01:00
cids = self.table.get_selected_card_ids()
2019-12-23 01:34:10 +01:00
did = self.mw.col.db.scalar("select did from cards where id = ?", cids[0])
current = self.mw.col.decks.get(did)["name"]
def callback(ret: StudyDeck) -> None:
if not ret.name:
return
did = self.col.decks.id(ret.name)
set_card_deck(parent=self, card_ids=cids, deck_id=did).run_in_background()
StudyDeck(
2019-12-23 01:34:10 +01:00
self.mw,
current=current,
2021-03-26 04:48:26 +01:00
accept=tr.browsing_move_cards(),
title=tr.browsing_change_deck(),
help=HelpPage.BROWSING,
2019-12-23 01:34:10 +01:00
parent=self,
callback=callback,
2019-12-23 01:34:10 +01:00
)
# legacy
setDeck = set_deck_of_selected_cards
# Tags
######################################################################
2021-04-26 08:46:08 +02:00
@no_arg_trigger
@skip_if_selection_is_empty
@ensure_editor_saved
2021-03-05 11:47:51 +01:00
def add_tags_to_selected_notes(
2021-02-01 00:39:55 +01:00
self,
tags: str | None = None,
2021-02-01 00:39:55 +01:00
) -> None:
2021-03-05 11:47:51 +01:00
"Shows prompt if tags not provided."
2021-03-26 04:48:26 +01:00
if not (tags := tags or self._prompt_for_tags(tr.browsing_enter_tags_to_add())):
return
space_separated_tags = re.sub(r"[ \n\t\v]+", " ", tags)
add_tags_to_notes(
parent=self,
note_ids=self.selected_notes(),
space_separated_tags=space_separated_tags,
2021-04-06 06:36:13 +02:00
).run_in_background(initiator=self)
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
2021-03-16 05:26:42 +01:00
2021-04-26 08:46:08 +02:00
@no_arg_trigger
@skip_if_selection_is_empty
@ensure_editor_saved
def remove_tags_from_selected_notes(self, tags: str | None = None) -> None:
2021-03-05 11:47:51 +01:00
"Shows prompt if tags not provided."
if not (
2021-03-26 04:48:26 +01:00
tags := tags or self._prompt_for_tags(tr.browsing_enter_tags_to_delete())
):
return
remove_tags_from_notes(
parent=self, note_ids=self.selected_notes(), space_separated_tags=tags
2021-04-06 06:36:13 +02:00
).run_in_background(initiator=self)
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
2021-03-16 05:26:42 +01:00
def _prompt_for_tags(self, prompt: str) -> str | None:
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
2021-03-16 05:26:42 +01:00
(tags, ok) = getTag(self, self.col, prompt)
if not ok:
return None
else:
return tags
2021-04-26 08:46:08 +02:00
@no_arg_trigger
@ensure_editor_saved
clear_unused_tags and browser redraw improvements - clear_unused_tags() is now undoable, and returns the number of removed notes - add a new mw.query_op() helper for immutable queries - decouple "freeze/unfreeze ui state" hooks from the "interface update required" hook, so that the former is fired even on error, and can be made re-entrant - use a 'block_updates' flag in Python, instead of setUpdatesEnabled(), as the latter has the side-effect of preventing child windows like tooltips from appearing, and forces a full redrawn when updates are enabled again. The new behaviour leads to the card list blanking out when a long-running op is running, but in the future if we cache the cell values we can just display them from the cache instead. - we were indiscriminately saving the note with saveNow(), due to the call to saveTags(). Changed so that it only saves when the tags field is focused. - drain the "on_done" queue on main before launching a new background task, to lower the chances of something in on_done making a small query to the DB and hanging until a long op finishes - the duplicate check in the editor was executed after the webview loads, leading to it hanging until the sidebar finishes loading. Run it at set_note() time instead, so that the editor loads first. - don't throw an error when a long-running op started with with_progress() finishes after the window it was launched from has closed - don't throw an error when the browser is closed before the sidebar has finished loading
2021-03-17 12:27:42 +01:00
def clear_unused_tags(self) -> None:
2021-04-06 06:36:13 +02:00
clear_unused_tags(parent=self).run_in_background()
2021-03-05 11:47:51 +01:00
addTags = add_tags_to_selected_notes
deleteTags = remove_tags_from_selected_notes
clear_unused_tags and browser redraw improvements - clear_unused_tags() is now undoable, and returns the number of removed notes - add a new mw.query_op() helper for immutable queries - decouple "freeze/unfreeze ui state" hooks from the "interface update required" hook, so that the former is fired even on error, and can be made re-entrant - use a 'block_updates' flag in Python, instead of setUpdatesEnabled(), as the latter has the side-effect of preventing child windows like tooltips from appearing, and forces a full redrawn when updates are enabled again. The new behaviour leads to the card list blanking out when a long-running op is running, but in the future if we cache the cell values we can just display them from the cache instead. - we were indiscriminately saving the note with saveNow(), due to the call to saveTags(). Changed so that it only saves when the tags field is focused. - drain the "on_done" queue on main before launching a new background task, to lower the chances of something in on_done making a small query to the DB and hanging until a long op finishes - the duplicate check in the editor was executed after the webview loads, leading to it hanging until the sidebar finishes loading. Run it at set_note() time instead, so that the editor loads first. - don't throw an error when a long-running op started with with_progress() finishes after the window it was launched from has closed - don't throw an error when the browser is closed before the sidebar has finished loading
2021-03-17 12:27:42 +01:00
clearUnusedTags = clear_unused_tags
2021-03-05 11:47:51 +01:00
# Suspending
######################################################################
2021-03-31 10:05:44 +02:00
def _update_toggle_suspend_action(self) -> None:
is_suspended = bool(
self.current_card and self.current_card.queue == QUEUE_TYPE_SUSPENDED
)
2021-03-31 10:05:44 +02:00
self.form.actionToggle_Suspend.setChecked(is_suspended)
@skip_if_selection_is_empty
2021-03-31 10:05:44 +02:00
@ensure_editor_saved
def suspend_selected_cards(self, checked: bool) -> None:
2021-03-18 02:46:11 +01:00
cids = self.selected_cards()
2021-03-31 10:05:44 +02:00
if checked:
suspend_cards(parent=self, card_ids=cids).run_in_background()
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
2021-03-16 05:26:42 +01:00
else:
unsuspend_cards(parent=self.mw, card_ids=cids).run_in_background()
2020-02-10 04:15:10 +01:00
# Exporting
######################################################################
2021-04-26 08:46:08 +02:00
@no_arg_trigger
@skip_if_selection_is_empty
2021-02-01 00:39:55 +01:00
def _on_export_notes(self) -> None:
Add apkg import/export on backend (#1743) * Add apkg export on backend * Filter out missing media-paths at write time * Make TagMatcher::new() infallible * Gather export data instead of copying directly * Revert changes to rslib/src/tags/ * Reuse filename_is_safe/check_filename_safe() * Accept func to produce MediaIter in export_apkg() * Only store file folder once in MediaIter * Use temporary tables for gathering export_apkg() now accepts a search instead of a deck id. Decks are gathered according to the matched notes' cards. * Use schedule_as_new() to reset cards * ExportData → ExchangeData * Ignore ascii case when filtering system tags * search_notes_cards_into_table → search_cards_of_notes_into_table * Start on apkg importing on backend * Fix due dates in days for apkg export * Refactor import-export/package - Move media and meta code into appropriate modules. - Normalize/check for normalization when deserializing media entries. * Add SafeMediaEntry for deserialized MediaEntries * Prepare media based on checksums - Ensure all existing media files are hashed. - Hash incoming files during preparation to detect conflicts. - Uniquify names of conflicting files with hash (not notetype id). - Mark media files as used while importing notes. - Finally copy used media. * Handle encoding in `replace_media_refs()` * Add trait to keep down cow boilerplate * Add notetypes immediately instaed of preparing * Move target_col into Context * Add notes immediately instaed of preparing * Note id, not guid of conflicting notes * Add import_decks() * decks_configs → deck_configs * Add import_deck_configs() * Add import_cards(), import_revlog() * Use dyn instead of generic for media_fn Otherwise, would have to pass None with type annotation in the default case. * Fix signature of import_apkg() * Fix search_cards_of_notes_into_table() * Test new functions in text.rs * Add roundtrip test for apkg (stub) * Keep source id of imported cards (or skip) * Keep source ids of imported revlog (or skip) * Try to keep source ids of imported notes * Make adding notetype with id undoable * Wrap apkg import in transaction * Keep source ids of imported deck configs (or skip) * Handle card due dates and original due/did * Fix importing cards/revlog Card ids are manually uniquified. * Factor out card importing * Refactor card and revlog importing * Factor out card importing Also handle missing parents . * Factor out note importing * Factor out media importing * Maybe upgrade scheduler of apkg * Fix parent deck gathering * Unconditionally import static media * Fix deck importing edge cases Test those edge cases, and add some global test helpers. * Test note importing * Let import_apkg() take a progress func * Expand roundtrip apkg test * Use fat pointer to avoid propogating generics * Fix progress_fn type * Expose apkg export/import on backend * Return note log when importing apkg * Fix archived collection name on apkg import * Add CollectionOpWithBackendProgress * Fix wrong Interrupted Exception being checked * Add ClosedCollectionOp * Add note ids to log and strip HTML * Update progress when checking incoming media too * Conditionally enable new importing in GUI * Fix all_checksums() for media import Entries of deleted files are nulled, not removed. * Make apkg exporting on backend abortable * Return number of notes imported from apkg * Fix exception printing for QueryOp as well * Add QueryOpWithBackendProgress Also support backend exporting progress. * Expose new apkg and colpkg exporting * Open transaction in insert_data() Was slowing down exporting by several orders of magnitude. * Handle zstd-compressed apkg * Add legacy arg to ExportAnkiPackage Currently not exposed on the frontend * Remove unused import in proto file * Add symlink for typechecking of import_export_pb2 * Avoid kwargs in pb message creation, so typechecking is not lost Protobuf's behaviour is rather subtle and I had to dig through the docs to figure it out: set a field on a submessage to automatically assign the submessage to the parent, or call SetInParent() to persist a default version of the field you specified. * Avoid re-exporting protobuf msgs we only use internally * Stop after one test failure mypy often fails much faster than pylint * Avoid an extra allocation when extracting media checksums * Update progress after prepare_media() finishes Otherwise the bulk of the import ends up being shown as "Checked: 0" in the progress window. * Show progress of note imports Note import is the slowest part, so showing progress here makes the UI feel more responsive. * Reset filtered decks at import time Before this change, filtered decks exported with scheduling remained filtered on import, and maybe_remove_from_filtered_deck() moved cards into them as their home deck, leading to errors during review. We may still want to provide a way to preserve filtered decks on import, but to do that we'll need to ensure we don't rewrite the home decks of cards, and we'll need to ensure the home decks are included as part of the import (or give an error if they're not). https://github.com/ankitects/anki/pull/1743/files#r839346423 * Fix a corner-case where due dates were shifted by a day This issue existed in the old Python code as well. We need to include the user's UTC offset in the exported file, or days_elapsed falls back on the v1 cutoff calculation, which may be a day earlier or later than the v2 calculation. * Log conflicting note in remapped nt case * take_fields() → into_fields() * Alias `[u8; 20]` with `Sha1Hash` * Truncate logged fields * Rework apkg note import tests - Use macros for more helpful errors. - Split monolith into unit tests. - Fix some unknown error with the previous test along the way. (Was failing after 969484de4388d225c9f17d94534b3ba0094c3568.) * Fix sorting of imported decks Also adjust the test, so it fails without the patch. It was only passing before, because the parent deck happened to come before the inconsistently capitalised child alphabetically. But we want all parent decks to be imported before their child decks, so their children can adopt their capitalisation. * target[_id]s → existing_card[_id]s * export_collection_extracting_media() → ... export_into_collection_file() * target_already_exists→card_ordinal_already_exists * Add search_cards_of_notes_into_table.sql * Imrove type of apkg export selector/limit * Remove redundant call to mod_schema() * Parent tooltips to mw * Fix a crash when truncating note text String::truncate() is a bit of a footgun, and I've hit this before too :-) * Remove ExportLimit in favour of separate classes * Remove OpWithBackendProgress and ClosedCollectionOp Backend progress logic is now in ProgressManager. QueryOp can be used for running on closed collection. Also fix aborting of colpkg exports, which slipped through in #1817. * Tidy up import log * Avoid QDialog.exec() * Default to excluding scheuling for deck list deck * Use IncrementalProgress in whole import_export code * Compare checksums when importing colpkgs * Avoid registering changes if hashes are not needed * ImportProgress::Collection → ImportProgress::File * Make downgrading apkgs depend on meta version * Generalise IncrementableProgress And use it in entire import_export code instead. * Fix type complexity lint * Take count_map for IncrementableProgress::get_inner * Replace import/export env with Shift click * Accept all args from update() for backend progress * Pass fields of ProgressUpdate explicitly * Move update_interval into IncrementableProgress * Outsource incrementing into Incrementor * Mutate ProgressUpdate in progress_update callback * Switch import/export legacy toggle to profile setting Shift would have been nice, but the existing shortcuts complicate things. If the user triggers an import with ctrl+shift+i, shift is unlikely to have been released by the time our code runs, meaning the user accidentally triggers the new code. We could potentially wait a while before bringing up the dialog, but then we're forced to guess at how long it will take the user to release the key. One alternative would be to use alt instead of shift, but then we need to trigger our shortcut when that key is pressed as well, and it could potentially cause a conflict with an add-on that already uses that combination. * Show extension in export dialog * Continue to provide separate options for schema 11+18 colpkg export * Default to colpkg export when using File>Export * Improve appearance of combo boxes when switching between apkg/colpkg + Deal with long deck names * Convert newlines to spaces when showing fields from import Ensures each imported note appears on a separate line * Don't separate total note count from the other summary lines This may come down to personal preference, but I feel the other counts are equally as important, and separating them feels like it makes it a bit easier to ignore them. * Fix 'deck not normal' error when importing a filtered deck for the 2nd time * Fix [Identical] being shown on first import * Revert "Continue to provide separate options for schema 11+18 colpkg export" This reverts commit 8f0b2c175f4794d642823b60414d142a12768441. Will use a different approach * Move legacy support into a separate exporter option; add to apkg export * Adjust 'too new' message to also apply to .apkg import case * Show a better message when attempting to import new apkg into old code Previously the user could end seeing a message like: UnicodeDecodeError: 'utf-8' codec can't decode byte 0xb5 in position 1: invalid start byte Unfortunately we can't retroactively fix this for older clients. * Hide legacy support option in older exporting screen * Reflect change from paths to fnames in type & name * Make imported decks normal at once Then skip special casing in update_deck(). Also skip updating description if new one is empty. Co-authored-by: Damien Elmes <gpg@ankiweb.net>
2022-05-02 13:12:46 +02:00
if self.mw.pm.new_import_export():
nids = self.selected_notes()
ExportDialog(self.mw, nids=nids)
else:
cids = self.selectedNotesAsCards()
LegacyExportDialog(self.mw, cids=list(cids))
2020-02-10 04:15:10 +01:00
# Flags & Marking
######################################################################
@skip_if_selection_is_empty
2021-03-18 03:06:45 +01:00
@ensure_editor_saved
def set_flag_of_selected_cards(self, flag: int) -> None:
if not self.current_card:
return
# flag needs toggling off?
if flag == self.current_card.user_flag():
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
2021-03-16 05:26:42 +01:00
flag = 0
2021-03-14 10:54:15 +01:00
2021-04-06 06:36:13 +02:00
set_card_flag(
parent=self, card_ids=self.selected_cards(), flag=flag
).run_in_background()
2021-03-18 03:06:45 +01:00
def _update_flags_menu(self) -> None:
flag = self.current_card and self.current_card.user_flag()
2018-11-12 03:10:50 +01:00
flag = flag or 0
for f in self.mw.flags.all():
getattr(self.form, f.action).setChecked(flag == f.index)
qtMenuShortcutWorkaround(self.form.menuFlag)
2018-11-12 03:10:50 +01:00
def _update_flag_labels(self) -> None:
for flag in self.mw.flags.all():
getattr(self.form, flag.action).setText(flag.label)
2021-03-31 10:05:44 +02:00
def toggle_mark_of_selected_notes(self, checked: bool) -> None:
if checked:
2021-03-18 03:06:45 +01:00
self.add_tags_to_selected_notes(tags=MARKED_TAG)
2021-03-31 10:05:44 +02:00
else:
self.remove_tags_from_selected_notes(tags=MARKED_TAG)
def _update_toggle_mark_action(self) -> None:
is_marked = bool(
self.current_card and self.current_card.note().has_tag(MARKED_TAG)
)
2021-03-31 10:05:44 +02:00
self.form.actionToggle_Mark.setChecked(is_marked)
2021-03-18 02:46:11 +01:00
# Scheduling
######################################################################
2021-04-26 08:46:08 +02:00
@no_arg_trigger
@skip_if_selection_is_empty
@ensure_editor_saved
2021-02-01 00:39:55 +01:00
def reposition(self) -> None:
if op := reposition_new_cards_dialog(
parent=self, card_ids=self.selected_cards()
):
op.run_in_background()
2021-04-26 08:46:08 +02:00
@no_arg_trigger
@skip_if_selection_is_empty
@ensure_editor_saved
def set_due_date(self) -> None:
if op := set_due_date_dialog(
parent=self,
2021-03-18 02:46:11 +01:00
card_ids=self.selected_cards(),
config_key=Config.String.SET_DUE_BROWSER,
):
op.run_in_background()
2021-04-26 08:46:08 +02:00
@no_arg_trigger
@skip_if_selection_is_empty
@ensure_editor_saved
def forget_cards(self) -> None:
if op := forget_cards(
parent=self,
2021-03-18 02:46:11 +01:00
card_ids=self.selected_cards(),
context=ScheduleCardsAsNew.Context.BROWSER,
):
op.run_in_background()
# Edit: selection
######################################################################
2021-04-26 08:46:08 +02:00
@no_arg_trigger
@skip_if_selection_is_empty
@ensure_editor_saved
2021-02-01 00:39:55 +01:00
def selectNotes(self) -> None:
2021-03-18 02:46:11 +01:00
nids = self.selected_notes()
# clear the selection so we don't waste energy preserving it
self.table.clear_selection()
search = self.col.build_search_string(
SearchNode(nids=SearchNode.IdList(ids=nids))
)
2020-10-19 20:51:36 +02:00
self.search_for(search)
self.table.select_all()
# Hooks
######################################################################
def setupHooks(self) -> None:
gui_hooks.undo_state_did_change.append(self.on_undo_state_change)
gui_hooks.backend_will_block.append(self.table.on_backend_will_block)
gui_hooks.backend_did_block.append(self.table.on_backend_did_block)
gui_hooks.operation_did_execute.append(self.on_operation_did_execute)
gui_hooks.focus_did_change.append(self.on_focus_change)
gui_hooks.flag_label_did_change.append(self._update_flag_labels)
gui_hooks.collection_will_temporarily_close.append(self._on_temporary_close)
def teardownHooks(self) -> None:
gui_hooks.undo_state_did_change.remove(self.on_undo_state_change)
gui_hooks.backend_will_block.remove(self.table.on_backend_will_block)
gui_hooks.backend_did_block.remove(self.table.on_backend_did_block)
gui_hooks.operation_did_execute.remove(self.on_operation_did_execute)
gui_hooks.focus_did_change.remove(self.on_focus_change)
gui_hooks.flag_label_did_change.remove(self._update_flag_labels)
gui_hooks.collection_will_temporarily_close.remove(self._on_temporary_close)
def _on_temporary_close(self, col: Collection) -> None:
# we could reload browser columns in the future; for now we just close
self.close()
# Undo
######################################################################
def undo(self) -> None:
2021-04-06 06:36:13 +02:00
undo(parent=self)
def redo(self) -> None:
redo(parent=self)
def on_undo_state_change(self, info: UndoActionsInfo) -> None:
self.form.actionUndo.setText(info.undo_text)
self.form.actionUndo.setEnabled(info.can_undo)
self.form.actionRedo.setText(info.redo_text)
self.form.actionRedo.setEnabled(info.can_redo)
self.form.actionRedo.setVisible(info.show_redo)
# Edit: replacing
######################################################################
2021-04-26 08:46:08 +02:00
@no_arg_trigger
@ensure_editor_saved
2021-02-01 00:39:55 +01:00
def onFindReplace(self) -> None:
FindAndReplaceDialog(self, mw=self.mw, note_ids=self.selected_notes())
# Edit: finding dupes
######################################################################
2021-04-26 08:46:08 +02:00
@no_arg_trigger
@ensure_editor_saved
2021-02-01 00:39:55 +01:00
def onFindDupes(self) -> None:
from aqt.browser.find_duplicates import FindDuplicatesDialog
FindDuplicatesDialog(browser=self, mw=self.mw)
# Jumping
######################################################################
def has_previous_card(self) -> bool:
return self.table.has_previous()
def has_next_card(self) -> bool:
return self.table.has_next()
2021-02-01 00:39:55 +01:00
def onPreviousCard(self) -> None:
self.focusTo = self.editor.currentField
self.editor.call_after_note_saved(self.table.to_previous_row)
2021-02-01 00:39:55 +01:00
def onNextCard(self) -> None:
self.focusTo = self.editor.currentField
self.editor.call_after_note_saved(self.table.to_next_row)
2021-02-01 00:39:55 +01:00
def onFirstCard(self) -> None:
self.table.to_first_row()
2021-02-01 00:39:55 +01:00
def onLastCard(self) -> None:
self.table.to_last_row()
2021-02-01 00:39:55 +01:00
def onFind(self) -> None:
self.form.searchEdit.setFocus()
self.form.searchEdit.lineEdit().selectAll()
2021-02-01 00:39:55 +01:00
def onNote(self) -> None:
2013-01-29 01:49:04 +01:00
self.editor.web.setFocus()
self.editor.loadNote(focusTo=0)
2021-02-01 00:39:55 +01:00
def onCardList(self) -> None:
self.form.tableView.setFocus()