anki/qt/aqt/browser.py

1921 lines
66 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
2017-12-11 07:20:00 +01:00
import html
2019-12-20 10:19:03 +01:00
import time
from concurrent.futures import Future
from dataclasses import dataclass
2019-12-20 10:19:03 +01:00
from operator import itemgetter
2021-02-01 00:39:55 +01:00
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union, cast
import aqt
2019-12-20 10:19:03 +01:00
import aqt.forms
from anki.cards import Card
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
from anki.collection import Collection, Config, OpChanges, SearchNode
2019-12-20 10:19:03 +01:00
from anki.consts import *
from anki.errors import InvalidInput, NotFoundError
2020-11-18 04:48:23 +01:00
from anki.lang import without_unicode_isolation
from anki.models import NoteType
from anki.stats import CardStats
2020-09-03 09:42:46 +02:00
from anki.utils import htmlToTextLine, ids2str, isMac, isWin
from aqt import AnkiQt, colors, gui_hooks
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
from aqt.card_ops import set_card_deck, set_card_flag
from aqt.editor import Editor
2020-02-10 04:15:19 +01:00
from aqt.exporting import ExportDialog
2020-08-16 18:49:51 +02:00
from aqt.main import ResetReason
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
from aqt.note_ops import (
add_tags,
clear_unused_tags,
find_and_replace,
remove_notes,
remove_tags,
)
2020-07-26 21:16:06 +02:00
from aqt.previewer import BrowserPreviewer as PreviewDialog
from aqt.previewer import Previewer
2013-05-03 10:52:46 +02:00
from aqt.qt 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
from aqt.scheduling_ops import (
forget_cards,
set_due_date_dialog,
suspend_cards,
unsuspend_cards,
)
from aqt.sidebar import SidebarTreeView
from aqt.theme import theme_manager
2019-12-23 01:34:10 +01:00
from aqt.utils import (
TR,
HelpPage,
KeyboardModifiersPressed,
2019-12-23 01:34:10 +01:00
askUser,
current_top_level_widget,
disable_help_button,
ensure_editor_saved,
ensure_editor_saved_on_trigger,
2019-12-23 01:34:10 +01:00
getTag,
openHelp,
qtMenuShortcutWorkaround,
restore_combo_history,
restore_combo_index_for_session,
restore_is_checked,
2019-12-23 01:34:10 +01:00
restoreGeom,
restoreHeader,
restoreSplitter,
restoreState,
save_combo_history,
save_combo_index_for_session,
save_is_checked,
2019-12-23 01:34:10 +01:00
saveGeom,
saveHeader,
saveSplitter,
saveState,
shortcut,
show_invalid_search_error,
2019-12-23 01:34:10 +01:00
showInfo,
tooltip,
tr,
2019-12-23 01:34:10 +01:00
)
from aqt.webview import AnkiWebView
@dataclass
class FindDupesDialog:
dialog: QDialog
browser: Browser
@dataclass
class SearchContext:
search: str
browser: Browser
2020-03-21 08:38:09 +01:00
order: Union[bool, str] = True
# if set, provided card ids will be used instead of the regular search
card_ids: Optional[Sequence[int]] = None
# Data model
##########################################################################
2019-12-23 01:34:10 +01:00
class DataModel(QAbstractTableModel):
2021-02-01 00:39:55 +01:00
def __init__(self, browser: Browser) -> None:
QAbstractTableModel.__init__(self)
self.browser = browser
self.col = browser.col
self.sortKey = None
self.activeCols = self.col.get_config(
2019-12-23 01:34:10 +01:00
"activeCols", ["noteFld", "template", "cardDue", "deck"]
)
self.cards: Sequence[int] = []
self.cardObjs: Dict[int, Card] = {}
self._refresh_needed = False
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
self.block_updates = False
def getCard(self, index: QModelIndex) -> Optional[Card]:
id = self.cards[index.row()]
if not id in self.cardObjs:
try:
card = self.col.getCard(id)
except NotFoundError:
# deleted
card = None
self.cardObjs[id] = card
return self.cardObjs[id]
# Model interface
######################################################################
2021-02-01 00:39:55 +01:00
def rowCount(self, parent: QModelIndex = QModelIndex()) -> int:
2017-02-23 07:21:00 +01:00
if parent and parent.isValid():
return 0
return len(self.cards)
2021-02-01 00:39:55 +01:00
def columnCount(self, parent: QModelIndex = QModelIndex()) -> int:
2017-02-23 07:21:00 +01:00
if parent and parent.isValid():
return 0
return len(self.activeCols)
2021-02-01 00:39:55 +01:00
def data(self, index: QModelIndex = QModelIndex(), role: int = 0) -> Any:
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
if self.block_updates:
return
if not index.isValid():
return
if role == Qt.FontRole:
2019-12-23 01:34:10 +01:00
if self.activeCols[index.column()] not in ("question", "answer", "noteFld"):
return
c = self.getCard(index)
if not c:
return
t = c.template()
if not t.get("bfont"):
return
f = QFont()
f.setFamily(cast(str, t.get("bfont", "arial")))
f.setPixelSize(cast(int, t.get("bsize", 12)))
return f
elif role == Qt.TextAlignmentRole:
align: Union[Qt.AlignmentFlag, int] = Qt.AlignVCenter
2019-12-23 01:34:10 +01:00
if self.activeCols[index.column()] not in (
"question",
"answer",
"template",
"deck",
"noteFld",
"note",
"noteTags",
2019-12-23 01:34:10 +01:00
):
align |= Qt.AlignHCenter
return align
elif role == Qt.DisplayRole or role == Qt.EditRole:
return self.columnData(index)
else:
return
2021-02-01 00:39:55 +01:00
def headerData(
self, section: int, orientation: Qt.Orientation, role: int = 0
) -> Optional[str]:
if orientation == Qt.Vertical:
2021-02-01 00:39:55 +01:00
return None
elif role == Qt.DisplayRole and section < len(self.activeCols):
type = self.columnType(section)
txt = None
for stype, name in self.browser.columns:
if type == stype:
txt = name
break
# give the user a hint an invalid column was added by an add-on
if not txt:
txt = tr(TR.BROWSING_ADDON)
return txt
else:
2021-02-01 00:39:55 +01:00
return None
2021-02-01 00:39:55 +01:00
def flags(self, index: QModelIndex) -> Qt.ItemFlags:
return cast(Qt.ItemFlags, Qt.ItemIsEnabled | Qt.ItemIsSelectable)
# Filtering
######################################################################
def search(self, txt: str) -> None:
self.beginReset()
self.cards = []
2017-02-02 12:00:58 +01:00
try:
ctx = SearchContext(search=txt, browser=self.browser)
gui_hooks.browser_will_search(ctx)
if ctx.card_ids is None:
2020-05-25 08:54:57 +02:00
ctx.card_ids = self.col.find_cards(ctx.search, order=ctx.order)
gui_hooks.browser_did_search(ctx)
self.cards = ctx.card_ids
except Exception as err:
raise err
finally:
self.endReset()
def redraw_cells(self) -> None:
"Update cell contents, without changing search count/columns/sorting."
if not self.cards:
return
top_left = self.index(0, 0)
bottom_right = self.index(len(self.cards) - 1, len(self.activeCols) - 1)
self.cardObjs = {}
self.dataChanged.emit(top_left, bottom_right) # type: ignore
2021-02-01 00:39:55 +01:00
def reset(self) -> None:
self.beginReset()
self.endReset()
# caller must have called editor.saveNow() before calling this or .reset()
2021-02-01 00:39:55 +01:00
def beginReset(self) -> None:
self.browser.editor.set_note(None, hide=False)
self.browser.mw.progress.start()
self.saveSelection()
self.beginResetModel()
self.cardObjs = {}
2021-02-01 00:39:55 +01:00
def endReset(self) -> None:
self.endResetModel()
self.restoreSelection()
self.browser.mw.progress.finish()
2021-02-01 00:39:55 +01:00
def reverse(self) -> None:
self.browser.editor.call_after_note_saved(self._reverse)
2021-02-01 00:39:55 +01:00
def _reverse(self) -> None:
self.beginReset()
2020-03-21 07:57:33 +01:00
self.cards = list(reversed(self.cards))
self.endReset()
2021-02-01 00:39:55 +01:00
def saveSelection(self) -> None:
cards = self.browser.selectedCards()
self.selectedCards = {id: True for id in cards}
2019-12-23 01:34:10 +01:00
if getattr(self.browser, "card", None):
self.focusedCard = self.browser.card.id
else:
self.focusedCard = None
2021-02-01 00:39:55 +01:00
def restoreSelection(self) -> None:
if not self.cards:
return
sm = self.browser.form.tableView.selectionModel()
sm.clear()
# restore selection
items = QItemSelection()
count = 0
firstIdx = None
focusedIdx = None
for row, id in enumerate(self.cards):
# if the id matches the focused card, note the index
if self.focusedCard == id:
focusedIdx = self.index(row, 0)
items.select(focusedIdx, focusedIdx)
self.focusedCard = None
# if the card was previously selected, select again
if id in self.selectedCards:
count += 1
idx = self.index(row, 0)
items.select(idx, idx)
# note down the first card of the selection, in case we don't
# have a focused card
if not firstIdx:
firstIdx = idx
# focus previously focused or first in selection
idx = focusedIdx or firstIdx
tv = self.browser.form.tableView
if idx:
row = idx.row()
pos = tv.rowViewportPosition(row)
visible = pos >= 0 and pos < tv.viewport().height()
tv.selectRow(row)
# we save and then restore the horizontal scroll position because
# scrollTo() also scrolls horizontally which is confusing
if not visible:
h = tv.horizontalScrollBar().value()
tv.scrollTo(idx, tv.PositionAtCenter)
tv.horizontalScrollBar().setValue(h)
if count < 500:
# discard large selections; they're too slow
2019-12-23 01:34:10 +01:00
sm.select(
items, QItemSelectionModel.SelectCurrent | QItemSelectionModel.Rows
)
else:
tv.selectRow(0)
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 op_executed(self, op: OpChanges, focused: bool) -> None:
if op.card or op.note or op.deck or op.notetype:
self._refresh_needed = True
if focused:
self.refresh_if_needed()
def refresh_if_needed(self) -> None:
if self._refresh_needed:
self.redraw_cells()
self._refresh_needed = False
# Column data
######################################################################
2021-02-01 00:39:55 +01:00
def columnType(self, column: int) -> str:
return self.activeCols[column]
2021-02-01 00:39:55 +01:00
def time_format(self) -> str:
return "%Y-%m-%d"
2021-02-01 00:39:55 +01:00
def columnData(self, index: QModelIndex) -> str:
col = index.column()
type = self.columnType(col)
c = self.getCard(index)
if not c:
return tr(TR.BROWSING_ROW_DELETED)
if type == "question":
return self.question(c)
elif type == "answer":
return self.answer(c)
elif type == "noteFld":
f = c.note()
2017-03-14 07:48:40 +01:00
return htmlToTextLine(f.fields[self.col.models.sortIdx(f.model())])
elif type == "template":
2019-12-23 01:34:10 +01:00
t = c.template()["name"]
if c.model()["type"] == MODEL_CLOZE:
t = f"{t} {c.ord + 1}"
2021-02-01 00:39:55 +01:00
return cast(str, t)
elif type == "cardDue":
# catch invalid dates
try:
t = self.nextDue(c, index)
except:
t = ""
if c.queue < 0:
t = f"({t})"
return t
elif type == "noteCrt":
return time.strftime(self.time_format(), time.localtime(c.note().id / 1000))
elif type == "noteMod":
return time.strftime(self.time_format(), time.localtime(c.note().mod))
elif type == "cardMod":
return time.strftime(self.time_format(), time.localtime(c.mod))
elif type == "cardReps":
return str(c.reps)
elif type == "cardLapses":
return str(c.lapses)
2013-09-03 20:20:20 +02:00
elif type == "noteTags":
2013-10-08 00:42:06 +02:00
return " ".join(c.note().tags)
elif type == "note":
2019-12-23 01:34:10 +01:00
return c.model()["name"]
elif type == "cardIvl":
if c.type == CARD_TYPE_NEW:
return tr(TR.BROWSING_NEW)
elif c.type == CARD_TYPE_LRN:
return tr(TR.BROWSING_LEARNING)
2020-05-24 00:36:50 +02:00
return self.col.format_timespan(c.ivl * 86400)
elif type == "cardEase":
if c.type == CARD_TYPE_NEW:
return tr(TR.BROWSING_NEW)
2019-12-23 01:34:10 +01:00
return "%d%%" % (c.factor / 10)
elif type == "deck":
if c.odid:
# in a cram deck
return "%s (%s)" % (
self.browser.mw.col.decks.name(c.did),
2019-12-23 01:34:10 +01:00
self.browser.mw.col.decks.name(c.odid),
)
# normal deck
return self.browser.mw.col.decks.name(c.did)
2021-02-01 00:39:55 +01:00
else:
return ""
2021-02-01 00:39:55 +01:00
def question(self, c: Card) -> str:
2017-03-14 07:48:40 +01:00
return htmlToTextLine(c.q(browser=True))
2021-02-01 00:39:55 +01:00
def answer(self, c: Card) -> str:
2019-12-23 01:34:10 +01:00
if c.template().get("bafmt"):
# they have provided a template, use it verbatim
c.q(browser=True)
2017-03-14 07:48:40 +01:00
return htmlToTextLine(c.a())
# need to strip question from answer
q = self.question(c)
2017-03-14 07:48:40 +01:00
a = htmlToTextLine(c.a())
if a.startswith(q):
2019-12-23 01:34:10 +01:00
return a[len(q) :].strip()
return a
2021-02-01 00:39:55 +01:00
def nextDue(self, c: Card, index: QModelIndex) -> str:
date: float
if c.odid:
return tr(TR.BROWSING_FILTERED)
2020-02-02 10:28:04 +01:00
elif c.queue == QUEUE_TYPE_LRN:
date = c.due
2020-02-02 10:19:45 +01:00
elif c.queue == QUEUE_TYPE_NEW or c.type == CARD_TYPE_NEW:
2020-02-27 03:25:19 +01:00
return tr(TR.STATISTICS_DUE_FOR_NEW_CARD, number=c.due)
2020-01-31 09:58:03 +01:00
elif c.queue in (QUEUE_TYPE_REV, QUEUE_TYPE_DAY_LEARN_RELEARN) or (
c.type == CARD_TYPE_REV and c.queue < 0
):
2019-12-23 01:34:10 +01:00
date = time.time() + ((c.due - self.col.sched.today) * 86400)
else:
return ""
return time.strftime(self.time_format(), time.localtime(date))
2021-02-01 00:39:55 +01:00
def isRTL(self, index: QModelIndex) -> bool:
col = index.column()
type = self.columnType(col)
if type != "noteFld":
return False
c = self.getCard(index)
nt = c.note().model()
2019-12-23 01:34:10 +01:00
return nt["flds"][self.col.models.sortIdx(nt)]["rtl"]
# Line painter
######################################################################
2019-12-23 01:34:10 +01:00
class StatusDelegate(QItemDelegate):
2021-02-01 00:39:55 +01:00
def __init__(self, browser: Browser, model: DataModel) -> None:
QItemDelegate.__init__(self, browser)
self.browser = browser
self.model = model
2021-02-01 00:39:55 +01:00
def paint(
self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex
) -> None:
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
if self.model.block_updates:
return QItemDelegate.paint(self, painter, option, index)
c = self.model.getCard(index)
if not c:
return QItemDelegate.paint(self, painter, option, index)
if self.model.isRTL(index):
option.direction = Qt.RightToLeft
col = None
if c.user_flag() > 0:
col = getattr(colors, f"FLAG{c.user_flag()}_BG")
elif c.note().has_tag("Marked"):
col = colors.MARKED_BG
elif c.queue == QUEUE_TYPE_SUSPENDED:
col = colors.SUSPENDED_BG
if col:
brush = QBrush(theme_manager.qcolor(col))
painter.save()
painter.fillRect(option.rect, brush)
painter.restore()
return QItemDelegate.paint(self, painter, option, index)
2019-12-23 01:34:10 +01:00
# Browser window
######################################################################
2019-12-23 01:34:10 +01:00
class Browser(QMainWindow):
2019-12-20 08:55:19 +01:00
model: DataModel
mw: AnkiQt
2020-05-20 09:56:52 +02:00
col: Collection
editor: Optional[Editor]
def __init__(
self,
mw: AnkiQt,
card: Optional[Card] = None,
search: Optional[Tuple[Union[str, SearchNode]]] = None,
) -> None:
2021-02-02 09:48:55 +01:00
"""
card : try to search for its note and select it
search: set and perform search; caller must ensure validity
"""
QMainWindow.__init__(self, None, Qt.Window)
self.mw = mw
self.col = self.mw.col
self.lastFilter = ""
self.focusTo: Optional[int] = None
self._previewer: Optional[Previewer] = None
self._closeEventHasCleanedUp = False
self.form = aqt.forms.browser.Ui_Dialog()
self.form.setupUi(self)
self.setupSidebar()
restoreGeom(self, "editor", 0)
restoreState(self, "editor")
restoreSplitter(self.form.splitter, "editor3")
self.form.splitter.setChildrenCollapsible(False)
self.card: Optional[Card] = None
self.setupColumns()
self.setupTable()
self.setupMenus()
self.setupHeaders()
self.setupHooks()
self.setupEditor()
self.updateFont()
self.onUndoState(self.mw.form.actionUndo.isEnabled())
self.setupSearch(card, search)
2020-02-29 17:02:51 +01:00
gui_hooks.browser_will_show(self)
self.show()
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 on_operations_will_execute(self) -> None:
# make sure the card list doesn't try to refresh itself during the operation,
# as that will block the UI
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
self.model.block_updates = True
def on_operations_did_execute(self) -> None:
self.model.block_updates = False
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 on_operation_did_execute(self, changes: OpChanges) -> None:
focused = current_top_level_widget() == self
self.model.op_executed(changes, focused)
self.sidebar.op_executed(changes, focused)
if changes.note or changes.notetype:
if not self.editor.is_updating_note():
note = self.editor.note
if note:
note.load()
self.editor.set_note(note)
self._renderPreview()
def on_focus_change(self, new: Optional[QWidget], old: Optional[QWidget]) -> None:
if current_top_level_widget() == self:
self.setUpdatesEnabled(True)
self.model.refresh_if_needed()
self.sidebar.refresh_if_needed()
def setupMenus(self) -> None:
2019-03-04 07:01:10 +01:00
# pylint: disable=unnecessary-lambda
# actions
f = self.form
# edit
qconnect(f.actionUndo.triggered, self.undo)
qconnect(f.actionInvertSelection.triggered, self.invertSelection)
qconnect(f.actionSelectNotes.triggered, self.selectNotes)
if not isMac:
f.actionClose.setVisible(False)
qconnect(f.actionCreateFilteredDeck.triggered, self.createFilteredDeck)
f.actionCreateFilteredDeck.setShortcuts(["Ctrl+G", "Ctrl+Alt+G"])
# notes
qconnect(f.actionAdd.triggered, self.mw.onAddCard)
2021-03-05 11:47:51 +01:00
qconnect(f.actionAdd_Tags.triggered, lambda: self.add_tags_to_selected_notes())
qconnect(
f.actionRemove_Tags.triggered,
lambda: 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)
qconnect(f.actionToggle_Mark.triggered, lambda: self.onMark())
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)
qconnect(f.actionRed_Flag.triggered, lambda: self.onSetFlag(1))
qconnect(f.actionOrange_Flag.triggered, lambda: self.onSetFlag(2))
qconnect(f.actionGreen_Flag.triggered, lambda: self.onSetFlag(3))
qconnect(f.actionBlue_Flag.triggered, lambda: self.onSetFlag(4))
qconnect(f.actionExport.triggered, lambda: 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)
2017-12-14 05:49:51 +01:00
# context menu
self.form.tableView.setContextMenuPolicy(Qt.CustomContextMenu)
qconnect(self.form.tableView.customContextMenuRequested, self.onContextMenu)
2017-12-14 05:49:51 +01:00
2021-02-01 00:39:55 +01:00
def onContextMenu(self, _point: QPoint) -> None:
2017-12-14 05:49:51 +01:00
m = QMenu()
for act in self.form.menu_Cards.actions():
m.addAction(act)
m.addSeparator()
for act in self.form.menu_Notes.actions():
m.addAction(act)
gui_hooks.browser_will_show_context_menu(self, m)
qtMenuShortcutWorkaround(m)
2017-12-14 05:49:51 +01:00
m.exec_(QCursor.pos())
2021-02-01 00:39:55 +01:00
def updateFont(self) -> None:
# we can't choose different line heights efficiently, so we need
# to pick a line height big enough for any card template
curmax = 16
for m in self.col.models.all():
2019-12-23 01:34:10 +01:00
for t in m["tmpls"]:
bsize = t.get("bsize", 0)
if bsize > curmax:
curmax = bsize
2019-12-23 01:34:10 +01:00
self.form.tableView.verticalHeader().setDefaultSectionSize(curmax + 6)
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.editor.cleanup()
saveSplitter(self.form.splitter, "editor3")
saveGeom(self, "editor")
saveState(self, "editor")
saveHeader(self.form.tableView.horizontalHeader(), "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_Escape:
self.close()
else:
super().keyPressEvent(evt)
2021-02-01 00:39:55 +01:00
def setupColumns(self) -> None:
self.columns = [
("question", tr(TR.BROWSING_QUESTION)),
("answer", tr(TR.BROWSING_ANSWER)),
("template", tr(TR.BROWSING_CARD)),
("deck", tr(TR.DECKS_DECK)),
("noteFld", tr(TR.BROWSING_SORT_FIELD)),
("noteCrt", tr(TR.BROWSING_CREATED)),
("noteMod", tr(TR.SEARCH_NOTE_MODIFIED)),
("cardMod", tr(TR.SEARCH_CARD_MODIFIED)),
2020-02-27 03:25:19 +01:00
("cardDue", tr(TR.STATISTICS_DUE_DATE)),
("cardIvl", tr(TR.BROWSING_INTERVAL)),
("cardEase", tr(TR.BROWSING_EASE)),
("cardReps", tr(TR.SCHEDULING_REVIEWS)),
("cardLapses", tr(TR.SCHEDULING_LAPSES)),
("noteTags", tr(TR.EDITING_TAGS)),
("note", tr(TR.BROWSING_NOTE)),
]
self.columns.sort(key=itemgetter(1))
def reopen(
self,
_mw: AnkiQt,
card: Optional[Card] = None,
search: Optional[Tuple[Union[str, SearchNode]]] = None,
) -> None:
if search is not None:
self.search_for_terms(*search)
self.form.searchEdit.setFocus()
elif card:
self.show_single_card(card)
self.form.searchEdit.setFocus()
# Searching
######################################################################
def setupSearch(
self,
card: Optional[Card] = None,
search: Optional[Tuple[Union[str, SearchNode]]] = None,
) -> None:
qconnect(self.form.searchEdit.lineEdit().returnPressed, self.onSearchActivated)
self.form.searchEdit.setCompleter(None)
self.form.searchEdit.lineEdit().setPlaceholderText(
tr(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)
elif card:
self.show_single_card(card)
else:
self.search_for(
self.col.build_search_string(SearchNode(deck="current")), ""
)
self.form.searchEdit.setFocus()
# search triggered by user
@ensure_editor_saved
2021-02-01 00:39:55 +01:00
def onSearchActivated(self) -> None:
text = self.form.searchEdit.lineEdit().text()
try:
normed = self.col.build_search_string(text)
except InvalidInput as err:
show_invalid_search_error(err)
else:
self.search_for(normed)
self.update_history()
2021-02-01 00:39:55 +01:00
def search_for(self, search: str, prompt: Optional[str] = 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.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.model.search(self._lastSearchTxt)
except Exception as err:
show_invalid_search_error(err)
if not self.model.cards:
# no row change will fire
self.onRowChanged(None, None)
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
2021-01-30 11:24:33 +01:00
def updateTitle(self) -> int:
selected = len(self.form.tableView.selectionModel().selectedRows())
cur = len(self.model.cards)
2019-12-23 01:34:10 +01:00
self.setWindowTitle(
2020-11-18 04:40:21 +01:00
without_unicode_isolation(
tr(TR.BROWSING_WINDOW_TITLE, total=cur, selected=selected)
2019-12-23 01:34:10 +01:00
)
)
return selected
def search_for_terms(self, *search_terms: Union[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 show_single_card(self, card: Card) -> None:
if card.nid:
2021-02-01 00:39:55 +01:00
def on_show_single_card() -> None:
self.card = card
search = self.col.build_search_string(SearchNode(nid=card.nid))
search = gui_hooks.default_search(search, card)
self.search_for(search, "")
self.focusCid(card.id)
self.editor.call_after_note_saved(on_show_single_card)
2021-02-01 00:39:55 +01:00
def onReset(self) -> None:
self.sidebar.refresh()
self.model.reset()
# Table view & editor
######################################################################
2021-02-01 00:39:55 +01:00
def setupTable(self) -> None:
self.model = DataModel(self)
self.form.tableView.setSortingEnabled(True)
self.form.tableView.setModel(self.model)
self.form.tableView.selectionModel()
self.form.tableView.setItemDelegate(StatusDelegate(self, self.model))
qconnect(
self.form.tableView.selectionModel().selectionChanged, self.onRowChanged
)
2020-03-20 09:34:03 +01:00
self.form.tableView.setWordWrap(False)
if not theme_manager.night_mode:
self.form.tableView.setStyleSheet(
"QTableView{ selection-background-color: rgba(150, 150, 150, 50); "
"selection-color: black; }"
)
elif theme_manager.macos_dark_mode():
grid = colors.FRAME_BG
2020-02-02 04:09:02 +01:00
self.form.tableView.setStyleSheet(
f"""
QTableView {{ gridline-color: {grid} }}
2020-02-02 04:09:02 +01:00
"""
)
self.singleCard = False
2021-02-01 00:39:55 +01:00
def setupEditor(self) -> None:
def add_preview_button(leftbuttons: List[str], editor: Editor) -> None:
2021-01-10 00:00:19 +01:00
preview_shortcut = "Ctrl+Shift+P"
leftbuttons.insert(
0,
editor.addButton(
None,
"preview",
lambda _editor: self.onTogglePreview(),
tr(
TR.BROWSING_PREVIEW_SELECTED_CARD,
val=shortcut(preview_shortcut),
),
tr(TR.ACTIONS_PREVIEW),
id="previewButton",
keys=preview_shortcut,
disables=False,
rightside=False,
toggleable=True,
),
)
gui_hooks.editor_did_init_left_buttons.append(add_preview_button)
2019-12-23 01:34:10 +01:00
self.editor = aqt.editor.Editor(self.mw, self.form.fieldsArea, self)
gui_hooks.editor_did_init_left_buttons.remove(add_preview_button)
@ensure_editor_saved
def onRowChanged(
self, current: Optional[QItemSelection], previous: Optional[QItemSelection]
) -> None:
"""Update current note and hide/show editor."""
if self._closeEventHasCleanedUp:
return
update = self.updateTitle()
show = self.model.cards and update == 1
idx = self.form.tableView.selectionModel().currentIndex()
if idx.isValid():
self.card = self.model.getCard(idx)
show = show and self.card is not None
self.form.splitter.widget(1).setVisible(bool(show))
if not show:
self.editor.set_note(None)
self.singleCard = False
self._renderPreview()
else:
self.editor.set_note(self.card.note(reload=True), focusTo=self.focusTo)
self.focusTo = None
2020-03-04 17:41:26 +01:00
self.editor.card = self.card
self.singleCard = True
2018-11-12 03:10:50 +01:00
self._updateFlagsMenu()
gui_hooks.browser_did_change_row(self)
2021-02-01 00:39:55 +01:00
def currentRow(self) -> int:
2013-05-03 10:52:46 +02:00
idx = self.form.tableView.selectionModel().currentIndex()
return idx.row()
# Headers & sorting
######################################################################
2021-02-01 00:39:55 +01:00
def setupHeaders(self) -> None:
vh = self.form.tableView.verticalHeader()
hh = self.form.tableView.horizontalHeader()
if not isWin:
vh.hide()
hh.show()
restoreHeader(hh, "editor")
hh.setHighlightSections(False)
hh.setMinimumSectionSize(50)
hh.setSectionsMovable(True)
self.setColumnSizes()
hh.setContextMenuPolicy(Qt.CustomContextMenu)
qconnect(hh.customContextMenuRequested, self.onHeaderContext)
self.setSortIndicator()
qconnect(hh.sortIndicatorChanged, self.onSortChanged)
qconnect(hh.sectionMoved, self.onColumnMoved)
@ensure_editor_saved
2021-02-01 00:39:55 +01:00
def onSortChanged(self, idx: int, ord: int) -> None:
ord = bool(ord)
type = self.model.activeCols[idx]
noSort = ("question", "answer")
if type in noSort:
showInfo(tr(TR.BROWSING_SORTING_ON_THIS_COLUMN_IS_NOT))
2019-12-23 01:34:10 +01:00
type = self.col.conf["sortType"]
if self.col.conf["sortType"] != type:
self.col.conf["sortType"] = type
# default to descending for non-text fields
if type == "noteFld":
ord = not ord
self.col.set_config_bool(Config.Bool.BROWSER_SORT_BACKWARDS, ord)
2020-03-20 06:44:35 +01:00
self.col.save()
self.search()
else:
if self.col.get_config_bool(Config.Bool.BROWSER_SORT_BACKWARDS) != ord:
self.col.set_config_bool(Config.Bool.BROWSER_SORT_BACKWARDS, ord)
2020-03-20 06:44:35 +01:00
self.col.save()
self.model.reverse()
self.setSortIndicator()
2021-02-01 00:39:55 +01:00
def setSortIndicator(self) -> None:
hh = self.form.tableView.horizontalHeader()
2019-12-23 01:34:10 +01:00
type = self.col.conf["sortType"]
if type not in self.model.activeCols:
hh.setSortIndicatorShown(False)
return
idx = self.model.activeCols.index(type)
if self.col.get_config_bool(Config.Bool.BROWSER_SORT_BACKWARDS):
ord = Qt.DescendingOrder
else:
ord = Qt.AscendingOrder
hh.blockSignals(True)
hh.setSortIndicator(idx, ord)
hh.blockSignals(False)
hh.setSortIndicatorShown(True)
2021-02-01 00:39:55 +01:00
def onHeaderContext(self, pos: QPoint) -> None:
gpos = self.form.tableView.mapToGlobal(pos)
m = QMenu()
for type, name in self.columns:
a = m.addAction(name)
a.setCheckable(True)
a.setChecked(type in self.model.activeCols)
qconnect(a.toggled, lambda b, t=type: self.toggleField(t))
gui_hooks.browser_header_will_show_context_menu(self, m)
m.exec_(gpos)
@ensure_editor_saved_on_trigger
2021-02-01 00:39:55 +01:00
def toggleField(self, type: str) -> None:
self.model.beginReset()
if type in self.model.activeCols:
if len(self.model.activeCols) < 2:
self.model.endReset()
2021-02-01 00:39:55 +01:00
showInfo(tr(TR.BROWSING_YOU_MUST_HAVE_AT_LEAST_ONE))
return
self.model.activeCols.remove(type)
2019-12-23 01:34:10 +01:00
adding = False
else:
self.model.activeCols.append(type)
2019-12-23 01:34:10 +01:00
adding = True
self.col.conf["activeCols"] = self.model.activeCols
# sorted field may have been hidden
self.setSortIndicator()
self.setColumnSizes()
self.model.endReset()
# if we added a column, scroll to it
if adding:
row = self.currentRow()
idx = self.model.index(row, len(self.model.activeCols) - 1)
self.form.tableView.scrollTo(idx)
2021-02-01 00:39:55 +01:00
def setColumnSizes(self) -> None:
hh = self.form.tableView.horizontalHeader()
hh.setSectionResizeMode(QHeaderView.Interactive)
2019-12-23 01:34:10 +01:00
hh.setSectionResizeMode(
hh.logicalIndex(len(self.model.activeCols) - 1), QHeaderView.Stretch
)
# this must be set post-resize or it doesn't work
hh.setCascadingSectionResizes(False)
2021-02-01 00:39:55 +01:00
def onColumnMoved(self, *args: Any) -> None:
self.setColumnSizes()
def setupSidebar(self) -> None:
dw = self.sidebarDockWidget = QDockWidget(tr(TR.BROWSING_SIDEBAR), self)
dw.setFeatures(QDockWidget.DockWidgetClosable)
dw.setObjectName("Sidebar")
dw.setAllowedAreas(Qt.LeftDockWidgetArea)
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())
2017-08-14 08:57:43 +02:00
self.addDockWidget(Qt.LeftDockWidgetArea, 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)
def showSidebar(self) -> None:
# workaround for PyQt focus bug
self.editor.hideCompleters()
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:
if not self.card:
return
info, cs = self._cardInfoData()
reps = self._revlogData(cs)
2019-12-23 01:34:10 +01:00
2020-02-12 22:12:45 +01:00
card_info_dialog = CardInfoDialog(self)
l = QVBoxLayout()
2019-12-23 01:34:10 +01:00
l.setContentsMargins(0, 0, 0, 0)
w = AnkiWebView(title="browser card info")
l.addWidget(w)
2020-02-12 22:12:45 +01:00
w.stdHtml(info + "<p>" + reps, context=card_info_dialog)
bb = QDialogButtonBox(QDialogButtonBox.Close)
l.addWidget(bb)
qconnect(bb.rejected, card_info_dialog.reject)
2020-02-12 22:12:45 +01:00
card_info_dialog.setLayout(l)
card_info_dialog.setWindowModality(Qt.WindowModal)
card_info_dialog.resize(500, 400)
2020-02-18 01:59:24 +01:00
restoreGeom(card_info_dialog, "revlog")
2020-02-12 22:12:45 +01:00
card_info_dialog.show()
def _cardInfoData(self) -> Tuple[str, CardStats]:
cs = CardStats(self.col, self.card)
rep = cs.report(include_revlog=True)
return rep, cs
# legacy - revlog used to be generated here, and some add-ons
# wrapped this function
def _revlogData(self, cs: CardStats) -> str:
return ""
# Menu helpers
######################################################################
2021-02-01 00:39:55 +01:00
def selectedCards(self) -> List[int]:
2019-12-23 01:34:10 +01:00
return [
self.model.cards[idx.row()]
for idx in self.form.tableView.selectionModel().selectedRows()
]
2021-02-01 00:39:55 +01:00
def selectedNotes(self) -> List[int]:
2019-12-23 01:34:10 +01:00
return self.col.db.list(
"""
select distinct nid from cards
2019-12-23 01:34:10 +01:00
where id in %s"""
% ids2str(
[
self.model.cards[idx.row()]
for idx in self.form.tableView.selectionModel().selectedRows()
]
)
)
2021-02-01 00:39:55 +01:00
def selectedNotesAsCards(self) -> List[int]:
return self.col.db.list(
2019-12-23 01:34:10 +01:00
"select id from cards where nid in (%s)"
% ",".join([str(s) for s in self.selectedNotes()])
)
2021-02-01 00:39:55 +01:00
def oneModelNotes(self) -> List[int]:
sf = self.selectedNotes()
if not sf:
2021-02-01 00:39:55 +01:00
return []
2019-12-23 01:34:10 +01:00
mods = self.col.db.scalar(
"""
select count(distinct mid) from notes
2019-12-23 01:34:10 +01:00
where id in %s"""
% ids2str(sf)
)
if mods > 1:
showInfo(tr(TR.BROWSING_PLEASE_SELECT_CARDS_FROM_ONLY_ONE))
2021-02-01 00:39:55 +01:00
return []
return sf
2021-02-01 00:39:55 +01:00
def onHelp(self) -> None:
openHelp(HelpPage.BROWSING)
# Misc menu options
######################################################################
@ensure_editor_saved_on_trigger
2021-02-01 00:39:55 +01:00
def onChangeModel(self) -> None:
nids = self.oneModelNotes()
if nids:
ChangeModel(self, nids)
def createFilteredDeck(self) -> None:
search = self.form.searchEdit.lineEdit().text()
if self.mw.col.schedVer() != 1 and KeyboardModifiersPressed().alt:
aqt.dialogs.open("DynDeckConfDialog", self.mw, search_2=search)
else:
aqt.dialogs.open("DynDeckConfDialog", 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()
self._on_preview_closed()
2013-05-03 10:52:46 +02:00
else:
self._previewer = PreviewDialog(self, self.mw, self._on_preview_closed)
self._previewer.open()
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()
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:
if self.editor.web:
self.editor.web.eval("$('#previewButton').removeClass('highlighted')")
self._previewer = None
# Card deletion
######################################################################
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
# nothing selected?
nids = self.selectedNotes()
if not nids:
return
# select the next card if there is one
self._onNextCard()
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
remove_notes(
mw=self.mw,
note_ids=nids,
2021-03-14 10:54:15 +01:00
success=lambda _: tooltip(tr(TR.BROWSING_NOTE_DELETED, count=len(nids))),
)
# legacy
deleteNotes = delete_selected_notes
# Deck change
######################################################################
@ensure_editor_saved_on_trigger
def set_deck_of_selected_cards(self) -> None:
from aqt.studydeck import StudyDeck
2019-12-23 01:34:10 +01:00
cids = self.selectedCards()
if not cids:
return
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"]
ret = StudyDeck(
2019-12-23 01:34:10 +01:00
self.mw,
current=current,
accept=tr(TR.BROWSING_MOVE_CARDS),
title=tr(TR.BROWSING_CHANGE_DECK),
help=HelpPage.BROWSING,
2019-12-23 01:34:10 +01:00
parent=self,
)
if not ret.name:
return
did = self.col.decks.id(ret.name)
2021-03-12 07:27:40 +01:00
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_card_deck(mw=self.mw, card_ids=cids, deck_id=did)
# legacy
setDeck = set_deck_of_selected_cards
# Tags
######################################################################
@ensure_editor_saved_on_trigger
2021-03-05 11:47:51 +01:00
def add_tags_to_selected_notes(
2021-02-01 00:39:55 +01:00
self,
tags: Optional[str] = None,
) -> None:
2021-03-05 11:47:51 +01:00
"Shows prompt if tags not provided."
if not (
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
tags := tags or self._prompt_for_tags(tr(TR.BROWSING_ENTER_TAGS_TO_ADD))
):
return
add_tags(mw=self.mw, note_ids=self.selectedNotes(), space_separated_tags=tags)
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
@ensure_editor_saved_on_trigger
2021-03-05 11:47:51 +01:00
def remove_tags_from_selected_notes(self, tags: Optional[str] = None) -> None:
"Shows prompt if tags not provided."
if not (
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
tags := tags or self._prompt_for_tags(tr(TR.BROWSING_ENTER_TAGS_TO_DELETE))
):
return
remove_tags(
mw=self.mw, note_ids=self.selectedNotes(), space_separated_tags=tags
)
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
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 _prompt_for_tags(self, prompt: str) -> Optional[str]:
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
@ensure_editor_saved_on_trigger
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:
clear_unused_tags(mw=self.mw, parent=self)
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-04 13:14:35 +01:00
def current_card_is_suspended(self) -> bool:
2020-03-23 18:16:39 +01:00
return bool(self.card and self.card.queue == QUEUE_TYPE_SUSPENDED)
@ensure_editor_saved_on_trigger
2021-03-04 13:14:35 +01:00
def suspend_selected_cards(self) -> None:
want_suspend = not self.current_card_is_suspended()
cids = self.selectedCards()
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
if want_suspend:
suspend_cards(mw=self.mw, card_ids=cids)
else:
unsuspend_cards(mw=self.mw, card_ids=cids)
2020-02-10 04:15:10 +01:00
# Exporting
######################################################################
2021-02-01 00:39:55 +01:00
def _on_export_notes(self) -> None:
2020-02-10 04:15:10 +01:00
cids = self.selectedNotesAsCards()
if cids:
ExportDialog(self.mw, cids=cids)
# Flags & Marking
######################################################################
2021-02-01 00:39:55 +01:00
def onSetFlag(self, n: int) -> None:
if not self.card:
return
self.editor.call_after_note_saved(lambda: self._on_set_flag(n))
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 _on_set_flag(self, flag: int) -> None:
# flag needs toggling off?
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
if flag == self.card.user_flag():
flag = 0
2021-03-14 10:54:15 +01:00
cids = self.selectedCards()
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_card_flag(mw=self.mw, card_ids=cids, flag=flag)
2021-02-01 00:39:55 +01:00
def _updateFlagsMenu(self) -> None:
flag = self.card and self.card.user_flag()
2018-11-12 03:10:50 +01:00
flag = flag or 0
f = self.form
2019-12-23 01:34:10 +01:00
flagActions = [
f.actionRed_Flag,
f.actionOrange_Flag,
f.actionGreen_Flag,
f.actionBlue_Flag,
]
2018-11-12 03:10:50 +01:00
for c, act in enumerate(flagActions):
2019-12-23 01:34:10 +01:00
act.setChecked(flag == c + 1)
qtMenuShortcutWorkaround(self.form.menuFlag)
2018-11-12 03:10:50 +01:00
2021-02-01 00:39:55 +01:00
def onMark(self, mark: bool = None) -> None:
if mark is None:
mark = not self.isMarked()
if mark:
2021-03-05 11:47:51 +01:00
self.add_tags_to_selected_notes(tags="marked")
else:
2021-03-05 11:47:51 +01:00
self.remove_tags_from_selected_notes(tags="marked")
2021-02-01 00:39:55 +01:00
def isMarked(self) -> bool:
return bool(self.card and self.card.note().has_tag("Marked"))
# Repositioning
######################################################################
@ensure_editor_saved_on_trigger
2021-02-01 00:39:55 +01:00
def reposition(self) -> None:
cids = self.selectedCards()
cids2 = self.col.db.list(
2020-01-31 09:58:03 +01:00
f"select id from cards where type = {CARD_TYPE_NEW} and id in "
+ ids2str(cids)
2019-12-23 01:34:10 +01:00
)
if not cids2:
2021-02-01 00:39:55 +01:00
showInfo(tr(TR.BROWSING_ONLY_NEW_CARDS_CAN_BE_REPOSITIONED))
return
d = QDialog(self)
disable_help_button(d)
d.setWindowModality(Qt.WindowModal)
frm = aqt.forms.reposition.Ui_Dialog()
frm.setupUi(d)
(pmin, pmax) = self.col.db.first(
2020-02-02 10:19:45 +01:00
f"select min(due), max(due) from cards where type={CARD_TYPE_NEW} and odid=0"
2019-12-23 01:34:10 +01:00
)
pmin = pmin or 0
pmax = pmax or 0
txt = tr(TR.BROWSING_QUEUE_TOP, val=pmin)
txt += "\n" + tr(TR.BROWSING_QUEUE_BOTTOM, val=pmax)
frm.label.setText(txt)
frm.start.selectAll()
if not d.exec_():
return
self.model.beginReset()
self.mw.checkpoint(tr(TR.ACTIONS_REPOSITION))
self.col.sched.sortCards(
2019-12-23 01:34:10 +01:00
cids,
start=frm.start.value(),
step=frm.step.value(),
shuffle=frm.randomize.isChecked(),
shift=frm.shift.isChecked(),
)
self.search()
2020-08-16 18:49:51 +02:00
self.mw.requireReset(reason=ResetReason.BrowserReposition, context=self)
self.model.endReset()
# Scheduling
######################################################################
@ensure_editor_saved_on_trigger
def set_due_date(self) -> None:
set_due_date_dialog(
mw=self.mw,
parent=self,
card_ids=self.selectedCards(),
config_key=Config.String.SET_DUE_BROWSER,
)
@ensure_editor_saved_on_trigger
def forget_cards(self) -> None:
forget_cards(
mw=self.mw,
parent=self,
card_ids=self.selectedCards(),
)
# Edit: selection
######################################################################
@ensure_editor_saved_on_trigger
2021-02-01 00:39:55 +01:00
def selectNotes(self) -> None:
nids = self.selectedNotes()
# clear the selection so we don't waste energy preserving it
tv = self.form.tableView
tv.selectionModel().clear()
2020-10-19 20:51:36 +02:00
search = self.col.build_search_string(
SearchNode(nids=SearchNode.IdList(ids=nids))
)
2020-10-19 20:51:36 +02:00
self.search_for(search)
tv.selectAll()
2021-02-01 00:39:55 +01:00
def invertSelection(self) -> None:
sm = self.form.tableView.selectionModel()
items = sm.selection()
self.form.tableView.selectAll()
sm.select(items, QItemSelectionModel.Deselect | QItemSelectionModel.Rows)
# Hooks
######################################################################
def setupHooks(self) -> None:
2020-01-15 07:53:24 +01:00
gui_hooks.undo_state_did_change.append(self.onUndoState)
# fixme: remove these once all items are using `operation_did_execute`
gui_hooks.sidebar_should_refresh_decks.append(self.on_item_added)
gui_hooks.sidebar_should_refresh_notetypes.append(self.on_item_added)
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
gui_hooks.operations_will_execute.append(self.on_operations_will_execute)
gui_hooks.operations_did_execute.append(self.on_operations_did_execute)
gui_hooks.operation_did_execute.append(self.on_operation_did_execute)
gui_hooks.focus_did_change.append(self.on_focus_change)
def teardownHooks(self) -> None:
2020-01-15 07:53:24 +01:00
gui_hooks.undo_state_did_change.remove(self.onUndoState)
gui_hooks.sidebar_should_refresh_decks.remove(self.on_item_added)
gui_hooks.sidebar_should_refresh_notetypes.remove(self.on_item_added)
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
gui_hooks.operations_will_execute.remove(self.on_operations_will_execute)
gui_hooks.operations_did_execute.remove(self.on_operations_will_execute)
gui_hooks.operation_did_execute.remove(self.on_operation_did_execute)
gui_hooks.focus_did_change.remove(self.on_focus_change)
# covers the tag, note and deck case
def on_item_added(self, item: Any = None) -> None:
self.sidebar.refresh()
2021-02-01 00:39:55 +01:00
def on_tag_list_update(self) -> None:
self.sidebar.refresh()
# Undo
######################################################################
def undo(self) -> None:
# need to make sure we don't hang the UI by redrawing the card list
# during the long-running op. mw.undo will take care of the progress
# dialog
self.setUpdatesEnabled(False)
self.mw.undo(lambda _: self.setUpdatesEnabled(True))
2021-02-01 00:39:55 +01:00
def onUndoState(self, on: bool) -> None:
self.form.actionUndo.setEnabled(on)
if on:
self.form.actionUndo.setText(self.mw.form.actionUndo.text())
# Edit: replacing
######################################################################
@ensure_editor_saved_on_trigger
2021-02-01 00:39:55 +01:00
def onFindReplace(self) -> None:
nids = self.selectedNotes()
if not nids:
return
import anki.find
2019-12-23 01:34:10 +01:00
2021-02-01 00:39:55 +01:00
def find() -> List[str]:
return anki.find.fieldNamesForNotes(self.mw.col, nids)
2021-02-01 00:39:55 +01:00
def on_done(fut: Future) -> None:
self._on_find_replace_diag(fut.result(), nids)
self.mw.taskman.with_progress(find, on_done, self)
def _on_find_replace_diag(self, fields: List[str], nids: List[int]) -> None:
d = QDialog(self)
disable_help_button(d)
frm = aqt.forms.findreplace.Ui_Dialog()
frm.setupUi(d)
d.setWindowModality(Qt.WindowModal)
combo = "BrowserFindAndReplace"
findhistory = restore_combo_history(frm.find, combo + "Find")
frm.find._completer().setCaseSensitivity(True)
replacehistory = restore_combo_history(frm.replace, combo + "Replace")
frm.replace._completer().setCaseSensitivity(True)
restore_is_checked(frm.re, combo + "Regex")
restore_is_checked(frm.ignoreCase, combo + "ignoreCase")
frm.find.setFocus()
allfields = [tr(TR.BROWSING_ALL_FIELDS)] + fields
frm.field.addItems(allfields)
restore_combo_index_for_session(frm.field, allfields, combo + "Field")
qconnect(frm.buttonBox.helpRequested, self.onFindReplaceHelp)
restoreGeom(d, "findreplace")
r = d.exec_()
saveGeom(d, "findreplace")
if not r:
return
save_combo_index_for_session(frm.field, combo + "Field")
if frm.field.currentIndex() == 0:
field = None
else:
2019-12-23 01:34:10 +01:00
field = fields[frm.field.currentIndex() - 1]
search = save_combo_history(frm.find, findhistory, combo + "Find")
replace = save_combo_history(frm.replace, replacehistory, combo + "Replace")
regex = frm.re.isChecked()
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
match_case = not frm.ignoreCase.isChecked()
save_is_checked(frm.re, combo + "Regex")
save_is_checked(frm.ignoreCase, combo + "ignoreCase")
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
find_and_replace(
mw=self.mw,
parent=self,
note_ids=nids,
search=search,
replacement=replace,
regex=regex,
field_name=field,
match_case=match_case,
)
2021-02-01 00:39:55 +01:00
def onFindReplaceHelp(self) -> None:
openHelp(HelpPage.BROWSING_FIND_AND_REPLACE)
# Edit: finding dupes
######################################################################
@ensure_editor_saved
2021-02-01 00:39:55 +01:00
def onFindDupes(self) -> None:
d = QDialog(self)
self.mw.garbage_collect_on_dialog_finish(d)
frm = aqt.forms.finddupes.Ui_Dialog()
frm.setupUi(d)
restoreGeom(d, "findDupes")
disable_help_button(d)
searchHistory = restore_combo_history(frm.search, "findDupesFind")
2019-12-23 01:34:10 +01:00
fields = sorted(
anki.find.fieldNames(self.col, downcase=False), key=lambda x: x.lower()
)
frm.fields.addItems(fields)
restore_combo_index_for_session(frm.fields, fields, "findDupesFields")
self._dupesButton = None
# links
frm.webView.title = "find duplicates"
web_context = FindDupesDialog(dialog=d, browser=self)
frm.webView.set_bridge_command(self.dupeLinkClicked, web_context)
frm.webView.stdHtml("", context=web_context)
2019-12-23 01:34:10 +01:00
2021-02-01 00:39:55 +01:00
def onFin(code: Any) -> None:
saveGeom(d, "findDupes")
2019-12-23 01:34:10 +01:00
qconnect(d.finished, onFin)
2019-12-23 01:34:10 +01:00
2021-02-01 00:39:55 +01:00
def onClick() -> None:
search_text = save_combo_history(frm.search, searchHistory, "findDupesFind")
save_combo_index_for_session(frm.fields, "findDupesFields")
field = fields[frm.fields.currentIndex()]
self.duplicatesReport(frm.webView, field, search_text, frm, web_context)
2019-12-23 01:34:10 +01:00
search = frm.buttonBox.addButton(
tr(TR.ACTIONS_SEARCH), QDialogButtonBox.ActionRole
)
qconnect(search.clicked, onClick)
d.show()
2021-02-01 00:39:55 +01:00
def duplicatesReport(
self,
web: AnkiWebView,
fname: str,
search: str,
frm: aqt.forms.finddupes.Ui_Dialog,
web_context: FindDupesDialog,
) -> None:
self.mw.progress.start()
try:
res = self.mw.col.findDupes(fname, search)
except InvalidInput as e:
self.mw.progress.finish()
show_invalid_search_error(e)
return
if not self._dupesButton:
self._dupesButton = b = frm.buttonBox.addButton(
tr(TR.BROWSING_TAG_DUPLICATES), QDialogButtonBox.ActionRole
2019-12-23 01:34:10 +01:00
)
qconnect(b.clicked, lambda: self._onTagDupes(res))
2020-02-11 23:28:33 +01:00
t = ""
groups = len(res)
notes = sum(len(r[1]) for r in res)
2020-11-18 00:22:27 +01:00
part1 = tr(TR.BROWSING_GROUP, count=groups)
part2 = tr(TR.BROWSING_NOTE_COUNT, count=notes)
t += tr(TR.BROWSING_FOUND_AS_ACROSS_BS, part=part1, whole=part2)
t += "<p><ol>"
for val, nids in res:
2019-12-23 01:34:10 +01:00
t += (
"""<li><a href=# onclick="pycmd('%s');return false;">%s</a>: %s</a>"""
% (
html.escape(
self.col.build_search_string(
SearchNode(nids=SearchNode.IdList(ids=nids))
)
),
2020-11-18 00:22:27 +01:00
tr(TR.BROWSING_NOTE_COUNT, count=len(nids)),
2019-12-23 01:34:10 +01:00
html.escape(val),
)
)
t += "</ol>"
web.stdHtml(t, context=web_context)
self.mw.progress.finish()
2021-02-01 00:39:55 +01:00
def _onTagDupes(self, res: List[Any]) -> None:
if not res:
return
self.model.beginReset()
self.mw.checkpoint(tr(TR.BROWSING_TAG_DUPLICATES))
nids = set()
for _, nidlist in res:
nids.update(nidlist)
2021-03-05 11:47:51 +01:00
self.col.tags.bulk_add(list(nids), tr(TR.BROWSING_DUPLICATE))
self.mw.progress.finish()
self.model.endReset()
2020-08-16 18:49:51 +02:00
self.mw.requireReset(reason=ResetReason.BrowserTagDupes, context=self)
tooltip(tr(TR.BROWSING_NOTES_TAGGED))
2021-02-01 00:39:55 +01:00
def dupeLinkClicked(self, link: str) -> None:
2020-10-19 20:51:36 +02:00
self.search_for(link)
self.onNote()
# Jumping
######################################################################
2021-02-01 00:39:55 +01:00
def _moveCur(self, dir: int, idx: QModelIndex = None) -> None:
if not self.model.cards:
return
tv = self.form.tableView
if idx is None:
idx = tv.moveCursor(dir, self.mw.app.keyboardModifiers())
tv.selectionModel().setCurrentIndex(
idx,
2019-12-23 01:34:10 +01:00
QItemSelectionModel.Clear
| QItemSelectionModel.Select
| QItemSelectionModel.Rows,
)
2021-02-01 00:39:55 +01:00
def onPreviousCard(self) -> None:
self.focusTo = self.editor.currentField
self.editor.call_after_note_saved(self._onPreviousCard)
2021-02-01 00:39:55 +01:00
def _onPreviousCard(self) -> None:
self._moveCur(QAbstractItemView.MoveUp)
2021-02-01 00:39:55 +01:00
def onNextCard(self) -> None:
self.focusTo = self.editor.currentField
self.editor.call_after_note_saved(self._onNextCard)
2021-02-01 00:39:55 +01:00
def _onNextCard(self) -> None:
self._moveCur(QAbstractItemView.MoveDown)
2021-02-01 00:39:55 +01:00
def onFirstCard(self) -> None:
sm = self.form.tableView.selectionModel()
idx = sm.currentIndex()
self._moveCur(None, self.model.index(0, 0))
if not KeyboardModifiersPressed().shift:
return
idx2 = sm.currentIndex()
item = QItemSelection(idx2, idx)
2019-12-23 01:34:10 +01:00
sm.select(item, QItemSelectionModel.SelectCurrent | QItemSelectionModel.Rows)
2021-02-01 00:39:55 +01:00
def onLastCard(self) -> None:
sm = self.form.tableView.selectionModel()
idx = sm.currentIndex()
2019-12-23 01:34:10 +01:00
self._moveCur(None, self.model.index(len(self.model.cards) - 1, 0))
if not KeyboardModifiersPressed().shift:
return
idx2 = sm.currentIndex()
item = QItemSelection(idx, idx2)
2019-12-23 01:34:10 +01:00
sm.select(item, QItemSelectionModel.SelectCurrent | QItemSelectionModel.Rows)
2021-02-01 00:39:55 +01:00
def onFind(self) -> None:
# workaround for PyQt focus bug
self.editor.hideCompleters()
self.form.searchEdit.setFocus()
self.form.searchEdit.lineEdit().selectAll()
2021-02-01 00:39:55 +01:00
def onNote(self) -> None:
# workaround for PyQt focus bug
self.editor.hideCompleters()
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()
2021-02-01 00:39:55 +01:00
def focusCid(self, cid: int) -> None:
try:
row = list(self.model.cards).index(cid)
except ValueError:
return
self.form.tableView.clearSelection()
self.form.tableView.selectRow(row)
2019-12-23 01:34:10 +01:00
# Change model dialog
######################################################################
2019-12-23 01:34:10 +01:00
class ChangeModel(QDialog):
2021-02-01 00:39:55 +01:00
def __init__(self, browser: Browser, nids: List[int]) -> None:
QDialog.__init__(self, browser)
self.browser = browser
self.nids = nids
self.oldModel = browser.card.note().model()
self.form = aqt.forms.changemodel.Ui_Dialog()
self.form.setupUi(self)
disable_help_button(self)
self.setWindowModality(Qt.WindowModal)
self.setup()
restoreGeom(self, "changeModel")
2020-01-15 07:53:24 +01:00
gui_hooks.state_did_reset.append(self.onReset)
gui_hooks.current_note_type_did_change.append(self.on_note_type_change)
# ugh - these are set dynamically by rebuildTemplateMap()
self.tcombos: List[QComboBox] = []
self.fcombos: List[QComboBox] = []
self.exec_()
def on_note_type_change(self, notetype: NoteType) -> None:
self.onReset()
2021-02-01 00:39:55 +01:00
def setup(self) -> None:
# maps
self.flayout = QHBoxLayout()
2019-12-23 01:34:10 +01:00
self.flayout.setContentsMargins(0, 0, 0, 0)
self.fwidg = None
self.form.fieldMap.setLayout(self.flayout)
self.tlayout = QHBoxLayout()
2019-12-23 01:34:10 +01:00
self.tlayout.setContentsMargins(0, 0, 0, 0)
self.twidg = None
self.form.templateMap.setLayout(self.tlayout)
if self.style().objectName() == "gtk+":
# gtk+ requires margins in inner layout
self.form.verticalLayout_2.setContentsMargins(0, 11, 0, 0)
self.form.verticalLayout_3.setContentsMargins(0, 11, 0, 0)
# model chooser
import aqt.modelchooser
2019-12-23 01:34:10 +01:00
self.oldModel = self.browser.col.models.get(
self.browser.col.db.scalar(
2019-12-23 01:34:10 +01:00
"select mid from notes where id = ?", self.nids[0]
)
)
self.form.oldModelLabel.setText(self.oldModel["name"])
self.modelChooser = aqt.modelchooser.ModelChooser(
2019-12-23 01:34:10 +01:00
self.browser.mw, self.form.modelChooserWidget, label=False
)
self.modelChooser.models.setFocus()
qconnect(self.form.buttonBox.helpRequested, self.onHelp)
self.modelChanged(self.browser.mw.col.models.current())
self.pauseUpdate = False
2021-02-01 00:39:55 +01:00
def onReset(self) -> None:
self.modelChanged(self.browser.col.models.current())
2021-02-01 00:39:55 +01:00
def modelChanged(self, model: Dict[str, Any]) -> None:
self.targetModel = model
self.rebuildTemplateMap()
self.rebuildFieldMap()
2021-02-01 00:39:55 +01:00
def rebuildTemplateMap(
self, key: Optional[str] = None, attr: Optional[str] = None
) -> None:
if not key:
key = "t"
attr = "tmpls"
map = getattr(self, key + "widg")
lay = getattr(self, key + "layout")
src = self.oldModel[attr]
dst = self.targetModel[attr]
if map:
lay.removeWidget(map)
map.deleteLater()
setattr(self, key + "MapWidget", None)
map = QWidget()
l = QGridLayout()
combos = []
targets = [x["name"] for x in dst] + [tr(TR.BROWSING_NOTHING)]
indices = {}
for i, x in enumerate(src):
l.addWidget(QLabel(tr(TR.BROWSING_CHANGE_TO, val=x["name"])), i, 0)
cb = QComboBox()
cb.addItems(targets)
2019-12-23 01:34:10 +01:00
idx = min(i, len(targets) - 1)
cb.setCurrentIndex(idx)
indices[cb] = idx
qconnect(
cb.currentIndexChanged,
lambda i, cb=cb, key=key: self.onComboChanged(i, cb, key),
2019-12-23 01:34:10 +01:00
)
combos.append(cb)
l.addWidget(cb, i, 1)
map.setLayout(l)
lay.addWidget(map)
setattr(self, key + "widg", map)
setattr(self, key + "layout", lay)
setattr(self, key + "combos", combos)
setattr(self, key + "indices", indices)
2021-02-01 00:39:55 +01:00
def rebuildFieldMap(self) -> None:
return self.rebuildTemplateMap(key="f", attr="flds")
2021-02-01 00:39:55 +01:00
def onComboChanged(self, i: int, cb: QComboBox, key: str) -> None:
indices = getattr(self, key + "indices")
if self.pauseUpdate:
indices[cb] = i
return
combos = getattr(self, key + "combos")
if i == cb.count() - 1:
# set to 'nothing'
return
# find another combo with same index
for c in combos:
if c == cb:
continue
if c.currentIndex() == i:
self.pauseUpdate = True
c.setCurrentIndex(indices[cb])
self.pauseUpdate = False
break
indices[cb] = i
2021-02-01 00:39:55 +01:00
def getTemplateMap(
self,
old: Optional[List[Dict[str, Any]]] = None,
combos: Optional[List[QComboBox]] = None,
new: Optional[List[Dict[str, Any]]] = None,
) -> Dict[int, Optional[int]]:
if not old:
2019-12-23 01:34:10 +01:00
old = self.oldModel["tmpls"]
combos = self.tcombos
2019-12-23 01:34:10 +01:00
new = self.targetModel["tmpls"]
template_map: Dict[int, Optional[int]] = {}
for i, f in enumerate(old):
idx = combos[i].currentIndex()
if idx == len(new):
# ignore
template_map[f["ord"]] = None
else:
f2 = new[idx]
template_map[f["ord"]] = f2["ord"]
return template_map
2021-02-01 00:39:55 +01:00
def getFieldMap(self) -> Dict[int, Optional[int]]:
return self.getTemplateMap(
2019-12-23 01:34:10 +01:00
old=self.oldModel["flds"], combos=self.fcombos, new=self.targetModel["flds"]
)
def cleanup(self) -> None:
2020-01-15 07:53:24 +01:00
gui_hooks.state_did_reset.remove(self.onReset)
gui_hooks.current_note_type_did_change.remove(self.on_note_type_change)
self.modelChooser.cleanup()
saveGeom(self, "changeModel")
2021-02-01 00:39:55 +01:00
def reject(self) -> None:
self.cleanup()
return QDialog.reject(self)
2021-02-01 00:39:55 +01:00
def accept(self) -> None:
# check maps
fmap = self.getFieldMap()
cmap = self.getTemplateMap()
if any(True for c in list(cmap.values()) if c is None):
2020-11-18 02:32:22 +01:00
if not askUser(tr(TR.BROWSING_ANY_CARDS_MAPPED_TO_NOTHING_WILL)):
return
self.browser.mw.checkpoint(tr(TR.BROWSING_CHANGE_NOTE_TYPE))
b = self.browser
b.mw.col.modSchema(check=True)
b.mw.progress.start()
b.model.beginReset()
mm = b.mw.col.models
mm.change(self.oldModel, self.nids, self.targetModel, fmap, cmap)
b.search()
b.model.endReset()
b.mw.progress.finish()
b.mw.reset()
self.cleanup()
QDialog.accept(self)
2021-02-01 00:39:55 +01:00
def onHelp(self) -> None:
openHelp(HelpPage.BROWSING_OTHER_MENU_ITEMS)
2020-02-12 22:12:45 +01:00
# Card Info Dialog
######################################################################
2020-02-12 22:15:44 +01:00
2020-02-12 22:12:45 +01:00
class CardInfoDialog(QDialog):
silentlyClose = True
2021-02-01 00:39:55 +01:00
def __init__(self, browser: Browser) -> None:
super().__init__(browser)
2020-02-12 22:12:45 +01:00
self.browser = browser
disable_help_button(self)
2020-02-12 22:12:45 +01:00
2021-02-01 00:39:55 +01:00
def reject(self) -> None:
2020-02-12 22:12:45 +01:00
saveGeom(self, "revlog")
return QDialog.reject(self)