diff options
Diffstat (limited to '.local/share/Anki2/addons21/Anki_connect/edit.py')
-rw-r--r-- | .local/share/Anki2/addons21/Anki_connect/edit.py | 458 |
1 files changed, 0 insertions, 458 deletions
diff --git a/.local/share/Anki2/addons21/Anki_connect/edit.py b/.local/share/Anki2/addons21/Anki_connect/edit.py deleted file mode 100644 index d414575..0000000 --- a/.local/share/Anki2/addons21/Anki_connect/edit.py +++ /dev/null @@ -1,458 +0,0 @@ -import aqt -import aqt.editor -import aqt.browser.previewer -from aqt import gui_hooks -from aqt.qt import QDialog, Qt, QKeySequence, QShortcut -from aqt.utils import disable_help_button, 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): - QDialog.__init__(self, None, Qt.WindowType.Window) - aqt.mw.garbage_collect_on_dialog_finish(self) - 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) - disable_help_button(self) - - 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_and_close(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) - QDialog.reject(self) - - # 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_and_close() - 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) |