459 lines
17 KiB
Python
459 lines
17 KiB
Python
import aqt
|
|
import aqt.editor
|
|
import aqt.browser.previewer
|
|
from aqt import gui_hooks
|
|
from aqt.qt import Qt, QKeySequence, QShortcut, QCloseEvent, QMainWindow
|
|
from aqt.utils import restoreGeom, saveGeom, tooltip
|
|
from anki.errors import NotFoundError
|
|
from anki.consts import QUEUE_TYPE_SUSPENDED
|
|
from anki.utils import ids2str
|
|
|
|
from . import anki_version
|
|
|
|
|
|
# Edit dialog. Like Edit Current, but:
|
|
# * has a Preview button to preview the cards for the note
|
|
# * has Previous/Back buttons to navigate the history of the dialog
|
|
# * has a Browse button to open the history in the Browser
|
|
# * has no bar with the Close button
|
|
#
|
|
# To register in Anki's dialog system:
|
|
# > from .edit import Edit
|
|
# > Edit.register_with_anki()
|
|
#
|
|
# To (re)open (note_id is an integer):
|
|
# > Edit.open_dialog_and_show_note_with_id(note_id)
|
|
|
|
|
|
DOMAIN_PREFIX = "foosoft.ankiconnect."
|
|
|
|
|
|
def get_note_by_note_id(note_id):
|
|
return aqt.mw.col.get_note(note_id)
|
|
|
|
def is_card_suspended(card):
|
|
return card.queue == QUEUE_TYPE_SUSPENDED
|
|
|
|
def filter_valid_note_ids(note_ids):
|
|
return aqt.mw.col.db.list(
|
|
"select id from notes where id in " + ids2str(note_ids)
|
|
)
|
|
|
|
|
|
##############################################################################
|
|
|
|
|
|
class DecentPreviewer(aqt.browser.previewer.MultiCardPreviewer):
|
|
class Adapter:
|
|
def get_current_card(self): raise NotImplementedError
|
|
def can_select_previous_card(self): raise NotImplementedError
|
|
def can_select_next_card(self): raise NotImplementedError
|
|
def select_previous_card(self): raise NotImplementedError
|
|
def select_next_card(self): raise NotImplementedError
|
|
|
|
def __init__(self, adapter: Adapter):
|
|
super().__init__(parent=None, mw=aqt.mw, on_close=lambda: None) # noqa
|
|
self.adapter = adapter
|
|
self.last_card_id = 0
|
|
|
|
def card(self):
|
|
return self.adapter.get_current_card()
|
|
|
|
def card_changed(self):
|
|
current_card_id = self.adapter.get_current_card().id
|
|
changed = self.last_card_id != current_card_id
|
|
self.last_card_id = current_card_id
|
|
return changed
|
|
|
|
# the check if we can select next/previous card is needed because
|
|
# the buttons sometimes get disabled a tad too late
|
|
# and can still be pressed by user.
|
|
# this is likely due to Anki sometimes delaying rendering of cards
|
|
# in order to avoid rendering them too fast?
|
|
def _on_prev_card(self):
|
|
if self.adapter.can_select_previous_card():
|
|
self.adapter.select_previous_card()
|
|
self.render_card()
|
|
|
|
def _on_next_card(self):
|
|
if self.adapter.can_select_next_card():
|
|
self.adapter.select_next_card()
|
|
self.render_card()
|
|
|
|
def _should_enable_prev(self):
|
|
return self.showing_answer_and_can_show_question() or \
|
|
self.adapter.can_select_previous_card()
|
|
|
|
def _should_enable_next(self):
|
|
return self.showing_question_and_can_show_answer() or \
|
|
self.adapter.can_select_next_card()
|
|
|
|
def _render_scheduled(self):
|
|
super()._render_scheduled() # noqa
|
|
self._updateButtons()
|
|
|
|
def showing_answer_and_can_show_question(self):
|
|
return self._state == "answer" and not self._show_both_sides
|
|
|
|
def showing_question_and_can_show_answer(self):
|
|
return self._state == "question"
|
|
|
|
|
|
class ReadyCardsAdapter(DecentPreviewer.Adapter):
|
|
def __init__(self, cards):
|
|
self.cards = cards
|
|
self.current = 0
|
|
|
|
def get_current_card(self):
|
|
return self.cards[self.current]
|
|
|
|
def can_select_previous_card(self):
|
|
return self.current > 0
|
|
|
|
def can_select_next_card(self):
|
|
return self.current < len(self.cards) - 1
|
|
|
|
def select_previous_card(self):
|
|
self.current -= 1
|
|
|
|
def select_next_card(self):
|
|
self.current += 1
|
|
|
|
|
|
##############################################################################
|
|
|
|
|
|
# store note ids instead of notes, as note objects don't implement __eq__ etc
|
|
class History:
|
|
number_of_notes_to_keep_in_history = 25
|
|
|
|
def __init__(self):
|
|
self.note_ids = []
|
|
|
|
def append(self, note):
|
|
if note.id in self.note_ids:
|
|
self.note_ids.remove(note.id)
|
|
self.note_ids.append(note.id)
|
|
self.note_ids = self.note_ids[-self.number_of_notes_to_keep_in_history:]
|
|
|
|
def has_note_to_left_of(self, note):
|
|
return note.id in self.note_ids and note.id != self.note_ids[0]
|
|
|
|
def has_note_to_right_of(self, note):
|
|
return note.id in self.note_ids and note.id != self.note_ids[-1]
|
|
|
|
def get_note_to_left_of(self, note):
|
|
note_id = self.note_ids[self.note_ids.index(note.id) - 1]
|
|
return get_note_by_note_id(note_id)
|
|
|
|
def get_note_to_right_of(self, note):
|
|
note_id = self.note_ids[self.note_ids.index(note.id) + 1]
|
|
return get_note_by_note_id(note_id)
|
|
|
|
def get_last_note(self): # throws IndexError if history empty
|
|
return get_note_by_note_id(self.note_ids[-1])
|
|
|
|
def remove_invalid_notes(self):
|
|
self.note_ids = filter_valid_note_ids(self.note_ids)
|
|
|
|
history = History()
|
|
|
|
|
|
# see method `find_cards` of `collection.py`
|
|
def trigger_search_for_dialog_history_notes(search_context, use_history_order):
|
|
search_context.search = " or ".join(
|
|
f"nid:{note_id}" for note_id in history.note_ids
|
|
)
|
|
|
|
if use_history_order:
|
|
search_context.order = f"""case c.nid {
|
|
" ".join(
|
|
f"when {note_id} then {n}"
|
|
for (n, note_id) in enumerate(reversed(history.note_ids))
|
|
)
|
|
} end asc"""
|
|
|
|
|
|
##############################################################################
|
|
|
|
|
|
# noinspection PyAttributeOutsideInit
|
|
class Edit(aqt.editcurrent.EditCurrent):
|
|
dialog_geometry_tag = DOMAIN_PREFIX + "edit"
|
|
dialog_registry_tag = DOMAIN_PREFIX + "Edit"
|
|
dialog_search_tag = DOMAIN_PREFIX + "edit.history"
|
|
|
|
# depending on whether the dialog already exists,
|
|
# upon a request to open the dialog via `aqt.dialogs.open()`,
|
|
# the manager will call either the constructor or the `reopen` method
|
|
def __init__(self, note):
|
|
QMainWindow.__init__(self, None, Qt.WindowType.Window)
|
|
self.form = aqt.forms.editcurrent.Ui_Dialog()
|
|
self.form.setupUi(self)
|
|
self.setWindowTitle("Edit")
|
|
self.setMinimumWidth(250)
|
|
self.setMinimumHeight(400)
|
|
restoreGeom(self, self.dialog_geometry_tag)
|
|
|
|
self.form.buttonBox.setVisible(False) # hides the Close button bar
|
|
self.setup_editor_buttons()
|
|
|
|
self.show()
|
|
self.bring_to_foreground()
|
|
|
|
history.remove_invalid_notes()
|
|
history.append(note)
|
|
self.show_note(note)
|
|
|
|
gui_hooks.operation_did_execute.append(self.on_operation_did_execute)
|
|
gui_hooks.editor_did_load_note.append(self.editor_did_load_note)
|
|
|
|
def reopen(self, note):
|
|
history.append(note)
|
|
self.show_note(note)
|
|
self.bring_to_foreground()
|
|
|
|
def cleanup(self):
|
|
gui_hooks.editor_did_load_note.remove(self.editor_did_load_note)
|
|
gui_hooks.operation_did_execute.remove(self.on_operation_did_execute)
|
|
|
|
self.editor.cleanup()
|
|
saveGeom(self, self.dialog_geometry_tag)
|
|
aqt.dialogs.markClosed(self.dialog_registry_tag)
|
|
|
|
def closeEvent(self, evt: QCloseEvent) -> None:
|
|
self.editor.call_after_note_saved(self.cleanup)
|
|
|
|
# This method (mostly) solves (at least on my Windows 10 machine) three issues
|
|
# with window activation. Without this not even too hacky a fix,
|
|
# * When dialog is opened from Yomichan *for the first time* since app start,
|
|
# the dialog opens in background (just like Browser does),
|
|
# but does not flash in taskbar (unlike Browser);
|
|
# * When dialog is opened, closed, *then main window is focused by clicking in it*,
|
|
# then dialog is opened from Yomichan again, same issue as above arises;
|
|
# * When dialog is restored from minimized state *and main window isn't minimized*,
|
|
# opening the dialog from Yomichan does not reliably focus it;
|
|
# sometimes it opens in foreground, sometimes in background.
|
|
# With this fix, windows nearly always appear in foreground in all three cases.
|
|
# In the case of the first two issues, strictly speaking, the fix is not ideal:
|
|
# the window appears in background first, and then quickly pops into foreground.
|
|
# It is not *too* unsightly, probably, no-one will notice this;
|
|
# still, a better solution must be possible. TODO find one!
|
|
#
|
|
# Note that operation systems, notably Windows, and desktop managers, may restrict
|
|
# background applications from raising windows to prevent them from interrupting
|
|
# what the user is currently doing. For details, see:
|
|
# https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-setforegroundwindow#remarks
|
|
# https://doc.qt.io/qt-5/qwidget.html#activateWindow
|
|
# https://wiki.qt.io/Technical_FAQ#QWidget_::activateWindow.28.29_-_behavior_under_windows
|
|
def bring_to_foreground(self):
|
|
aqt.mw.app.processEvents()
|
|
self.activateWindow()
|
|
self.raise_()
|
|
|
|
#################################### hooks enabled during dialog lifecycle
|
|
|
|
def on_operation_did_execute(self, changes, handler):
|
|
if changes.note_text and handler is not self.editor:
|
|
self.reload_notes_after_user_action_elsewhere()
|
|
|
|
def editor_did_load_note(self, _editor):
|
|
self.enable_disable_next_and_previous_buttons()
|
|
|
|
###################################################### load & reload notes
|
|
|
|
# setting editor.card is required for the "Cards…" button to work properly
|
|
def show_note(self, note):
|
|
self.note = note
|
|
cards = note.cards()
|
|
|
|
self.editor.set_note(note)
|
|
self.editor.card = cards[0] if cards else None
|
|
|
|
if any(is_card_suspended(card) for card in cards):
|
|
tooltip("Some of the cards associated with this note "
|
|
"have been suspended", parent=self)
|
|
|
|
def reload_notes_after_user_action_elsewhere(self):
|
|
history.remove_invalid_notes()
|
|
|
|
try:
|
|
self.note.load() # this also updates the fields
|
|
except NotFoundError:
|
|
try:
|
|
self.note = history.get_last_note()
|
|
except IndexError:
|
|
self.cleanup()
|
|
return
|
|
|
|
self.show_note(self.note)
|
|
|
|
################################################################## actions
|
|
|
|
# search two times, one is to select the current note or its cards,
|
|
# and another to show the whole history, while keeping the above selection
|
|
# set sort column to our search tag, which:
|
|
# * prevents the column sort indicator from being shown
|
|
# * serves as a hint for us to show notes or cards in history order
|
|
# (user can then click on any of the column names
|
|
# to show history cards in the order of their choosing)
|
|
def show_browser(self, *_):
|
|
def search_input_select_all(hook_browser, *_):
|
|
hook_browser.form.searchEdit.lineEdit().selectAll()
|
|
gui_hooks.browser_did_change_row.remove(search_input_select_all)
|
|
gui_hooks.browser_did_change_row.append(search_input_select_all)
|
|
|
|
browser = aqt.dialogs.open("Browser", aqt.mw)
|
|
browser.table._state.sort_column = self.dialog_search_tag # noqa
|
|
browser.table._set_sort_indicator() # noqa
|
|
|
|
browser.search_for(f"nid:{self.note.id}")
|
|
browser.table.select_all()
|
|
browser.search_for(self.dialog_search_tag)
|
|
|
|
def show_preview(self, *_):
|
|
if cards := self.note.cards():
|
|
previewer = DecentPreviewer(ReadyCardsAdapter(cards))
|
|
previewer.open()
|
|
return previewer
|
|
else:
|
|
tooltip("No cards found", parent=self)
|
|
return None
|
|
|
|
def show_previous(self, *_):
|
|
if history.has_note_to_left_of(self.note):
|
|
self.show_note(history.get_note_to_left_of(self.note))
|
|
|
|
def show_next(self, *_):
|
|
if history.has_note_to_right_of(self.note):
|
|
self.show_note(history.get_note_to_right_of(self.note))
|
|
|
|
################################################## button and hotkey setup
|
|
|
|
def setup_editor_buttons(self):
|
|
gui_hooks.editor_did_init.append(self.add_preview_button)
|
|
gui_hooks.editor_did_init_buttons.append(self.add_right_hand_side_buttons)
|
|
|
|
# on Anki 2.1.50, browser mode makes the Preview button visible
|
|
extra_kwargs = {} if anki_version < (2, 1, 50) else {
|
|
"editor_mode": aqt.editor.EditorMode.BROWSER
|
|
}
|
|
|
|
self.editor = aqt.editor.Editor(aqt.mw, self.form.fieldsArea, self,
|
|
**extra_kwargs)
|
|
|
|
gui_hooks.editor_did_init_buttons.remove(self.add_right_hand_side_buttons)
|
|
gui_hooks.editor_did_init.remove(self.add_preview_button)
|
|
|
|
# * on Anki < 2.1.50, make the button via js (`setupEditor` of browser.py);
|
|
# also, make a copy of _links so that opening Anki's browser does not
|
|
# screw them up as they are apparently shared between instances?!
|
|
# the last part seems to have been fixed in Anki 2.1.50
|
|
# * on Anki 2.1.50, the button is created by setting editor mode,
|
|
# see above; so we only need to add the link.
|
|
def add_preview_button(self, editor):
|
|
QShortcut(QKeySequence("Ctrl+Shift+P"), self, self.show_preview)
|
|
|
|
if anki_version < (2, 1, 50):
|
|
editor._links = editor._links.copy()
|
|
editor.web.eval("""
|
|
$editorToolbar.then(({notetypeButtons}) =>
|
|
notetypeButtons.appendButton(
|
|
{component: editorToolbar.PreviewButton, id: 'preview'}
|
|
)
|
|
);
|
|
""")
|
|
|
|
editor._links["preview"] = lambda _editor: self.show_preview() and None
|
|
|
|
# * on Anki < 2.1.50, button style is okay-ish from get-go,
|
|
# except when disabled; adding class `btn` fixes that;
|
|
# * on Anki 2.1.50, buttons have weird font size and are square';
|
|
# the style below makes them in line with left-hand side buttons
|
|
def add_right_hand_side_buttons(self, buttons, editor):
|
|
if anki_version < (2, 1, 50):
|
|
extra_button_class = "btn"
|
|
else:
|
|
extra_button_class = "anki-connect-button"
|
|
editor.web.eval("""
|
|
(function(){
|
|
const style = document.createElement("style");
|
|
style.innerHTML = `
|
|
.anki-connect-button {
|
|
white-space: nowrap;
|
|
width: auto;
|
|
padding: 0 2px;
|
|
font-size: var(--base-font-size);
|
|
}
|
|
.anki-connect-button:disabled {
|
|
pointer-events: none;
|
|
opacity: .4;
|
|
}
|
|
`;
|
|
document.head.appendChild(style);
|
|
})();
|
|
""")
|
|
|
|
def add(cmd, function, label, tip, keys):
|
|
button_html = editor.addButton(
|
|
icon=None,
|
|
cmd=DOMAIN_PREFIX + cmd,
|
|
id=DOMAIN_PREFIX + cmd,
|
|
func=function,
|
|
label=f" {label} ",
|
|
tip=f"{tip} ({keys})",
|
|
keys=keys,
|
|
)
|
|
|
|
button_html = button_html.replace('class="',
|
|
f'class="{extra_button_class} ')
|
|
buttons.append(button_html)
|
|
|
|
add("browse", self.show_browser, "Browse", "Browse", "Ctrl+F")
|
|
add("previous", self.show_previous, "<", "Previous", "Alt+Left")
|
|
add("next", self.show_next, ">", "Next", "Alt+Right")
|
|
|
|
def run_javascript_after_toolbar_ready(self, js):
|
|
js = f"setTimeout(function() {{ {js} }}, 1)"
|
|
if anki_version < (2, 1, 50):
|
|
js = f'$editorToolbar.then(({{ toolbar }}) => {js})'
|
|
else:
|
|
js = f'require("anki/ui").loaded.then(() => {js})'
|
|
self.editor.web.eval(js)
|
|
|
|
def enable_disable_next_and_previous_buttons(self):
|
|
def to_js(boolean):
|
|
return "true" if boolean else "false"
|
|
|
|
disable_previous = not(history.has_note_to_left_of(self.note))
|
|
disable_next = not(history.has_note_to_right_of(self.note))
|
|
|
|
self.run_javascript_after_toolbar_ready(f"""
|
|
document.getElementById("{DOMAIN_PREFIX}previous")
|
|
.disabled = {to_js(disable_previous)};
|
|
document.getElementById("{DOMAIN_PREFIX}next")
|
|
.disabled = {to_js(disable_next)};
|
|
""")
|
|
|
|
##########################################################################
|
|
|
|
@classmethod
|
|
def browser_will_search(cls, search_context):
|
|
if search_context.search == cls.dialog_search_tag:
|
|
trigger_search_for_dialog_history_notes(
|
|
search_context=search_context,
|
|
use_history_order=cls.dialog_search_tag ==
|
|
search_context.browser.table._state.sort_column # noqa
|
|
)
|
|
|
|
@classmethod
|
|
def register_with_anki(cls):
|
|
if cls.dialog_registry_tag not in aqt.dialogs._dialogs: # noqa
|
|
aqt.dialogs.register_dialog(cls.dialog_registry_tag, cls)
|
|
gui_hooks.browser_will_search.append(cls.browser_will_search)
|
|
|
|
@classmethod
|
|
def open_dialog_and_show_note_with_id(cls, note_id): # raises NotFoundError
|
|
note = get_note_by_note_id(note_id)
|
|
return aqt.dialogs.open(cls.dialog_registry_tag, note)
|