From 7dac2fc4ff3147088022a6ef97bc5f52a2f7ae0a Mon Sep 17 00:00:00 2001 From: Abdo Date: Mon, 21 Jul 2025 22:30:25 +0300 Subject: [PATCH] Add back legacy code as separate screens --- qt/aqt/__init__.py | 2 + qt/aqt/addcards.py | 7 +- qt/aqt/addcards_legacy.py | 414 ++++++++ qt/aqt/browser/browser.py | 18 +- qt/aqt/editcurrent.py | 7 +- qt/aqt/editcurrent_legacy.py | 94 ++ qt/aqt/editor.py | 70 +- qt/aqt/editor_legacy.py | 1790 ++++++++++++++++++++++++++++++++++ qt/aqt/main.py | 16 +- qt/aqt/mediasrv.py | 14 +- qt/tools/genhooks_gui.py | 50 +- 11 files changed, 2383 insertions(+), 99 deletions(-) create mode 100644 qt/aqt/addcards_legacy.py create mode 100644 qt/aqt/editcurrent_legacy.py create mode 100644 qt/aqt/editor_legacy.py diff --git a/qt/aqt/__init__.py b/qt/aqt/__init__.py index 53bdc3c92..74c4919a7 100644 --- a/qt/aqt/__init__.py +++ b/qt/aqt/__init__.py @@ -125,9 +125,11 @@ from aqt import stats, about, preferences, mediasync # isort:skip class DialogManager: _dialogs: dict[str, list] = { "AddCards": [addcards.AddCards, None], + "NewAddCards": [addcards.NewAddCards, None], "AddonsDialog": [addons.AddonsDialog, None], "Browser": [browser.Browser, None], "EditCurrent": [editcurrent.EditCurrent, None], + "NewEditCurrent": [editcurrent.NewEditCurrent, None], "FilteredDeckConfigDialog": [filtered_deck.FilteredDeckConfigDialog, None], "DeckStats": [stats.DeckStats, None], "NewDeckStats": [stats.NewDeckStats, None], diff --git a/qt/aqt/addcards.py b/qt/aqt/addcards.py index 3b0421b30..8ea8b808e 100644 --- a/qt/aqt/addcards.py +++ b/qt/aqt/addcards.py @@ -14,6 +14,7 @@ from anki.models import NotetypeId from anki.notes import Note, NoteId from anki.utils import html_to_text_line, is_mac from aqt import AnkiQt, gui_hooks +from aqt.addcards_legacy import * from aqt.deckchooser import DeckChooser from aqt.notetypechooser import NotetypeChooser from aqt.qt import * @@ -30,7 +31,7 @@ from aqt.utils import ( ) -class AddCards(QMainWindow): +class NewAddCards(QMainWindow): def __init__(self, mw: AnkiQt) -> None: super().__init__(None, Qt.WindowType.Window) self._close_event_has_cleaned_up = False @@ -79,7 +80,7 @@ class AddCards(QMainWindow): self.setAndFocusNote(new_note) def setupEditor(self) -> None: - self.editor = aqt.editor.Editor( + self.editor = aqt.editor.NewEditor( self.mw, self.form.fieldsArea, self, @@ -244,7 +245,7 @@ class AddCards(QMainWindow): gui_hooks.operation_did_execute.remove(self.on_operation_did_execute) self.mw.maybeReset() saveGeom(self, "add") - aqt.dialogs.markClosed("AddCards") + aqt.dialogs.markClosed("NewAddCards") self._close_event_has_cleaned_up = True self.mw.deferred_delete_and_garbage_collect(self) self.close() diff --git a/qt/aqt/addcards_legacy.py b/qt/aqt/addcards_legacy.py new file mode 100644 index 000000000..86e8a25b1 --- /dev/null +++ b/qt/aqt/addcards_legacy.py @@ -0,0 +1,414 @@ +# Copyright: Ankitects Pty Ltd and contributors +# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +from __future__ import annotations + +from collections.abc import Callable + +import aqt.editor +import aqt.forms +from anki._legacy import deprecated +from anki.collection import OpChanges, SearchNode +from anki.decks import DeckId +from anki.models import NotetypeId +from anki.notes import Note, NoteFieldsCheckResult, NoteId +from anki.utils import html_to_text_line, is_mac +from aqt import AnkiQt, gui_hooks +from aqt.deckchooser import DeckChooser +from aqt.notetypechooser import NotetypeChooser +from aqt.operations.note import add_note +from aqt.qt import * +from aqt.sound import av_player +from aqt.utils import ( + HelpPage, + add_close_shortcut, + ask_user_dialog, + askUser, + downArrow, + openHelp, + restoreGeom, + saveGeom, + shortcut, + showWarning, + tooltip, + tr, +) + + +class AddCards(QMainWindow): + def __init__(self, mw: AnkiQt) -> None: + super().__init__(None, Qt.WindowType.Window) + self._close_event_has_cleaned_up = False + self.mw = mw + self.col = mw.col + form = aqt.forms.addcards.Ui_Dialog() + form.setupUi(self) + self.form = form + self.setWindowTitle(tr.actions_add()) + self.setMinimumHeight(300) + self.setMinimumWidth(400) + self.setup_choosers() + self.setupEditor() + add_close_shortcut(self) + self._load_new_note() + self.setupButtons() + self.history: list[NoteId] = [] + self._last_added_note: Note | None = None + gui_hooks.operation_did_execute.append(self.on_operation_did_execute) + restoreGeom(self, "add") + gui_hooks.add_cards_did_init(self) + if not is_mac: + self.setMenuBar(None) + self.show() + + def set_deck(self, deck_id: DeckId) -> None: + self.deck_chooser.selected_deck_id = deck_id + + def set_note_type(self, note_type_id: NotetypeId) -> None: + self.notetype_chooser.selected_notetype_id = note_type_id + + def set_note(self, note: Note, deck_id: DeckId | None = None) -> None: + """Set tags, field contents and notetype according to `note`. Deck is set + to `deck_id` or the deck last used with the notetype. + """ + self.notetype_chooser.selected_notetype_id = note.mid + if deck_id or (deck_id := self.col.default_deck_for_notetype(note.mid)): + self.deck_chooser.selected_deck_id = deck_id + + new_note = self._new_note() + new_note.fields = note.fields[:] + new_note.tags = note.tags[:] + + self.editor.orig_note_id = note.id + self.setAndFocusNote(new_note) + + def setupEditor(self) -> None: + self.editor = aqt.editor.Editor( + self.mw, + self.form.fieldsArea, + self, + editor_mode=aqt.editor.EditorMode.ADD_CARDS, + ) + + def setup_choosers(self) -> None: + defaults = self.col.defaults_for_adding( + current_review_card=self.mw.reviewer.card + ) + + self.notetype_chooser = NotetypeChooser( + mw=self.mw, + widget=self.form.modelArea, + starting_notetype_id=NotetypeId(defaults.notetype_id), + on_button_activated=self.show_notetype_selector, + on_notetype_changed=self.on_notetype_change, + ) + self.deck_chooser = DeckChooser( + self.mw, + self.form.deckArea, + starting_deck_id=DeckId(defaults.deck_id), + on_deck_changed=self.on_deck_changed, + ) + + def reopen(self, mw: AnkiQt) -> None: + if not self.editor.fieldsAreBlank(): + return + + defaults = self.col.defaults_for_adding( + current_review_card=self.mw.reviewer.card + ) + self.set_note_type(NotetypeId(defaults.notetype_id)) + self.set_deck(DeckId(defaults.deck_id)) + + def helpRequested(self) -> None: + openHelp(HelpPage.ADDING_CARD_AND_NOTE) + + def setupButtons(self) -> None: + bb = self.form.buttonBox + ar = QDialogButtonBox.ButtonRole.ActionRole + # add + self.addButton = bb.addButton(tr.actions_add(), ar) + qconnect(self.addButton.clicked, self.add_current_note) + self.addButton.setShortcut(QKeySequence("Ctrl+Return")) + # qt5.14+ doesn't handle numpad enter on Windows + self.compat_add_shorcut = QShortcut(QKeySequence("Ctrl+Enter"), self) + qconnect(self.compat_add_shorcut.activated, self.addButton.click) + self.addButton.setToolTip(shortcut(tr.adding_add_shortcut_ctrlandenter())) + + # close + self.closeButton = QPushButton(tr.actions_close()) + self.closeButton.setAutoDefault(False) + bb.addButton(self.closeButton, QDialogButtonBox.ButtonRole.RejectRole) + qconnect(self.closeButton.clicked, self.close) + # help + self.helpButton = QPushButton(tr.actions_help(), clicked=self.helpRequested) # type: ignore + self.helpButton.setAutoDefault(False) + bb.addButton(self.helpButton, QDialogButtonBox.ButtonRole.HelpRole) + # history + b = bb.addButton(f"{tr.adding_history()} {downArrow()}", ar) + if is_mac: + sc = "Ctrl+Shift+H" + else: + sc = "Ctrl+H" + b.setShortcut(QKeySequence(sc)) + b.setToolTip(tr.adding_shortcut(val=shortcut(sc))) + qconnect(b.clicked, self.onHistory) + b.setEnabled(False) + self.historyButton = b + + def setAndFocusNote(self, note: Note) -> None: + self.editor.set_note(note, focusTo=0) + + def show_notetype_selector(self) -> None: + self.editor.call_after_note_saved(self.notetype_chooser.choose_notetype) + + def on_deck_changed(self, deck_id: int) -> None: + gui_hooks.add_cards_did_change_deck(deck_id) + + def on_notetype_change( + self, notetype_id: NotetypeId, update_deck: bool = True + ) -> None: + # need to adjust current deck? + if update_deck: + if deck_id := self.col.default_deck_for_notetype(notetype_id): + self.deck_chooser.selected_deck_id = deck_id + + # only used for detecting changed sticky fields on close + self._last_added_note = None + + # copy fields into new note with the new notetype + old_note = self.editor.note + new_note = self._new_note() + if old_note: + old_field_names = list(old_note.keys()) + new_field_names = list(new_note.keys()) + copied_field_names = set() + for f in new_note.note_type()["flds"]: + field_name = f["name"] + # copy identical non-empty fields + if field_name in old_field_names and old_note[field_name]: + new_note[field_name] = old_note[field_name] + copied_field_names.add(field_name) + new_idx = 0 + for old_idx, old_field_value in enumerate(old_field_names): + # skip previously copied identical fields in new note + while ( + new_idx < len(new_field_names) + and new_field_names[new_idx] in copied_field_names + ): + new_idx += 1 + if new_idx >= len(new_field_names): + break + # copy non-empty old fields + if ( + old_field_value not in copied_field_names + and old_note.fields[old_idx] + ): + new_note.fields[new_idx] = old_note.fields[old_idx] + new_idx += 1 + + new_note.tags = old_note.tags + + # and update editor state + self.editor.note = new_note + self.editor.loadNote( + focusTo=min(self.editor.last_field_index or 0, len(new_note.fields) - 1) + ) + gui_hooks.addcards_did_change_note_type( + self, old_note.note_type(), new_note.note_type() + ) + + def _load_new_note(self, sticky_fields_from: Note | None = None) -> None: + note = self._new_note() + if old_note := sticky_fields_from: + flds = note.note_type()["flds"] + # copy fields from old note + if old_note: + for n in range(min(len(note.fields), len(old_note.fields))): + if flds[n]["sticky"]: + note.fields[n] = old_note.fields[n] + # and tags + note.tags = old_note.tags + self.setAndFocusNote(note) + + def on_operation_did_execute( + self, changes: OpChanges, handler: object | None + ) -> None: + if (changes.notetype or changes.deck) and handler is not self.editor: + self.on_notetype_change( + NotetypeId( + self.col.defaults_for_adding( + current_review_card=self.mw.reviewer.card + ).notetype_id + ), + update_deck=False, + ) + + def _new_note(self) -> Note: + return self.col.new_note( + self.col.models.get(self.notetype_chooser.selected_notetype_id) + ) + + def addHistory(self, note: Note) -> None: + self.history.insert(0, note.id) + self.history = self.history[:15] + self.historyButton.setEnabled(True) + + def onHistory(self) -> None: + m = QMenu(self) + for nid in self.history: + if self.col.find_notes(self.col.build_search_string(SearchNode(nid=nid))): + note = self.col.get_note(nid) + fields = note.fields + txt = html_to_text_line(", ".join(fields)) + if len(txt) > 30: + txt = f"{txt[:30]}..." + line = tr.adding_edit(val=txt) + line = gui_hooks.addcards_will_add_history_entry(line, note) + line = line.replace("&", "&&") + # In qt action "&i" means "underline i, trigger this line when i is pressed". + # except for "&&" which is replaced by a single "&" + a = m.addAction(line) + qconnect(a.triggered, lambda b, nid=nid: self.editHistory(nid)) + else: + a = m.addAction(tr.adding_note_deleted()) + a.setEnabled(False) + gui_hooks.add_cards_will_show_history_menu(self, m) + m.exec(self.historyButton.mapToGlobal(QPoint(0, 0))) + + def editHistory(self, nid: NoteId) -> None: + aqt.dialogs.open("Browser", self.mw, search=(SearchNode(nid=nid),)) + + def add_current_note(self) -> None: + if self.editor.current_notetype_is_image_occlusion(): + self.editor.update_occlusions_field() + self.editor.call_after_note_saved(self._add_current_note) + self.editor.reset_image_occlusion() + else: + self.editor.call_after_note_saved(self._add_current_note) + + def _add_current_note(self) -> None: + note = self.editor.note + + if not self._note_can_be_added(note): + return + + target_deck_id = self.deck_chooser.selected_deck_id + + def on_success(changes: OpChanges) -> None: + # only used for detecting changed sticky fields on close + self._last_added_note = note + + self.addHistory(note) + + tooltip(tr.adding_added(), period=500) + av_player.stop_and_clear_queue() + self._load_new_note(sticky_fields_from=note) + gui_hooks.add_cards_did_add_note(note) + + add_note(parent=self, note=note, target_deck_id=target_deck_id).success( + on_success + ).run_in_background() + + def _note_can_be_added(self, note: Note) -> bool: + result = note.fields_check() + # no problem, duplicate, and confirmed cloze cases + problem = None + if result == NoteFieldsCheckResult.EMPTY: + if self.editor.current_notetype_is_image_occlusion(): + problem = tr.notetypes_no_occlusion_created2() + else: + problem = tr.adding_the_first_field_is_empty() + elif result == NoteFieldsCheckResult.MISSING_CLOZE: + if not askUser(tr.adding_you_have_a_cloze_deletion_note()): + return False + elif result == NoteFieldsCheckResult.NOTETYPE_NOT_CLOZE: + problem = tr.adding_cloze_outside_cloze_notetype() + elif result == NoteFieldsCheckResult.FIELD_NOT_CLOZE: + problem = tr.adding_cloze_outside_cloze_field() + + # filter problem through add-ons + problem = gui_hooks.add_cards_will_add_note(problem, note) + if problem is not None: + showWarning(problem, help=HelpPage.ADDING_CARD_AND_NOTE) + return False + + optional_problems: list[str] = [] + gui_hooks.add_cards_might_add_note(optional_problems, note) + if not all(askUser(op) for op in optional_problems): + return False + + return True + + def keyPressEvent(self, evt: QKeyEvent) -> None: + if evt.key() == Qt.Key.Key_Escape: + self.close() + else: + super().keyPressEvent(evt) + + def closeEvent(self, evt: QCloseEvent) -> None: + if self._close_event_has_cleaned_up: + evt.accept() + return + self.ifCanClose(self._close) + evt.ignore() + + def _close(self) -> None: + self.editor.cleanup() + self.notetype_chooser.cleanup() + self.deck_chooser.cleanup() + gui_hooks.operation_did_execute.remove(self.on_operation_did_execute) + self.mw.maybeReset() + saveGeom(self, "add") + aqt.dialogs.markClosed("AddCards") + self._close_event_has_cleaned_up = True + self.mw.deferred_delete_and_garbage_collect(self) + self.close() + + def ifCanClose(self, onOk: Callable) -> None: + def callback(choice: int) -> None: + if choice == 0: + onOk() + + def afterSave() -> None: + if self.editor.fieldsAreBlank(self._last_added_note): + return onOk() + + ask_user_dialog( + tr.adding_discard_current_input(), + callback=callback, + buttons=[ + QMessageBox.StandardButton.Discard, + (tr.adding_keep_editing(), QMessageBox.ButtonRole.RejectRole), + ], + ) + + self.editor.call_after_note_saved(afterSave) + + def closeWithCallback(self, cb: Callable[[], None]) -> None: + def doClose() -> None: + self._close() + cb() + + self.ifCanClose(doClose) + + # legacy aliases + + @property + def deckChooser(self) -> DeckChooser: + if getattr(self, "form", None): + # show this warning only after Qt form has been initialized, + # or PyQt's introspection triggers it + print("deckChooser is deprecated; use deck_chooser instead") + return self.deck_chooser + + addCards = add_current_note + _addCards = _add_current_note + onModelChange = on_notetype_change + + @deprecated(info="obsolete") + def addNote(self, note: Note) -> None: + pass + + @deprecated(info="does nothing; will go away") + def removeTempNote(self, note: Note) -> None: + pass diff --git a/qt/aqt/browser/browser.py b/qt/aqt/browser/browser.py index 6e7af72cd..6be4d33de 100644 --- a/qt/aqt/browser/browser.py +++ b/qt/aqt/browser/browser.py @@ -27,7 +27,6 @@ from anki.scheduler.base import ScheduleCardsAsNew from anki.tags import MARKED_TAG from anki.utils import is_mac from aqt import AnkiQt, gui_hooks -from aqt.editor import Editor, EditorWebView from aqt.errors import show_exception from aqt.exporting import ExportDialog as LegacyExportDialog from aqt.import_export.exporting import ExportDialog @@ -77,7 +76,7 @@ from aqt.utils import ( tr, ) -from ..addcards import AddCards +from ..addcards import NewAddCards as AddCards from ..changenotetype import change_notetype_dialog from .card_info import BrowserCardInfo from .find_and_replace import FindAndReplaceDialog @@ -111,7 +110,7 @@ class MockModel: class Browser(QMainWindow): mw: AnkiQt col: Collection - editor: Editor | None + editor: aqt.editor.NewEditor | None table: Table def __init__( @@ -267,7 +266,7 @@ class Browser(QMainWindow): return None def add_card(self, deck_id: DeckId): - add_cards = cast(AddCards, aqt.dialogs.open("AddCards", self.mw)) + add_cards = cast(AddCards, aqt.dialogs.open("NewAddCards", self.mw)) add_cards.set_deck(deck_id) if note_type_id := self.get_active_note_type_id(): @@ -392,7 +391,7 @@ class Browser(QMainWindow): add_ellipsis_to_action_label(f.action_forget) add_ellipsis_to_action_label(f.action_grade_now) - def _editor_web_view(self) -> EditorWebView: + def _editor_web_view(self) -> aqt.editor.NewEditorWebView: assert self.editor is not None editor_web_view = self.editor.web assert editor_web_view is not None @@ -592,12 +591,14 @@ class Browser(QMainWindow): def setupEditor(self) -> None: QShortcut(QKeySequence("Ctrl+Shift+P"), self, self.onTogglePreview) - def add_preview_button(editor: Editor) -> None: + def add_preview_button( + editor: aqt.editor.Editor | aqt.editor.NewEditor, + ) -> None: editor._links["preview"] = lambda _editor: self.onTogglePreview() gui_hooks.editor_did_init.remove(add_preview_button) gui_hooks.editor_did_init.append(add_preview_button) - self.editor = aqt.editor.Editor( + self.editor = aqt.editor.NewEditor( self.mw, self.form.fieldsArea, self, @@ -806,7 +807,7 @@ class Browser(QMainWindow): assert current_card is not None deck_id = current_card.current_deck_id() - aqt.dialogs.open("AddCards", self.mw).set_note(note, deck_id) + aqt.dialogs.open("NewAddCards", self.mw).set_note(note, deck_id) @no_arg_trigger @skip_if_selection_is_empty @@ -1264,3 +1265,4 @@ class Browser(QMainWindow): line_edit = self.form.searchEdit.lineEdit() assert line_edit is not None return line_edit + return line_edit diff --git a/qt/aqt/editcurrent.py b/qt/aqt/editcurrent.py index 4cd8dcb0c..1b42c5d83 100644 --- a/qt/aqt/editcurrent.py +++ b/qt/aqt/editcurrent.py @@ -7,11 +7,12 @@ from collections.abc import Callable import aqt.editor from anki.collection import OpChanges from aqt import gui_hooks +from aqt.editcurrent_legacy import * from aqt.qt import * from aqt.utils import add_close_shortcut, restoreGeom, saveGeom, tr -class EditCurrent(QMainWindow): +class NewEditCurrent(QMainWindow): def __init__(self, mw: aqt.AnkiQt) -> None: super().__init__(None, Qt.WindowType.Window) self.mw = mw @@ -22,7 +23,7 @@ class EditCurrent(QMainWindow): self.setMinimumWidth(250) if not is_mac: self.setMenuBar(None) - self.editor = aqt.editor.Editor( + self.editor = aqt.editor.NewEditor( self.mw, self.form.fieldsArea, self, @@ -46,7 +47,7 @@ class EditCurrent(QMainWindow): gui_hooks.operation_did_execute.remove(self.on_operation_did_execute) self.editor.cleanup() saveGeom(self, "editcurrent") - aqt.dialogs.markClosed("EditCurrent") + aqt.dialogs.markClosed("NewEditCurrent") def reopen(self, mw: aqt.AnkiQt) -> None: if card := self.mw.reviewer.card: diff --git a/qt/aqt/editcurrent_legacy.py b/qt/aqt/editcurrent_legacy.py new file mode 100644 index 000000000..d4e969c21 --- /dev/null +++ b/qt/aqt/editcurrent_legacy.py @@ -0,0 +1,94 @@ +# Copyright: Ankitects Pty Ltd and contributors +# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html +from __future__ import annotations + +from collections.abc import Callable + +import aqt.editor +from anki.collection import OpChanges +from anki.errors import NotFoundError +from aqt import gui_hooks +from aqt.qt import * +from aqt.utils import add_close_shortcut, restoreGeom, saveGeom, tr + + +class EditCurrent(QMainWindow): + def __init__(self, mw: aqt.AnkiQt) -> None: + super().__init__(None, Qt.WindowType.Window) + self.mw = mw + self.form = aqt.forms.editcurrent.Ui_Dialog() + self.form.setupUi(self) + self.setWindowTitle(tr.editing_edit_current()) + self.setMinimumHeight(400) + self.setMinimumWidth(250) + if not is_mac: + self.setMenuBar(None) + self.editor = aqt.editor.Editor( + self.mw, + self.form.fieldsArea, + self, + editor_mode=aqt.editor.EditorMode.EDIT_CURRENT, + ) + assert self.mw.reviewer.card is not None + self.editor.card = self.mw.reviewer.card + self.editor.set_note(self.mw.reviewer.card.note(), focusTo=0) + restoreGeom(self, "editcurrent") + self.buttonbox = QDialogButtonBox(Qt.Orientation.Horizontal) + self.form.verticalLayout.insertWidget(1, self.buttonbox) + self.buttonbox.addButton(QDialogButtonBox.StandardButton.Close) + qconnect(self.buttonbox.rejected, self.close) + close_button = self.buttonbox.button(QDialogButtonBox.StandardButton.Close) + assert close_button is not None + close_button.setShortcut(QKeySequence("Ctrl+Return")) + add_close_shortcut(self) + # qt5.14+ doesn't handle numpad enter on Windows + self.compat_add_shorcut = QShortcut(QKeySequence("Ctrl+Enter"), self) + qconnect(self.compat_add_shorcut.activated, close_button.click) + gui_hooks.operation_did_execute.append(self.on_operation_did_execute) + self.show() + + def on_operation_did_execute( + self, changes: OpChanges, handler: object | None + ) -> None: + if changes.note_text and handler is not self.editor: + # reload note + note = self.editor.note + try: + assert note is not None + note.load() + except NotFoundError: + # note's been deleted + self.cleanup() + self.close() + return + + self.editor.set_note(note) + + def cleanup(self) -> None: + gui_hooks.operation_did_execute.remove(self.on_operation_did_execute) + self.editor.cleanup() + saveGeom(self, "editcurrent") + aqt.dialogs.markClosed("EditCurrent") + + def reopen(self, mw: aqt.AnkiQt) -> None: + if card := self.mw.reviewer.card: + self.editor.card = card + self.editor.set_note(card.note()) + + def closeEvent(self, evt: QCloseEvent | None) -> None: + self.editor.call_after_note_saved(self.cleanup) + + def _saveAndClose(self) -> None: + self.cleanup() + self.mw.deferred_delete_and_garbage_collect(self) + self.close() + + def closeWithCallback(self, onsuccess: Callable[[], None]) -> None: + def callback() -> None: + self._saveAndClose() + onsuccess() + + self.editor.call_after_note_saved(callback) + + onReset = on_operation_did_execute + onReset = on_operation_did_execute diff --git a/qt/aqt/editor.py b/qt/aqt/editor.py index 5610d64ed..394751065 100644 --- a/qt/aqt/editor.py +++ b/qt/aqt/editor.py @@ -10,7 +10,6 @@ import mimetypes import os from collections.abc import Callable from dataclasses import dataclass -from enum import Enum from random import randrange from typing import Any @@ -21,58 +20,16 @@ from anki.models import NotetypeId from anki.notes import Note, NoteId from anki.utils import is_win from aqt import AnkiQt, gui_hooks +from aqt.editor_legacy import * from aqt.qt import * from aqt.sound import av_player from aqt.utils import shortcut, showWarning from aqt.webview import AnkiWebView, AnkiWebViewKind -pics = ("jpg", "jpeg", "png", "gif", "svg", "webp", "ico", "avif") -audio = ( - "3gp", - "aac", - "avi", - "flac", - "flv", - "m4a", - "mkv", - "mov", - "mp3", - "mp4", - "mpeg", - "mpg", - "oga", - "ogg", - "ogv", - "ogx", - "opus", - "spx", - "swf", - "wav", - "webm", -) - - -class EditorMode(Enum): - ADD_CARDS = 0 - EDIT_CURRENT = 1 - BROWSER = 2 - - -class EditorState(Enum): - """ - Current input state of the editing UI. - """ - - INITIAL = -1 - FIELDS = 0 - IO_PICKER = 1 - IO_MASKS = 2 - IO_FIELDS = 3 - def on_editor_ready(func: Callable) -> Callable: @functools.wraps(func) - def decorated(self: Editor, *args: Any, **kwargs: Any) -> None: + def decorated(self: NewEditor, *args: Any, **kwargs: Any) -> None: if self._ready: func(self, *args, **kwargs) else: @@ -96,7 +53,7 @@ class NoteInfo: self.mid = NotetypeId(int(self.mid)) -class Editor: +class NewEditor: """The screen that embeds an editing widget should listen for changes via the `operation_did_execute` hook, and call set_note() when the editor needs redrawing. @@ -152,7 +109,7 @@ class Editor: self.outerLayout = l def add_webview(self) -> None: - self.web = EditorWebView(self.widget, self) + self.web = NewEditorWebView(self.widget, self) self.web.set_bridge_command(self.onBridgeCmd, self) self.web.hide_while_preserving_layout() self.outerLayout.addWidget(self.web, 1) @@ -213,7 +170,7 @@ require("anki/ui").loaded.then(() => require("anki/NoteEditor").instances[0].too self, icon: str | None, cmd: str, - func: Callable[[Editor], None], + func: Callable[[NewEditor], None], tip: str = "", label: str = "", id: str | None = None, @@ -224,7 +181,7 @@ require("anki/ui").loaded.then(() => require("anki/NoteEditor").instances[0].too ) -> str: """Assign func to bridge cmd, register shortcut, return button""" - def wrapped_func(editor: Editor) -> None: + def wrapped_func(editor: NewEditor) -> None: self.call_after_note_saved(functools.partial(func, editor), keepFocus=True) self._links[cmd] = wrapped_func @@ -553,11 +510,11 @@ require("anki/ui").loaded.then(() => require("anki/NoteEditor").instances[0].too def _init_links(self) -> None: self._links: dict[str, Callable] = dict( - fields=Editor.onFields, - cards=Editor.onCardLayout, - paste=Editor.onPaste, - cut=Editor.onCut, - copy=Editor.onCopy, + fields=NewEditor.onFields, + cards=NewEditor.onCardLayout, + paste=NewEditor.onPaste, + cut=NewEditor.onCut, + copy=NewEditor.onCopy, ) def get_note_info(self, on_done: Callable[[NoteInfo], None]) -> None: @@ -571,8 +528,8 @@ require("anki/ui").loaded.then(() => require("anki/NoteEditor").instances[0].too ###################################################################### -class EditorWebView(AnkiWebView): - def __init__(self, parent: QWidget, editor: Editor) -> None: +class NewEditorWebView(AnkiWebView): + def __init__(self, parent: QWidget, editor: NewEditor) -> None: AnkiWebView.__init__(self, kind=AnkiWebViewKind.EDITOR) self.editor = editor self.setAcceptDrops(True) @@ -592,3 +549,4 @@ class EditorWebView(AnkiWebView): def onPaste(self) -> None: self.triggerPageAction(QWebEnginePage.WebAction.Paste) + self.triggerPageAction(QWebEnginePage.WebAction.Paste) diff --git a/qt/aqt/editor_legacy.py b/qt/aqt/editor_legacy.py new file mode 100644 index 000000000..138deed7a --- /dev/null +++ b/qt/aqt/editor_legacy.py @@ -0,0 +1,1790 @@ +# Copyright: Ankitects Pty Ltd and contributors +# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +from __future__ import annotations + +import base64 +import functools +import html +import itertools +import json +import mimetypes +import os +import re +import urllib.error +import urllib.parse +import urllib.request +import warnings +from collections.abc import Callable +from enum import Enum +from random import randrange +from typing import Any, Iterable, Match, cast + +import bs4 +import requests +from bs4 import BeautifulSoup + +import aqt +import aqt.forms +import aqt.operations +import aqt.sound +from anki._legacy import deprecated +from anki.cards import Card +from anki.collection import Config, SearchNode +from anki.consts import MODEL_CLOZE +from anki.hooks import runFilter +from anki.httpclient import HttpClient +from anki.models import NotetypeDict, NotetypeId, StockNotetype +from anki.notes import Note, NoteFieldsCheckResult, NoteId +from anki.utils import checksum, is_lin, is_win, namedtmp +from aqt import AnkiQt, colors, gui_hooks +from aqt.operations import QueryOp +from aqt.operations.note import update_note +from aqt.operations.notetype import update_notetype_legacy +from aqt.qt import * +from aqt.sound import av_player +from aqt.theme import theme_manager +from aqt.utils import ( + HelpPage, + KeyboardModifiersPressed, + disable_help_button, + getFile, + openFolder, + openHelp, + qtMenuShortcutWorkaround, + restoreGeom, + saveGeom, + shortcut, + show_in_folder, + showInfo, + showWarning, + tooltip, + tr, +) +from aqt.webview import AnkiWebView, AnkiWebViewKind + +pics = ("jpg", "jpeg", "png", "gif", "svg", "webp", "ico", "avif") +audio = ( + "3gp", + "aac", + "avi", + "flac", + "flv", + "m4a", + "mkv", + "mov", + "mp3", + "mp4", + "mpeg", + "mpg", + "oga", + "ogg", + "ogv", + "ogx", + "opus", + "spx", + "swf", + "wav", + "webm", +) + + +class EditorMode(Enum): + ADD_CARDS = 0 + EDIT_CURRENT = 1 + BROWSER = 2 + + +class EditorState(Enum): + """ + Current input state of the editing UI. + """ + + INITIAL = -1 + FIELDS = 0 + IO_PICKER = 1 + IO_MASKS = 2 + IO_FIELDS = 3 + + +class Editor: + """The screen that embeds an editing widget should listen for changes via + the `operation_did_execute` hook, and call set_note() when the editor needs + redrawing. + + The editor will cause that hook to be fired when it saves changes. To avoid + an unwanted refresh, the parent widget should check if handler + corresponds to this editor instance, and ignore the change if it does. + """ + + def __init__( + self, + mw: AnkiQt, + widget: QWidget, + parentWindow: QWidget, + addMode: bool | None = None, + *, + editor_mode: EditorMode = EditorMode.EDIT_CURRENT, + ) -> None: + self.mw = mw + self.widget = widget + self.parentWindow = parentWindow + self.note: Note | None = None + # legacy argument provided? + if addMode is not None: + editor_mode = EditorMode.ADD_CARDS if addMode else EditorMode.EDIT_CURRENT + self.addMode = editor_mode is EditorMode.ADD_CARDS + self.editorMode = editor_mode + self.currentField: int | None = None + # Similar to currentField, but not set to None on a blur. May be + # outside the bounds of the current notetype. + self.last_field_index: int | None = None + # used when creating a copy of an existing note + self.orig_note_id: NoteId | None = None + # current card, for card layout + self.card: Card | None = None + self.state: EditorState = EditorState.INITIAL + # used for the io mask editor's context menu + self.last_io_image_path: str | None = None + self._init_links() + self.setupOuter() + self.add_webview() + self.setupWeb() + self.setupShortcuts() + gui_hooks.editor_did_init(self) + + # Initial setup + ############################################################ + + def setupOuter(self) -> None: + l = QVBoxLayout() + l.setContentsMargins(0, 0, 0, 0) + l.setSpacing(0) + self.widget.setLayout(l) + self.outerLayout = l + + def add_webview(self) -> None: + self.web = EditorWebView(self.widget, self) + self.web.set_bridge_command(self.onBridgeCmd, self) + self.outerLayout.addWidget(self.web, 1) + + def setupWeb(self) -> None: + if self.editorMode == EditorMode.ADD_CARDS: + mode = "add" + elif self.editorMode == EditorMode.BROWSER: + mode = "browse" + else: + mode = "review" + + # then load page + self.web.stdHtml( + "", + css=["css/editor.css"], + js=[ + "js/mathjax.js", + "js/editor.js", + ], + context=self, + default_css=False, + ) + self.web.eval(f"setupEditor('{mode}')") + self.web.show() + + lefttopbtns: list[str] = [] + gui_hooks.editor_did_init_left_buttons(lefttopbtns, self) + + lefttopbtns_defs = [ + f"uiPromise.then((noteEditor) => noteEditor.toolbar.notetypeButtons.appendButton({{ component: editorToolbar.Raw, props: {{ html: {json.dumps(button)} }} }}, -1));" + for button in lefttopbtns + ] + lefttopbtns_js = "\n".join(lefttopbtns_defs) + + righttopbtns: list[str] = [] + gui_hooks.editor_did_init_buttons(righttopbtns, self) + # legacy filter + righttopbtns = runFilter("setupEditorButtons", righttopbtns, self) + + righttopbtns_defs = ", ".join([json.dumps(button) for button in righttopbtns]) + righttopbtns_js = ( + f""" +require("anki/ui").loaded.then(() => require("anki/NoteEditor").instances[0].toolbar.toolbar.append({{ + component: editorToolbar.AddonButtons, + id: "addons", + props: {{ buttons: [ {righttopbtns_defs} ] }}, +}})); +""" + if len(righttopbtns) > 0 + else "" + ) + + self.web.eval(f"{lefttopbtns_js} {righttopbtns_js}") + + # Top buttons + ###################################################################### + + def resourceToData(self, path: str) -> str: + """Convert a file (specified by a path) into a data URI.""" + if not os.path.exists(path): + raise FileNotFoundError + mime, _ = mimetypes.guess_type(path) + with open(path, "rb") as fp: + data = fp.read() + data64 = b"".join(base64.encodebytes(data).splitlines()) + return f"data:{mime};base64,{data64.decode('ascii')}" + + def addButton( + self, + icon: str | None, + cmd: str, + func: Callable[[Editor], None], + tip: str = "", + label: str = "", + id: str | None = None, + toggleable: bool = False, + keys: str | None = None, + disables: bool = True, + rightside: bool = True, + ) -> str: + """Assign func to bridge cmd, register shortcut, return button""" + + def wrapped_func(editor: Editor) -> None: + self.call_after_note_saved(functools.partial(func, editor), keepFocus=True) + + self._links[cmd] = wrapped_func + + if keys: + + def on_activated() -> None: + wrapped_func(self) + + if toggleable: + # generate a random id for triggering toggle + id = id or str(randrange(1_000_000)) + + def on_hotkey() -> None: + on_activated() + self.web.eval( + f'toggleEditorButton(document.getElementById("{id}"));' + ) + + else: + on_hotkey = on_activated + + QShortcut( # type: ignore + QKeySequence(keys), + self.widget, + activated=on_hotkey, + ) + + btn = self._addButton( + icon, + cmd, + tip=tip, + label=label, + id=id, + toggleable=toggleable, + disables=disables, + rightside=rightside, + ) + return btn + + def _addButton( + self, + icon: str | None, + cmd: str, + tip: str = "", + label: str = "", + id: str | None = None, + toggleable: bool = False, + disables: bool = True, + rightside: bool = True, + ) -> str: + title_attribute = tip + + if icon: + if icon.startswith("qrc:/"): + iconstr = icon + elif os.path.isabs(icon): + iconstr = self.resourceToData(icon) + else: + iconstr = f"/_anki/imgs/{icon}.png" + image_element = f'' + else: + image_element = "" + + if not label and icon: + label_element = "" + elif label: + label_element = label + else: + label_element = cmd + + title_attribute = shortcut(title_attribute) + id_attribute_assignment = f"id={id}" if id else "" + class_attribute = "linkb" if rightside else "rounded" + if not disables: + class_attribute += " perm" + + return f"""""" + + def setupShortcuts(self) -> None: + # if a third element is provided, enable shortcut even when no field selected + cuts: list[tuple] = [] + gui_hooks.editor_did_init_shortcuts(cuts, self) + for row in cuts: + if len(row) == 2: + keys, fn = row + fn = self._addFocusCheck(fn) + else: + keys, fn, _ = row + QShortcut(QKeySequence(keys), self.widget, activated=fn) # type: ignore + + def _addFocusCheck(self, fn: Callable) -> Callable: + def checkFocus() -> None: + if self.currentField is None: + return + fn() + + return checkFocus + + def onFields(self) -> None: + self.call_after_note_saved(self._onFields) + + def _onFields(self) -> None: + from aqt.fields import FieldDialog + + FieldDialog(self.mw, self.note_type(), parent=self.parentWindow) + + def onCardLayout(self) -> None: + self.call_after_note_saved(self._onCardLayout) + + def _onCardLayout(self) -> None: + from aqt.clayout import CardLayout + + if self.card: + ord = self.card.ord + else: + ord = 0 + + assert self.note is not None + CardLayout( + self.mw, + self.note, + ord=ord, + parent=self.parentWindow, + fill_empty=False, + ) + if is_win: + self.parentWindow.activateWindow() + + # JS->Python bridge + ###################################################################### + + def onBridgeCmd(self, cmd: str) -> Any: + if not self.note: + # shutdown + return + + # focus lost or key/button pressed? + if cmd.startswith("blur") or cmd.startswith("key"): + (type, ord_str, nid_str, txt) = cmd.split(":", 3) + ord = int(ord_str) + try: + nid = int(nid_str) + except ValueError: + nid = 0 + if nid != self.note.id: + print("ignored late blur") + return + + try: + self.note.fields[ord] = self.mungeHTML(txt) + except IndexError: + print("ignored late blur after notetype change") + return + + if not self.addMode: + self._save_current_note() + if type == "blur": + self.currentField = None + # run any filters + if gui_hooks.editor_did_unfocus_field(False, self.note, ord): + # something updated the note; update it after a subsequent focus + # event has had time to fire + self.mw.progress.timer( + 100, self.loadNoteKeepingFocus, False, parent=self.widget + ) + else: + self._check_and_update_duplicate_display_async() + else: + gui_hooks.editor_did_fire_typing_timer(self.note) + self._check_and_update_duplicate_display_async() + + # focused into field? + elif cmd.startswith("focus"): + (type, num) = cmd.split(":", 1) + self.last_field_index = self.currentField = int(num) + gui_hooks.editor_did_focus_field(self.note, self.currentField) + + elif cmd.startswith("toggleStickyAll"): + model = self.note_type() + flds = model["flds"] + + any_sticky = any([fld["sticky"] for fld in flds]) + result = [] + for fld in flds: + if not any_sticky or fld["sticky"]: + fld["sticky"] = not fld["sticky"] + + result.append(fld["sticky"]) + + update_notetype_legacy(parent=self.mw, notetype=model).run_in_background( + initiator=self + ) + + return result + + elif cmd.startswith("toggleSticky"): + (type, num) = cmd.split(":", 1) + ord = int(num) + + model = self.note_type() + fld = model["flds"][ord] + new_state = not fld["sticky"] + fld["sticky"] = new_state + + update_notetype_legacy(parent=self.mw, notetype=model).run_in_background( + initiator=self + ) + + return new_state + + elif cmd.startswith("lastTextColor"): + (_, textColor) = cmd.split(":", 1) + assert self.mw.pm.profile is not None + self.mw.pm.profile["lastTextColor"] = textColor + + elif cmd.startswith("lastHighlightColor"): + (_, highlightColor) = cmd.split(":", 1) + assert self.mw.pm.profile is not None + self.mw.pm.profile["lastHighlightColor"] = highlightColor + + elif cmd.startswith("saveTags"): + (type, tagsJson) = cmd.split(":", 1) + self.note.tags = json.loads(tagsJson) + + gui_hooks.editor_did_update_tags(self.note) + if not self.addMode: + self._save_current_note() + + elif cmd.startswith("setTagsCollapsed"): + (type, collapsed_string) = cmd.split(":", 1) + collapsed = collapsed_string == "true" + self.setTagsCollapsed(collapsed) + + elif cmd.startswith("editorState"): + (_, new_state_id, old_state_id) = cmd.split(":", 2) + self.signal_state_change( + EditorState(int(new_state_id)), EditorState(int(old_state_id)) + ) + + elif cmd.startswith("ioImageLoaded"): + (_, path_or_nid_data) = cmd.split(":", 1) + path_or_nid = json.loads(path_or_nid_data) + if self.addMode: + gui_hooks.editor_mask_editor_did_load_image(self, path_or_nid) + else: + gui_hooks.editor_mask_editor_did_load_image( + self, NoteId(int(path_or_nid)) + ) + + elif cmd in self._links: + return self._links[cmd](self) + + else: + print("uncaught cmd", cmd) + + def mungeHTML(self, txt: str) -> str: + return gui_hooks.editor_will_munge_html(txt, self) + + def signal_state_change( + self, new_state: EditorState, old_state: EditorState + ) -> None: + self.state = new_state + gui_hooks.editor_state_did_change(self, new_state, old_state) + + # Setting/unsetting the current note + ###################################################################### + + def set_note( + self, + note: Note | None, + hide: bool = True, + focusTo: int | None = None, + ) -> None: + "Make NOTE the current note." + self.note = note + self.currentField = None + if self.note: + self.loadNote(focusTo=focusTo) + elif hide: + self.widget.hide() + + def loadNoteKeepingFocus(self) -> None: + self.loadNote(self.currentField) + + def set_cloze_button(self) -> None: + action = "show" if self.note_type()["type"] == MODEL_CLOZE else "hide" + self.web.eval( + 'require("anki/ui").loaded.then(() =>' + f'require("anki/NoteEditor").instances[0].toolbar.toolbar.{action}("cloze")' + "); " + ) + + def set_image_occlusion_button(self) -> None: + action = "show" if self.current_notetype_is_image_occlusion() else "hide" + self.web.eval( + 'require("anki/ui").loaded.then(() =>' + f'require("anki/NoteEditor").instances[0].toolbar.toolbar.{action}("image-occlusion-button")' + "); " + ) + + def loadNote(self, focusTo: int | None = None) -> None: + if not self.note: + return + + data = [ + (fld, self.mw.col.media.escape_media_filenames(val)) + for fld, val in self.note.items() + ] + + note_type = self.note_type() + flds = note_type["flds"] + collapsed = [fld["collapsed"] for fld in flds] + cloze_fields_ords = self.mw.col.models.cloze_fields(self.note.mid) + cloze_fields = [ord in cloze_fields_ords for ord in range(len(flds))] + plain_texts = [fld.get("plainText", False) for fld in flds] + descriptions = [fld.get("description", "") for fld in flds] + notetype_meta = {"id": self.note.mid, "modTime": note_type["mod"]} + + self.widget.show() + + note_fields_status = self.note.fields_check() + + def oncallback(arg: Any) -> None: + if not self.note: + return + self.setupForegroundButton() + # we currently do this synchronously to ensure we load before the + # sidebar on browser startup + self._update_duplicate_display(note_fields_status) + if focusTo is not None: + self.web.setFocus() + self.set_cloze_button() + self.set_image_occlusion_button() + gui_hooks.editor_did_load_note(self) + + assert self.mw.pm.profile is not None + text_color = self.mw.pm.profile.get("lastTextColor", "#0000ff") + highlight_color = self.mw.pm.profile.get("lastHighlightColor", "#0000ff") + + js = f""" + saveSession(); + setFields({json.dumps(data)}); + setIsImageOcclusion({json.dumps(self.current_notetype_is_image_occlusion())}); + setNotetypeMeta({json.dumps(notetype_meta)}); + setCollapsed({json.dumps(collapsed)}); + setClozeFields({json.dumps(cloze_fields)}); + setPlainTexts({json.dumps(plain_texts)}); + setDescriptions({json.dumps(descriptions)}); + setFonts({json.dumps(self.fonts())}); + focusField({json.dumps(focusTo)}); + setNoteId({json.dumps(self.note.id)}); + setColorButtons({json.dumps([text_color, highlight_color])}); + setTags({json.dumps(self.note.tags)}); + setTagsCollapsed({json.dumps(self.mw.pm.tags_collapsed(self.editorMode))}); + setMathjaxEnabled({json.dumps(self.mw.col.get_config("renderMathjax", True))}); + setShrinkImages({json.dumps(self.mw.col.get_config("shrinkEditorImages", True))}); + setCloseHTMLTags({json.dumps(self.mw.col.get_config("closeHTMLTags", True))}); + triggerChanges(); + """ + + if self.addMode: + sticky = [field["sticky"] for field in self.note_type()["flds"]] + js += " setSticky(%s);" % json.dumps(sticky) + + if self.current_notetype_is_image_occlusion(): + io_field_indices = self.mw.backend.get_image_occlusion_fields(self.note.mid) + image_field = self.note.fields[io_field_indices.image] + self.last_io_image_path = self.extract_img_path_from_html(image_field) + + if self.editorMode is not EditorMode.ADD_CARDS: + io_options = self._create_edit_io_options(note_id=self.note.id) + js += " setupMaskEditor(%s);" % json.dumps(io_options) + elif orig_note_id := self.orig_note_id: + self.orig_note_id = None + io_options = self._create_clone_io_options(orig_note_id) + js += " setupMaskEditor(%s);" % json.dumps(io_options) + + js = gui_hooks.editor_will_load_note(js, self.note, self) + self.web.evalWithCallback( + f'require("anki/ui").loaded.then(() => {{ {js} }})', oncallback + ) + + def _save_current_note(self) -> None: + "Call after note is updated with data from webview." + if not self.note: + return + + update_note(parent=self.widget, note=self.note).run_in_background( + initiator=self + ) + + def fonts(self) -> list[tuple[str, int, bool]]: + return [ + (gui_hooks.editor_will_use_font_for_field(f["font"]), f["size"], f["rtl"]) + for f in self.note_type()["flds"] + ] + + def call_after_note_saved( + self, callback: Callable, keepFocus: bool = False + ) -> None: + "Save unsaved edits then call callback()." + if not self.note: + # calling code may not expect the callback to fire immediately + self.mw.progress.single_shot(10, callback) + return + self.web.evalWithCallback("saveNow(%d)" % keepFocus, lambda res: callback()) + + saveNow = call_after_note_saved + + def _check_and_update_duplicate_display_async(self) -> None: + note = self.note + if not note: + return + + def on_done(result: NoteFieldsCheckResult.V) -> None: + if self.note != note: + return + self._update_duplicate_display(result) + + QueryOp( + parent=self.parentWindow, + op=lambda _: note.fields_check(), + success=on_done, + ).run_in_background() + + checkValid = _check_and_update_duplicate_display_async + + def _update_duplicate_display(self, result: NoteFieldsCheckResult.V) -> None: + assert self.note is not None + cols = [""] * len(self.note.fields) + cloze_hint = "" + if result == NoteFieldsCheckResult.DUPLICATE: + cols[0] = "dupe" + elif result == NoteFieldsCheckResult.NOTETYPE_NOT_CLOZE: + cloze_hint = tr.adding_cloze_outside_cloze_notetype() + elif result == NoteFieldsCheckResult.FIELD_NOT_CLOZE: + cloze_hint = tr.adding_cloze_outside_cloze_field() + + self.web.eval( + 'require("anki/ui").loaded.then(() => {' + f"setBackgrounds({json.dumps(cols)});\n" + f"setClozeHint({json.dumps(cloze_hint)});\n" + "}); " + ) + + def showDupes(self) -> None: + assert self.note is not None + aqt.dialogs.open( + "Browser", + self.mw, + search=( + SearchNode( + dupe=SearchNode.Dupe( + notetype_id=self.note_type()["id"], + first_field=self.note.fields[0], + ) + ), + ), + ) + + def fieldsAreBlank(self, previousNote: Note | None = None) -> bool: + if not self.note: + return True + m = self.note_type() + for c, f in enumerate(self.note.fields): + f = f.replace("
", "").strip() + notChangedvalues = {"", "
"} + if previousNote and m["flds"][c]["sticky"]: + notChangedvalues.add(previousNote.fields[c].replace("
", "").strip()) + if f not in notChangedvalues: + return False + return True + + def cleanup(self) -> None: + av_player.stop_and_clear_queue_if_caller(self.editorMode) + self.set_note(None) + # prevent any remaining evalWithCallback() events from firing after C++ object deleted + if self.web: + self.web.cleanup() + self.web = None # type: ignore + + # legacy + + setNote = set_note + + # Tag handling + ###################################################################### + + def setupTags(self) -> None: + import aqt.tagedit + + g = QGroupBox(self.widget) + g.setStyleSheet("border: 0") + tb = QGridLayout() + tb.setSpacing(12) + tb.setContentsMargins(2, 6, 2, 6) + # tags + l = QLabel(tr.editing_tags()) + tb.addWidget(l, 1, 0) + self.tags = aqt.tagedit.TagEdit(self.widget) + qconnect(self.tags.lostFocus, self.on_tag_focus_lost) + self.tags.setToolTip(shortcut(tr.editing_jump_to_tags_with_ctrlandshiftandt())) + border = theme_manager.var(colors.BORDER) + self.tags.setStyleSheet(f"border: 1px solid {border}") + tb.addWidget(self.tags, 1, 1) + g.setLayout(tb) + self.outerLayout.addWidget(g) + + def updateTags(self) -> None: + if self.tags.col != self.mw.col: + self.tags.setCol(self.mw.col) + if not self.tags.text() or not self.addMode: + assert self.note is not None + self.tags.setText(self.note.string_tags().strip()) + + def on_tag_focus_lost(self) -> None: + assert self.note is not None + self.note.tags = self.mw.col.tags.split(self.tags.text()) + gui_hooks.editor_did_update_tags(self.note) + if not self.addMode: + self._save_current_note() + + def blur_tags_if_focused(self) -> None: + if not self.note: + return + if self.tags.hasFocus(): + self.widget.setFocus() + + def hideCompleters(self) -> None: + self.tags.hideCompleter() + + def onFocusTags(self) -> None: + self.tags.setFocus() + + # legacy + + def saveAddModeVars(self) -> None: + pass + + saveTags = blur_tags_if_focused + + # Audio/video/images + ###################################################################### + + def onAddMedia(self) -> None: + """Show a file selection screen, then add the selected media. + This expects initial setup to have been done by TemplateButtons.svelte.""" + extension_filter = " ".join( + f"*.{extension}" for extension in sorted(itertools.chain(pics, audio)) + ) + filter = f"{tr.editing_media()} ({extension_filter})" + + def accept(file: str) -> None: + self.resolve_media(file) + + getFile( + parent=self.widget, + title=tr.editing_add_media(), + cb=cast(Callable[[Any], None], accept), + filter=filter, + key="media", + ) + + self.parentWindow.activateWindow() + + def addMedia(self, path: str, canDelete: bool = False) -> None: + """Legacy routine used by add-ons to add a media file and update the current field. + canDelete is ignored.""" + + try: + html = self._addMedia(path) + except Exception as e: + showWarning(str(e)) + return + + self.web.eval(f"setFormat('inserthtml', {json.dumps(html)});") + + def resolve_media(self, path: str) -> None: + """Finish inserting media into a field. + This expects initial setup to have been done by TemplateButtons.svelte.""" + try: + html = self._addMedia(path) + except Exception as e: + showWarning(str(e)) + return + + self.web.eval( + f'require("anki/TemplateButtons").resolveMedia({json.dumps(html)})' + ) + + def _addMedia(self, path: str, canDelete: bool = False) -> str: + """Add to media folder and return local img or sound tag.""" + # copy to media folder + fname = self.mw.col.media.add_file(path) + # return a local html link + return self.fnameToLink(fname) + + def _addMediaFromData(self, fname: str, data: bytes) -> str: + return self.mw.col.media._legacy_write_data(fname, data) + + def onRecSound(self) -> None: + aqt.sound.record_audio( + self.parentWindow, + self.mw, + True, + self.resolve_media, + ) + + # Media downloads + ###################################################################### + + def urlToLink(self, url: str, allowed_suffixes: Iterable[str] = ()) -> str: + fname = ( + self.urlToFile(url, allowed_suffixes) + if allowed_suffixes + else self.urlToFile(url) + ) + if not fname: + return '{}'.format( + url, html.escape(urllib.parse.unquote(url)) + ) + return self.fnameToLink(fname) + + def fnameToLink(self, fname: str) -> str: + ext = fname.split(".")[-1].lower() + if ext in pics: + name = urllib.parse.quote(fname.encode("utf8")) + return f'' + else: + av_player.play_file_with_caller(fname, self.editorMode) + return f"[sound:{html.escape(fname, quote=False)}]" + + def urlToFile( + self, url: str, allowed_suffixes: Iterable[str] = pics + audio + ) -> str | None: + l = url.lower() + for suffix in allowed_suffixes: + if l.endswith(f".{suffix}"): + return self._retrieveURL(url) + # not a supported type + return None + + def isURL(self, s: str) -> bool: + s = s.lower() + return ( + s.startswith("http://") + or s.startswith("https://") + or s.startswith("ftp://") + or s.startswith("file://") + ) + + def inlinedImageToFilename(self, txt: str) -> str: + prefix = "data:image/" + suffix = ";base64," + for ext in ("jpg", "jpeg", "png", "gif"): + fullPrefix = prefix + ext + suffix + if txt.startswith(fullPrefix): + b64data = txt[len(fullPrefix) :].strip() + data = base64.b64decode(b64data, validate=True) + if ext == "jpeg": + ext = "jpg" + return self._addPastedImage(data, ext) + + return "" + + def inlinedImageToLink(self, src: str) -> str: + fname = self.inlinedImageToFilename(src) + if fname: + return self.fnameToLink(fname) + + return "" + + def _pasted_image_filename(self, data: bytes, ext: str) -> str: + csum = checksum(data) + return f"paste-{csum}.{ext}" + + def _read_pasted_image(self, mime: QMimeData) -> str: + image = QImage(mime.imageData()) + buffer = QBuffer() + buffer.open(QBuffer.OpenModeFlag.ReadWrite) + if self.mw.col.get_config_bool(Config.Bool.PASTE_IMAGES_AS_PNG): + ext = "png" + quality = 50 + else: + ext = "jpg" + quality = 80 + image.save(buffer, ext, quality) + buffer.reset() + data = bytes(buffer.readAll()) # type: ignore + fname = self._pasted_image_filename(data, ext) + path = namedtmp(fname) + with open(path, "wb") as file: + file.write(data) + + return path + + def _addPastedImage(self, data: bytes, ext: str) -> str: + # hash and write + fname = self._pasted_image_filename(data, ext) + return self._addMediaFromData(fname, data) + + def _retrieveURL(self, url: str) -> str | None: + "Download file into media folder and return local filename or None." + local = url.lower().startswith("file://") + # fetch it into a temporary folder + self.mw.progress.start(immediate=not local, parent=self.parentWindow) + content_type = None + error_msg: str | None = None + try: + if local: + # urllib doesn't understand percent-escaped utf8, but requires things like + # '#' to be escaped. + url = urllib.parse.unquote(url) + url = url.replace("%", "%25") + url = url.replace("#", "%23") + req = urllib.request.Request( + url, None, {"User-Agent": "Mozilla/5.0 (compatible; Anki)"} + ) + with urllib.request.urlopen(req) as response: + filecontents = response.read() + else: + with HttpClient() as client: + client.timeout = 30 + with client.get(url) as response: + if response.status_code != 200: + error_msg = tr.qt_misc_unexpected_response_code( + val=response.status_code, + ) + return None + filecontents = response.content + content_type = response.headers.get("content-type") + except (urllib.error.URLError, requests.exceptions.RequestException) as e: + error_msg = tr.editing_an_error_occurred_while_opening(val=str(e)) + return None + finally: + self.mw.progress.finish() + if error_msg: + showWarning(error_msg) + # strip off any query string + url = re.sub(r"\?.*?$", "", url) + fname = os.path.basename(urllib.parse.unquote(url)) + if not fname.strip(): + fname = "paste" + if content_type: + fname = self.mw.col.media.add_extension_based_on_mime(fname, content_type) + + return self.mw.col.media.write_data(fname, filecontents) + + # Paste/drag&drop + ###################################################################### + + removeTags = ["script", "iframe", "object", "style"] + + def _pastePreFilter(self, html: str, internal: bool) -> str: + # https://anki.tenderapp.com/discussions/ankidesktop/39543-anki-is-replacing-the-character-by-when-i-exit-the-html-edit-mode-ctrlshiftx + if html.find(">") < 0: + return html + + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + doc = BeautifulSoup(html, "html.parser") + + if not internal: + for tag_name in self.removeTags: + for node in doc(tag_name): + node.decompose() + + # convert p tags to divs + for node in doc("p"): + if hasattr(node, "name"): + node.name = "div" + + for element in doc("img"): + if not isinstance(element, bs4.Tag): + continue + tag = element + try: + src = tag["src"] + except KeyError: + # for some bizarre reason, mnemosyne removes src elements + # from missing media + continue + + # in internal pastes, rewrite mediasrv references to relative + if internal: + m = re.match(r"http://127.0.0.1:\d+/(.*)$", str(src)) + if m: + tag["src"] = m.group(1) + # in external pastes, download remote media + elif isinstance(src, str) and self.isURL(src): + fname = self._retrieveURL(src) + if fname: + tag["src"] = fname + elif isinstance(src, str) and src.startswith("data:image/"): + # and convert inlined data + tag["src"] = self.inlinedImageToFilename(str(src)) + + html = str(doc) + return html + + def doPaste(self, html: str, internal: bool, extended: bool = False) -> None: + html = self._pastePreFilter(html, internal) + if extended: + ext = "true" + else: + ext = "false" + self.web.eval(f"pasteHTML({json.dumps(html)}, {json.dumps(internal)}, {ext});") + gui_hooks.editor_did_paste(self, html, internal, extended) + + def doDrop( + self, html: str, internal: bool, extended: bool, cursor_pos: QPoint + ) -> None: + def pasteIfField(ret: bool) -> None: + if ret: + self.doPaste(html, internal, extended) + + zoom = self.web.zoomFactor() + x, y = int(cursor_pos.x() / zoom), int(cursor_pos.y() / zoom) + + self.web.evalWithCallback(f"focusIfField({x}, {y});", pasteIfField) + + def onPaste(self) -> None: + self.web.onPaste() + + def onCutOrCopy(self) -> None: + self.web.user_cut_or_copied() + + # Image occlusion + ###################################################################### + + def current_notetype_is_image_occlusion(self) -> bool: + if not self.note: + return False + + return ( + self.note_type().get("originalStockKind", None) + == StockNotetype.OriginalStockKind.ORIGINAL_STOCK_KIND_IMAGE_OCCLUSION + ) + + def setup_mask_editor(self, image_path: str) -> None: + try: + if self.editorMode == EditorMode.ADD_CARDS: + self.setup_mask_editor_for_new_note( + image_path=image_path, notetype_id=0 + ) + else: + assert self.note is not None + self.setup_mask_editor_for_existing_note( + note_id=self.note.id, image_path=image_path + ) + except Exception as e: + showWarning(str(e)) + + def select_image_and_occlude(self) -> None: + """Show a file selection screen, then get selected image path.""" + extension_filter = " ".join( + f"*.{extension}" for extension in sorted(itertools.chain(pics)) + ) + filter = f"{tr.editing_media()} ({extension_filter})" + + getFile( + parent=self.widget, + title=tr.editing_add_media(), + cb=cast(Callable[[Any], None], self.setup_mask_editor), + filter=filter, + key="media", + ) + + self.parentWindow.activateWindow() + + def extract_img_path_from_html(self, html: str) -> str | None: + assert self.note is not None + # with allowed_suffixes=pics, all non-pics will be rendered as s and won't be included here + if not (images := self.mw.col.media.files_in_str(self.note.mid, html)): + return None + image_path = urllib.parse.unquote(images[0]) + return os.path.join(self.mw.col.media.dir(), image_path) + + def select_image_from_clipboard_and_occlude(self) -> None: + """Set up the mask editor for the image in the clipboard.""" + + clipboard = self.mw.app.clipboard() + assert clipboard is not None + mime = clipboard.mimeData() + assert mime is not None + # try checking for urls first, fallback to image data + if ( + (html := self.web._processUrls(mime, allowed_suffixes=pics)) + and (path := self.extract_img_path_from_html(html)) + ) or (mime.hasImage() and (path := self._read_pasted_image(mime))): + self.setup_mask_editor(path) + self.parentWindow.activateWindow() + else: + showWarning(tr.editing_no_image_found_on_clipboard()) + return + + def setup_mask_editor_for_new_note( + self, + image_path: str, + notetype_id: NotetypeId | int = 0, + ): + """Set-up IO mask editor for adding new notes + Presupposes that active editor notetype is an image occlusion notetype + Args: + image_path: Absolute path to image. + notetype_id: ID of note type to use. Provided ID must belong to an + image occlusion notetype. Set this to 0 to auto-select the first + found image occlusion notetype in the user's collection. + """ + image_field_html = self._addMedia(image_path) + self.last_io_image_path = self.extract_img_path_from_html(image_field_html) + io_options = self._create_add_io_options( + image_path=image_path, + image_field_html=image_field_html, + notetype_id=notetype_id, + ) + self._setup_mask_editor(io_options) + + def setup_mask_editor_for_existing_note( + self, note_id: NoteId, image_path: str | None = None + ): + """Set-up IO mask editor for editing existing notes + Presupposes that active editor notetype is an image occlusion notetype + Args: + note_id: ID of note to edit. + image_path: (Optional) Absolute path to image that should replace current + image + """ + io_options = self._create_edit_io_options(note_id) + if image_path: + image_field_html = self._addMedia(image_path) + self.last_io_image_path = self.extract_img_path_from_html(image_field_html) + self.web.eval(f"resetIOImage({json.dumps(image_path)})") + self.web.eval(f"setImageField({json.dumps(image_field_html)})") + self._setup_mask_editor(io_options) + + def reset_image_occlusion(self) -> None: + self.web.eval("resetIOImageLoaded()") + + def update_occlusions_field(self) -> None: + self.web.eval("saveOcclusions()") + + def _setup_mask_editor(self, io_options: dict): + self.web.eval( + 'require("anki/ui").loaded.then(() =>' + f"setupMaskEditor({json.dumps(io_options)})" + "); " + ) + + @staticmethod + def _create_add_io_options( + image_path: str, image_field_html: str, notetype_id: NotetypeId | int = 0 + ) -> dict: + return { + "mode": {"kind": "add", "imagePath": image_path, "notetypeId": notetype_id}, + "html": image_field_html, + } + + @staticmethod + def _create_clone_io_options(orig_note_id: NoteId) -> dict: + return { + "mode": {"kind": "add", "clonedNoteId": orig_note_id}, + } + + @staticmethod + def _create_edit_io_options(note_id: NoteId) -> dict: + return {"mode": {"kind": "edit", "noteId": note_id}} + + # Legacy editing routines + ###################################################################### + + _js_legacy = "this routine has been moved into JS, and will be removed soon" + + @deprecated(info=_js_legacy) + def onHtmlEdit(self) -> None: + field = self.currentField + self.call_after_note_saved(lambda: self._onHtmlEdit(field)) + + @deprecated(info=_js_legacy) + def _onHtmlEdit(self, field: int) -> None: + assert self.note is not None + d = QDialog(self.widget, Qt.WindowType.Window) + form = aqt.forms.edithtml.Ui_Dialog() + form.setupUi(d) + restoreGeom(d, "htmlEditor") + disable_help_button(d) + qconnect( + form.buttonBox.helpRequested, lambda: openHelp(HelpPage.EDITING_FEATURES) + ) + font = QFont("Courier") + font.setStyleHint(QFont.StyleHint.TypeWriter) + form.textEdit.setFont(font) + form.textEdit.setPlainText(self.note.fields[field]) + d.show() + form.textEdit.moveCursor(QTextCursor.MoveOperation.End) + d.exec() + html = form.textEdit.toPlainText() + if html.find(">") > -1: + # filter html through beautifulsoup so we can strip out things like a + # leading + html_escaped = self.mw.col.media.escape_media_filenames(html) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + html_escaped = str(BeautifulSoup(html_escaped, "html.parser")) + html = self.mw.col.media.escape_media_filenames( + html_escaped, unescape=True + ) + self.note.fields[field] = html + if not self.addMode: + self._save_current_note() + self.loadNote(focusTo=field) + saveGeom(d, "htmlEditor") + + @deprecated(info=_js_legacy) + def toggleBold(self) -> None: + self.web.eval("setFormat('bold');") + + @deprecated(info=_js_legacy) + def toggleItalic(self) -> None: + self.web.eval("setFormat('italic');") + + @deprecated(info=_js_legacy) + def toggleUnderline(self) -> None: + self.web.eval("setFormat('underline');") + + @deprecated(info=_js_legacy) + def toggleSuper(self) -> None: + self.web.eval("setFormat('superscript');") + + @deprecated(info=_js_legacy) + def toggleSub(self) -> None: + self.web.eval("setFormat('subscript');") + + @deprecated(info=_js_legacy) + def removeFormat(self) -> None: + self.web.eval("setFormat('removeFormat');") + + @deprecated(info=_js_legacy) + def onCloze(self) -> None: + self.call_after_note_saved(self._onCloze, keepFocus=True) + + @deprecated(info=_js_legacy) + def _onCloze(self) -> None: + # check that the model is set up for cloze deletion + if self.note_type()["type"] != MODEL_CLOZE: + if self.addMode: + tooltip(tr.editing_warning_cloze_deletions_will_not_work()) + else: + showInfo(tr.editing_to_make_a_cloze_deletion_on()) + return + # find the highest existing cloze + highest = 0 + assert self.note is not None + for _, val in list(self.note.items()): + m = re.findall(r"\{\{c(\d+)::", val) + if m: + highest = max(highest, sorted(int(x) for x in m)[-1]) + # reuse last? + if not KeyboardModifiersPressed().alt: + highest += 1 + # must start at 1 + highest = max(1, highest) + self.web.eval("wrap('{{c%d::', '}}');" % highest) + + def setupForegroundButton(self) -> None: + assert self.mw.pm.profile is not None + self.fcolour = self.mw.pm.profile.get("lastColour", "#00f") + + # use last colour + @deprecated(info=_js_legacy) + def onForeground(self) -> None: + self._wrapWithColour(self.fcolour) + + # choose new colour + @deprecated(info=_js_legacy) + def onChangeCol(self) -> None: + if is_lin: + new = QColorDialog.getColor( + QColor(self.fcolour), + None, + None, + QColorDialog.ColorDialogOption.DontUseNativeDialog, + ) + else: + new = QColorDialog.getColor(QColor(self.fcolour), None) + # native dialog doesn't refocus us for some reason + self.parentWindow.activateWindow() + if new.isValid(): + self.fcolour = new.name() + self.onColourChanged() + self._wrapWithColour(self.fcolour) + + @deprecated(info=_js_legacy) + def _updateForegroundButton(self) -> None: + pass + + @deprecated(info=_js_legacy) + def onColourChanged(self) -> None: + self._updateForegroundButton() + assert self.mw.pm.profile is not None + self.mw.pm.profile["lastColour"] = self.fcolour + + @deprecated(info=_js_legacy) + def _wrapWithColour(self, colour: str) -> None: + self.web.eval(f"setFormat('forecolor', '{colour}')") + + @deprecated(info=_js_legacy) + def onAdvanced(self) -> None: + m = QMenu(self.mw) + + for text, handler, shortcut in ( + (tr.editing_mathjax_inline(), self.insertMathjaxInline, "Ctrl+M, M"), + (tr.editing_mathjax_block(), self.insertMathjaxBlock, "Ctrl+M, E"), + ( + tr.editing_mathjax_chemistry(), + self.insertMathjaxChemistry, + "Ctrl+M, C", + ), + (tr.editing_latex(), self.insertLatex, "Ctrl+T, T"), + (tr.editing_latex_equation(), self.insertLatexEqn, "Ctrl+T, E"), + (tr.editing_latex_math_env(), self.insertLatexMathEnv, "Ctrl+T, M"), + (tr.editing_edit_html(), self.onHtmlEdit, "Ctrl+Shift+X"), + ): + a = m.addAction(text) + assert a is not None + qconnect(a.triggered, handler) + a.setShortcut(QKeySequence(shortcut)) + + qtMenuShortcutWorkaround(m) + + m.exec(QCursor.pos()) + + @deprecated(info=_js_legacy) + def insertLatex(self) -> None: + self.web.eval("wrap('[latex]', '[/latex]');") + + @deprecated(info=_js_legacy) + def insertLatexEqn(self) -> None: + self.web.eval("wrap('[$]', '[/$]');") + + @deprecated(info=_js_legacy) + def insertLatexMathEnv(self) -> None: + self.web.eval("wrap('[$$]', '[/$$]');") + + @deprecated(info=_js_legacy) + def insertMathjaxInline(self) -> None: + self.web.eval("wrap('\\\\(', '\\\\)');") + + @deprecated(info=_js_legacy) + def insertMathjaxBlock(self) -> None: + self.web.eval("wrap('\\\\[', '\\\\]');") + + @deprecated(info=_js_legacy) + def insertMathjaxChemistry(self) -> None: + self.web.eval("wrap('\\\\(\\\\ce{', '}\\\\)');") + + def toggleMathjax(self) -> None: + self.mw.col.set_config( + "renderMathjax", not self.mw.col.get_config("renderMathjax", False) + ) + # hackily redraw the page + self.setupWeb() + self.loadNoteKeepingFocus() + + def toggleShrinkImages(self) -> None: + self.mw.col.set_config( + "shrinkEditorImages", + not self.mw.col.get_config("shrinkEditorImages", True), + ) + + def toggleCloseHTMLTags(self) -> None: + self.mw.col.set_config( + "closeHTMLTags", + not self.mw.col.get_config("closeHTMLTags", True), + ) + + def setTagsCollapsed(self, collapsed: bool) -> None: + aqt.mw.pm.set_tags_collapsed(self.editorMode, collapsed) + + # Links from HTML + ###################################################################### + + def _init_links(self) -> None: + self._links: dict[str, Callable] = dict( + fields=Editor.onFields, + cards=Editor.onCardLayout, + bold=Editor.toggleBold, + italic=Editor.toggleItalic, + underline=Editor.toggleUnderline, + super=Editor.toggleSuper, + sub=Editor.toggleSub, + clear=Editor.removeFormat, + colour=Editor.onForeground, + changeCol=Editor.onChangeCol, + cloze=Editor.onCloze, + attach=Editor.onAddMedia, + record=Editor.onRecSound, + more=Editor.onAdvanced, + dupes=Editor.showDupes, + paste=Editor.onPaste, + cutOrCopy=Editor.onCutOrCopy, + htmlEdit=Editor.onHtmlEdit, + mathjaxInline=Editor.insertMathjaxInline, + mathjaxBlock=Editor.insertMathjaxBlock, + mathjaxChemistry=Editor.insertMathjaxChemistry, + toggleMathjax=Editor.toggleMathjax, + toggleShrinkImages=Editor.toggleShrinkImages, + toggleCloseHTMLTags=Editor.toggleCloseHTMLTags, + addImageForOcclusion=Editor.select_image_and_occlude, + addImageForOcclusionFromClipboard=Editor.select_image_from_clipboard_and_occlude, + ) + + def note_type(self) -> NotetypeDict: + assert self.note is not None + note_type = self.note.note_type() + assert note_type is not None + return note_type + + +# Pasting, drag & drop, and keyboard layouts +###################################################################### + + +class EditorWebView(AnkiWebView): + def __init__(self, parent: QWidget, editor: Editor) -> None: + AnkiWebView.__init__(self, kind=AnkiWebViewKind.EDITOR) + self.editor = editor + self.setAcceptDrops(True) + self._store_field_content_on_next_clipboard_change = False + # when we detect the user copying from a field, we store the content + # here, and use it when they paste, so we avoid filtering field content + self._internal_field_text_for_paste: str | None = None + self._last_known_clipboard_mime: QMimeData | None = None + clip = self.editor.mw.app.clipboard() + assert clip is not None + clip.dataChanged.connect(self._on_clipboard_change) + gui_hooks.editor_web_view_did_init(self) + + def user_cut_or_copied(self) -> None: + self._store_field_content_on_next_clipboard_change = True + self._internal_field_text_for_paste = None + + def _on_clipboard_change( + self, mode: QClipboard.Mode = QClipboard.Mode.Clipboard + ) -> None: + self._last_known_clipboard_mime = self._clipboard().mimeData(mode) + if self._store_field_content_on_next_clipboard_change: + # if the flag was set, save the field data + self._internal_field_text_for_paste = self._get_clipboard_html_for_field( + mode + ) + self._store_field_content_on_next_clipboard_change = False + elif self._internal_field_text_for_paste != self._get_clipboard_html_for_field( + mode + ): + # if we've previously saved the field, blank it out if the clipboard state has changed + self._internal_field_text_for_paste = None + + def _get_clipboard_html_for_field(self, mode: QClipboard.Mode) -> str | None: + clip = self._clipboard() + if not (mime := clip.mimeData(mode)): + return None + if not mime.hasHtml(): + return None + return mime.html() + + def onCut(self) -> None: + self.triggerPageAction(QWebEnginePage.WebAction.Cut) + + def onCopy(self) -> None: + self.triggerPageAction(QWebEnginePage.WebAction.Copy) + + def on_copy_image(self) -> None: + self.triggerPageAction(QWebEnginePage.WebAction.CopyImageToClipboard) + + def _opened_context_menu_on_image(self) -> bool: + if not hasattr(self, "lastContextMenuRequest"): + return False + context_menu_request = self.lastContextMenuRequest() + assert context_menu_request is not None + return ( + context_menu_request.mediaType() + == context_menu_request.MediaType.MediaTypeImage + ) + + def _wantsExtendedPaste(self) -> bool: + strip_html = self.editor.mw.col.get_config_bool( + Config.Bool.PASTE_STRIPS_FORMATTING + ) + if KeyboardModifiersPressed().shift: + strip_html = not strip_html + return not strip_html + + def _onPaste(self, mode: QClipboard.Mode) -> None: + # Since _on_clipboard_change doesn't always trigger properly on macOS, we do a double check if any changes were made before pasting + clipboard = self._clipboard() + if self._last_known_clipboard_mime != clipboard.mimeData(mode): + self._on_clipboard_change(mode) + extended = self._wantsExtendedPaste() + if html := self._internal_field_text_for_paste: + print("reuse internal") + self.editor.doPaste(html, True, extended) + else: + if not (mime := clipboard.mimeData(mode=mode)): + return + print("use clipboard") + html, internal = self._processMime(mime, extended) + if html: + self.editor.doPaste(html, internal, extended) + + def onPaste(self) -> None: + self._onPaste(QClipboard.Mode.Clipboard) + + def onMiddleClickPaste(self) -> None: + self._onPaste(QClipboard.Mode.Selection) + + def dragEnterEvent(self, evt: QDragEnterEvent | None) -> None: + assert evt is not None + evt.accept() + + def dropEvent(self, evt: QDropEvent | None) -> None: + assert evt is not None + extended = self._wantsExtendedPaste() + mime = evt.mimeData() + assert mime is not None + + if ( + self.editor.state is EditorState.IO_PICKER + and (html := self._processUrls(mime, allowed_suffixes=pics)) + and (path := self.editor.extract_img_path_from_html(html)) + ): + self.editor.setup_mask_editor(path) + return + + evt_pos = evt.position() + cursor_pos = QPoint(int(evt_pos.x()), int(evt_pos.y())) + + if evt.source() and mime.hasHtml(): + # don't filter html from other fields + html, internal = mime.html(), True + else: + html, internal = self._processMime(mime, extended, drop_event=True) + + if not html: + return + + self.editor.doDrop(html, internal, extended, cursor_pos) + + # returns (html, isInternal) + def _processMime( + self, mime: QMimeData, extended: bool = False, drop_event: bool = False + ) -> tuple[str, bool]: + # print("html=%s image=%s urls=%s txt=%s" % ( + # mime.hasHtml(), mime.hasImage(), mime.hasUrls(), mime.hasText())) + # print("html", mime.html()) + # print("urls", mime.urls()) + # print("text", mime.text()) + + internal = False + + mime = gui_hooks.editor_will_process_mime( + mime, self, internal, extended, drop_event + ) + + # try various content types in turn + if mime.hasHtml(): + html_content = mime.html()[11:] if internal else mime.html() + return html_content, internal + + # given _processUrls' extra allowed_suffixes kwarg, placate the typechecker + def process_url(mime: QMimeData, extended: bool = False) -> str | None: + return self._processUrls(mime, extended) + + # favour url if it's a local link + if ( + mime.hasUrls() + and (urls := mime.urls()) + and urls[0].toString().startswith("file://") + ): + types = (process_url, self._processImage, self._processText) + else: + types = (self._processImage, process_url, self._processText) + + for fn in types: + html = fn(mime, extended) + if html: + return html, True + return "", False + + def _processUrls( + self, + mime: QMimeData, + extended: bool = False, + allowed_suffixes: Iterable[str] = (), + ) -> str | None: + if not mime.hasUrls(): + return None + + buf = "" + for qurl in mime.urls(): + url = qurl.toString() + # chrome likes to give us the URL twice with a \n + if lines := url.splitlines(): + url = lines[0] + buf += self.editor.urlToLink(url, allowed_suffixes=allowed_suffixes) + + return buf + + def _processText(self, mime: QMimeData, extended: bool = False) -> str | None: + if not mime.hasText(): + return None + + txt = mime.text() + processed = [] + lines = txt.split("\n") + + for line in lines: + for token in re.split(r"(\S+)", line): + # inlined data in base64? + if extended and token.startswith("data:image/"): + processed.append(self.editor.inlinedImageToLink(token)) + elif extended and self.editor.isURL(token): + # if the user is pasting an image or sound link, convert it to local, otherwise paste as a hyperlink + link = self.editor.urlToLink(token) + processed.append(link) + else: + token = html.escape(token).replace("\t", " " * 4) + + # if there's more than one consecutive space, + # use non-breaking spaces for the second one on + def repl(match: Match) -> str: + return f"{match.group(1).replace(' ', ' ')} " + + token = re.sub(" ( +)", repl, token) + processed.append(token) + + processed.append("
") + # remove last
+ processed.pop() + return "".join(processed) + + def _processImage(self, mime: QMimeData, extended: bool = False) -> str | None: + if not mime.hasImage(): + return None + path = self.editor._read_pasted_image(mime) + fname = self.editor._addMedia(path) + + return fname + + def contextMenuEvent(self, evt: QContextMenuEvent | None) -> None: + m = QMenu(self) + if self.hasSelection(): + self._add_cut_action(m) + self._add_copy_action(m) + a = m.addAction(tr.editing_paste()) + assert a is not None + qconnect(a.triggered, self.onPaste) + if self.editor.state is EditorState.IO_MASKS and ( + path := self.editor.last_io_image_path + ): + self._add_image_menu_with_path(m, path) + elif self._opened_context_menu_on_image(): + self._add_image_menu(m) + gui_hooks.editor_will_show_context_menu(self, m) + m.popup(QCursor.pos()) + + def _add_cut_action(self, menu: QMenu) -> None: + a = menu.addAction(tr.editing_cut()) + assert a is not None + qconnect(a.triggered, self.onCut) + + def _add_copy_action(self, menu: QMenu) -> None: + a = menu.addAction(tr.actions_copy()) + assert a is not None + qconnect(a.triggered, self.onCopy) + + def _add_image_menu(self, menu: QMenu) -> None: + a = menu.addAction(tr.editing_copy_image()) + assert a is not None + qconnect(a.triggered, self.on_copy_image) + + context_menu_request = self.lastContextMenuRequest() + assert context_menu_request is not None + url = context_menu_request.mediaUrl() + file_name = url.fileName() + path = os.path.join(self.editor.mw.col.media.dir(), file_name) + self._add_image_menu_with_path(menu, path) + + def _add_image_menu_with_path(self, menu: QMenu, path: str) -> None: + a = menu.addAction(tr.editing_open_image()) + assert a is not None + qconnect(a.triggered, lambda: openFolder(path)) + + a = menu.addAction(tr.editing_show_in_folder()) + assert a is not None + qconnect(a.triggered, lambda: show_in_folder(path)) + + def _clipboard(self) -> QClipboard: + clipboard = self.editor.mw.app.clipboard() + assert clipboard is not None + return clipboard + + +# QFont returns "Kozuka Gothic Pro L" but WebEngine expects "Kozuka Gothic Pro Light" +# - there may be other cases like a trailing 'Bold' that need fixing, but will +# wait for further reports first. +def fontMungeHack(font: str) -> str: + return re.sub(" L$", " Light", font) + + +def munge_html(txt: str, editor: Editor) -> str: + return "" if txt in ("
", "

") else txt + + +def remove_null_bytes(txt: str, editor: Editor) -> str: + # misbehaving apps may include a null byte in the text + return txt.replace("\x00", "") + + +def reverse_url_quoting(txt: str, editor: Editor) -> str: + # reverse the url quoting we added to get images to display + return editor.mw.col.media.escape_media_filenames(txt, unescape=True) + + +gui_hooks.editor_will_use_font_for_field.append(fontMungeHack) +gui_hooks.editor_will_munge_html.append(munge_html) # type: ignore +gui_hooks.editor_will_munge_html.append(remove_null_bytes) # type: ignore +gui_hooks.editor_will_munge_html.append(reverse_url_quoting) # type: ignore +gui_hooks.editor_will_munge_html.append(reverse_url_quoting) # type: ignore diff --git a/qt/aqt/main.py b/qt/aqt/main.py index c707d1b2a..0ec2aa75c 100644 --- a/qt/aqt/main.py +++ b/qt/aqt/main.py @@ -1280,14 +1280,20 @@ title="{}" {}>{}""".format( # Other menu operations ########################################################################## + def _open_new_or_legacy_dialog(self, name: str, *args: Any, **kwargs: Any) -> None: + want_old = KeyboardModifiersPressed().shift + if not want_old: + name = f"New{name}" + aqt.dialogs.open(name, self, *args, **kwargs) + def onAddCard(self) -> None: - aqt.dialogs.open("AddCards", self) + self._open_new_or_legacy_dialog("AddCards") def onBrowse(self) -> None: aqt.dialogs.open("Browser", self, card=self.reviewer.card) def onEditCurrent(self) -> None: - aqt.dialogs.open("EditCurrent", self) + self._open_new_or_legacy_dialog("EditCurrent") def onOverview(self) -> None: self.moveToState("overview") @@ -1296,11 +1302,7 @@ title="{}" {}>{}""".format( deck = self._selectedDeck() if not deck: return - want_old = KeyboardModifiersPressed().shift - if want_old: - aqt.dialogs.open("DeckStats", self) - else: - aqt.dialogs.open("NewDeckStats", self) + self._open_new_or_legacy_dialog("DeckStats", self) def onPrefs(self) -> None: aqt.dialogs.open("Preferences", self) diff --git a/qt/aqt/mediasrv.py b/qt/aqt/mediasrv.py index 052dac291..4328b1189 100644 --- a/qt/aqt/mediasrv.py +++ b/qt/aqt/mediasrv.py @@ -608,10 +608,10 @@ def editor_op_changes_request(endpoint: str) -> bytes: response.ParseFromString(output) def handle_on_main() -> None: - from aqt.editor import Editor + from aqt.editor import NewEditor handler = aqt.mw.app.activeWindow() - if handler and isinstance(getattr(handler, "editor", None), Editor): + if handler and isinstance(getattr(handler, "editor", None), NewEditor): handler = handler.editor # type: ignore on_op_finished(aqt.mw, response, handler) @@ -808,10 +808,10 @@ def close_add_cards() -> bytes: req.ParseFromString(request.data) def handle_on_main() -> None: - from aqt.addcards import AddCards + from aqt.addcards import NewAddCards window = aqt.mw.app.activeWindow() - if isinstance(window, AddCards): + if isinstance(window, NewAddCards): window._close_if_user_wants_to_discard_changes(req.val) aqt.mw.taskman.run_on_main(lambda: QTimer.singleShot(0, handle_on_main)) @@ -820,10 +820,10 @@ def close_add_cards() -> bytes: def close_edit_current() -> bytes: def handle_on_main() -> None: - from aqt.editcurrent import EditCurrent + from aqt.editcurrent import NewEditCurrent window = aqt.mw.app.activeWindow() - if isinstance(window, EditCurrent): + if isinstance(window, NewEditCurrent): window.close() aqt.mw.taskman.run_on_main(lambda: QTimer.singleShot(0, handle_on_main)) @@ -1070,3 +1070,5 @@ def _extract_dynamic_get_request(path: str) -> DynamicRequest | None: return legacy_page_data else: return None + return None + return None diff --git a/qt/tools/genhooks_gui.py b/qt/tools/genhooks_gui.py index 33838c46b..8c3a7f994 100644 --- a/qt/tools/genhooks_gui.py +++ b/qt/tools/genhooks_gui.py @@ -1008,12 +1008,15 @@ hooks = [ ################### Hook( name="add_cards_will_show_history_menu", - args=["addcards: aqt.addcards.AddCards", "menu: QMenu"], + args=[ + "addcards: aqt.addcards.AddCards | aqt.addcards.NewAddCards", + "menu: QMenu", + ], legacy_hook="AddCards.onHistory", ), Hook( name="add_cards_did_init", - args=["addcards: aqt.addcards.AddCards"], + args=["addcards: aqt.addcards.AddCards | aqt.addcards.NewAddCards"], ), Hook( name="add_cards_did_add_note", @@ -1068,7 +1071,7 @@ hooks = [ Hook( name="addcards_did_change_note_type", args=[ - "addcards: aqt.addcards.AddCards", + "addcards: aqt.addcards.AddCards | aqt.addcards.NewAddCards", "old: anki.models.NoteType", "new: anki.models.NoteType", ], @@ -1087,20 +1090,26 @@ hooks = [ ################### Hook( name="editor_did_init_left_buttons", - args=["buttons: list[str]", "editor: aqt.editor.Editor"], + args=["buttons: list[str]", "editor: aqt.editor.Editor | aqt.editor.NewEditor"], ), Hook( name="editor_did_init_buttons", - args=["buttons: list[str]", "editor: aqt.editor.Editor"], + args=["buttons: list[str]", "editor: aqt.editor.Editor | aqt.editor.NewEditor"], ), Hook( name="editor_did_init_shortcuts", - args=["shortcuts: list[tuple]", "editor: aqt.editor.Editor"], + args=[ + "shortcuts: list[tuple]", + "editor: aqt.editor.Editor | aqt.editor.NewEditor", + ], legacy_hook="setupEditorShortcuts", ), Hook( name="editor_will_show_context_menu", - args=["editor_webview: aqt.editor.EditorWebView", "menu: QMenu"], + args=[ + "editor_webview: aqt.editor.EditorWebView | aqt.editor.NewEditorWebView", + "menu: QMenu", + ], legacy_hook="EditorWebView.contextMenuEvent", ), Hook( @@ -1121,7 +1130,7 @@ hooks = [ ), Hook( name="editor_did_load_note", - args=["editor: aqt.editor.Editor"], + args=["editor: aqt.editor.Editor | aqt.editor.NewEditor"], legacy_hook="loadNote", ), Hook( @@ -1131,7 +1140,7 @@ hooks = [ ), Hook( name="editor_will_munge_html", - args=["txt: str", "editor: aqt.editor.Editor"], + args=["txt: str", "editor: aqt.editor.Editor | aqt.editor.NewEditor"], return_type="str", doc="""Allows manipulating the text that will be saved by the editor""", ), @@ -1143,15 +1152,21 @@ hooks = [ ), Hook( name="editor_web_view_did_init", - args=["editor_web_view: aqt.editor.EditorWebView"], + args=[ + "editor_web_view: aqt.editor.EditorWebView | aqt.editor.NewEditorWebView" + ], ), Hook( name="editor_did_init", - args=["editor: aqt.editor.Editor"], + args=["editor: aqt.editor.Editor | aqt.editor.NewEditor"], ), Hook( name="editor_will_load_note", - args=["js: str", "note: anki.notes.Note", "editor: aqt.editor.Editor"], + args=[ + "js: str", + "note: anki.notes.Note", + "editor: aqt.editor.Editor | aqt.editor.NewEditor", + ], return_type="str", doc="""Allows changing the javascript commands to load note before executing it and do change in the QT editor.""", @@ -1159,7 +1174,7 @@ hooks = [ Hook( name="editor_did_paste", args=[ - "editor: aqt.editor.Editor", + "editor: aqt.editor.Editor | aqt.editor.NewEditor", "html: str", "internal: bool", "extended: bool", @@ -1170,7 +1185,7 @@ hooks = [ name="editor_will_process_mime", args=[ "mime: QMimeData", - "editor_web_view: aqt.editor.EditorWebView", + "editor_web_view: aqt.editor.EditorWebView | aqt.editor.NewEditorWebView", "internal: bool", "extended: bool", "drop_event: bool", @@ -1194,7 +1209,7 @@ hooks = [ Hook( name="editor_state_did_change", args=[ - "editor: aqt.editor.Editor", + "editor: aqt.editor.Editor | aqt.editor.NewEditor", "new_state: aqt.editor.EditorState", "old_state: aqt.editor.EditorState", ], @@ -1203,7 +1218,10 @@ hooks = [ ), Hook( name="editor_mask_editor_did_load_image", - args=["editor: aqt.editor.Editor", "path_or_nid: str | anki.notes.NoteId"], + args=[ + "editor: aqt.editor.Editor | aqt.editor.NewEditor", + "path_or_nid: str | anki.notes.NoteId", + ], doc="""Called when the image occlusion mask editor has completed loading an image.