diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 2c5502f64..2f7580621 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -41,11 +41,17 @@ module.exports = { parser: "svelte-eslint-parser", parserOptions: { parser: "@typescript-eslint/parser", + svelteFeatures: { + experimentalGenerics: true, + }, }, rules: { "svelte/no-at-html-tags": "off", "svelte/valid-compile": ["error", { "ignoreWarnings": true }], "@typescript-eslint/no-explicit-any": "off", + "prefer-const": "off", + // TODO: enable this when we update to eslint-plugin-svelte 3 + // "svelte/prefer-const": "warn", }, }, ], diff --git a/build/configure/src/aqt.rs b/build/configure/src/aqt.rs index 83be77e91..912a1360c 100644 --- a/build/configure/src/aqt.rs +++ b/build/configure/src/aqt.rs @@ -191,7 +191,12 @@ fn build_js(build: &mut Build) -> Result<()> { }, )?; let files_from_ts = build.inputs_with_suffix( - inputs![":ts:editor", ":ts:reviewer:reviewer.js", ":ts:mathjax"], + inputs![ + ":ts:editor", + ":ts:editable", + ":ts:reviewer:reviewer.js", + ":ts:mathjax" + ], ".js", ); build.add_action( diff --git a/build/configure/src/web.rs b/build/configure/src/web.rs index ef2d268bb..0d952dcc6 100644 --- a/build/configure/src/web.rs +++ b/build/configure/src/web.rs @@ -170,7 +170,7 @@ fn declare_and_check_other_libraries(build: &mut Build) -> Result<()> { "components", inputs![":ts:lib", ":ts:sveltelib", glob!("ts/components/**")], ), - ("html-filter", inputs![glob!("ts/html-filter/**")]), + ("html-filter", inputs![glob!("ts/lib/html-filter/**")]), ] { let library_with_ts = format!("ts:{library}"); build.add_dependency(&library_with_ts, inputs.clone()); @@ -187,7 +187,7 @@ fn build_and_check_pages(build: &mut Build) -> Result<()> { let entrypoint = if html { format!("ts/routes/{name}/index.ts") } else { - format!("ts/{name}/index.ts") + format!("ts/lib/{name}/index.ts") }; build.add_action( &group, @@ -203,12 +203,11 @@ fn build_and_check_pages(build: &mut Build) -> Result<()> { Ok(()) }; - // we use the generated .css file separately + // we use the generated .css file separately in the legacy editor build_page( "editable", false, inputs![ - // ":ts:lib", ":ts:components", ":ts:domlib", @@ -220,21 +219,15 @@ fn build_and_check_pages(build: &mut Build) -> Result<()> { build_page( "congrats", true, - inputs![ - // - ":ts:lib", - ":ts:components", - ":sass", - ":sveltekit" - ], + inputs![":ts:lib", ":ts:components", ":sass", ":sveltekit"], )?; Ok(()) } +/// Only used for the legacy editor page. fn build_and_check_editor(build: &mut Build) -> Result<()> { let editor_deps = inputs![ - // ":ts:lib", ":ts:components", ":ts:domlib", @@ -242,14 +235,14 @@ fn build_and_check_editor(build: &mut Build) -> Result<()> { ":ts:html-filter", ":sass", ":sveltekit", - glob!("ts/{editable,editor,routes/image-occlusion}/**") + glob!("ts/lib/editable,routes/{editor,image-occlusion}/**") ]; build.add_action( "ts:editor", EsbuildScript { script: "ts/bundle_svelte.mjs".into(), - entrypoint: "ts/editor/index.ts".into(), + entrypoint: "ts/routes/editor/index.ts".into(), output_stem: "ts/editor/editor", deps: editor_deps.clone(), extra_exts: &["css"], diff --git a/proto/anki/frontend.proto b/proto/anki/frontend.proto index 95b929c5c..3a54774ee 100644 --- a/proto/anki/frontend.proto +++ b/proto/anki/frontend.proto @@ -10,6 +10,9 @@ package anki.frontend; import "anki/scheduler.proto"; import "anki/generic.proto"; import "anki/search.proto"; +import "anki/notes.proto"; +import "anki/notetypes.proto"; +import "anki/links.proto"; service FrontendService { // Returns values from the reviewer @@ -27,6 +30,34 @@ service FrontendService { rpc deckOptionsRequireClose(generic.Empty) returns (generic.Empty); // Warns python that the deck option web view is ready to receive requests. rpc deckOptionsReady(generic.Empty) returns (generic.Empty); + + // Editor + rpc UpdateEditorNote(notes.UpdateNotesRequest) returns (generic.Empty); + rpc UpdateEditorNotetype(notetypes.Notetype) returns (generic.Empty); + rpc AddEditorNote(notes.AddNoteRequest) returns (notes.AddNoteResponse); + rpc ConvertPastedImage(ConvertPastedImageRequest) + returns (ConvertPastedImageResponse); + rpc OpenFilePicker(openFilePickerRequest) returns (generic.String); + rpc OpenMedia(generic.String) returns (generic.Empty); + rpc ShowInMediaFolder(generic.String) returns (generic.Empty); + rpc RecordAudio(generic.Empty) returns (generic.String); + rpc CloseAddCards(generic.Bool) returns (generic.Empty); + rpc CloseEditCurrent(generic.Empty) returns (generic.Empty); + rpc OpenLink(generic.String) returns (generic.Empty); + rpc AskUser(AskUserRequest) returns (generic.Bool); + rpc ShowMessageBox(ShowMessageBoxRequest) returns (generic.Empty); + + // Profile config + rpc GetProfileConfigJson(generic.String) returns (generic.Json); + rpc SetProfileConfigJson(SetSettingJsonRequest) returns (generic.Empty); + + // Metadata + rpc GetMetaJson(generic.String) returns (generic.Json); + rpc SetMetaJson(SetSettingJsonRequest) returns (generic.Empty); + + // Clipboard + rpc ReadClipboard(ReadClipboardRequest) returns (ReadClipboardResponse); + rpc WriteClipboard(WriteClipboardRequest) returns (generic.Empty); } service BackendFrontendService {} @@ -40,3 +71,64 @@ message SetSchedulingStatesRequest { string key = 1; scheduler.SchedulingStates states = 2; } + +message ConvertPastedImageRequest { + bytes data = 1; + string ext = 2; +} + +message ConvertPastedImageResponse { + bytes data = 1; +} + +message SetSettingJsonRequest { + string key = 1; + bytes value_json = 2; +} + +message openFilePickerRequest { + string title = 1; + string key = 2; + string filter_description = 3; + repeated string extensions = 4; +} + +message ReadClipboardRequest { + repeated string types = 1; +} + +message ReadClipboardResponse { + map data = 1; +} + +message WriteClipboardRequest { + map data = 1; +} + +message Help { + oneof value { + links.HelpPageLinkRequest.HelpPage help_page = 1; + string help_link = 2; + } +} + +message AskUserRequest { + string text = 1; + optional Help help = 2; + optional string title = 4; + optional bool default_no = 5; +} + +enum MessageBoxType { + INFO = 0; + WARNING = 1; + CRITICAL = 2; +} + +message ShowMessageBoxRequest { + string text = 1; + MessageBoxType type = 2; + optional Help help = 3; + optional string title = 4; + optional string text_format = 5; +} diff --git a/proto/anki/media.proto b/proto/anki/media.proto index 76d42931a..58f481379 100644 --- a/proto/anki/media.proto +++ b/proto/anki/media.proto @@ -13,16 +13,21 @@ import "anki/notetypes.proto"; service MediaService { rpc CheckMedia(generic.Empty) returns (CheckMediaResponse); rpc AddMediaFile(AddMediaFileRequest) returns (generic.String); + rpc AddMediaFromPath(AddMediaFromPathRequest) returns (generic.String); rpc TrashMediaFiles(TrashMediaFilesRequest) returns (generic.Empty); rpc EmptyTrash(generic.Empty) returns (generic.Empty); rpc RestoreTrash(generic.Empty) returns (generic.Empty); rpc ExtractStaticMediaFiles(notetypes.NotetypeId) returns (generic.StringList); + rpc ExtractMediaFiles(generic.String) returns (generic.StringList); + rpc GetAbsoluteMediaPath(generic.String) returns (generic.String); } // Implicitly includes any of the above methods that are not listed in the // backend service. -service BackendMediaService {} +service BackendMediaService { + rpc AddMediaFromUrl(AddMediaFromUrlRequest) returns (AddMediaFromUrlResponse); +} message CheckMediaResponse { repeated string unused = 1; @@ -40,3 +45,16 @@ message AddMediaFileRequest { string desired_name = 1; bytes data = 2; } + +message AddMediaFromPathRequest { + string path = 1; +} + +message AddMediaFromUrlRequest { + string url = 1; +} + +message AddMediaFromUrlResponse { + optional string filename = 1; + optional string error = 2; +} 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 a27d86234..12bdf3d12 100644 --- a/qt/aqt/addcards.py +++ b/qt/aqt/addcards.py @@ -5,40 +5,33 @@ 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, OpChangesWithCount, 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 anki.notes import Note +from anki.utils import 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.addcards_legacy import * 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: +class NewAddCards(QMainWindow): + def __init__( + self, + mw: AnkiQt, + deck_id: DeckId | None = None, + notetype_id: NotetypeId | None = None, + ) -> None: super().__init__(None, Qt.WindowType.Window) self._close_event_has_cleaned_up = False + self._close_callback: Callable[[], None] = self._close self.mw = mw self.col = mw.col form = aqt.forms.addcards.Ui_Dialog() @@ -47,297 +40,52 @@ class AddCards(QMainWindow): 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) + self._load_new_note(deck_id, notetype_id) 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) + self.editor.load_note( + mid=note.mid, + original_note_id=note.id, + focus_to=0, + ) def setupEditor(self) -> None: - self.editor = aqt.editor.Editor( + self.editor = aqt.editor.NewEditor( 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 reopen( + self, + mw: AnkiQt, + deck_id: DeckId | None = None, + notetype_id: NotetypeId | None = None, + ) -> None: + self.editor.reload_note_if_empty(deck_id, notetype_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 + def _load_new_note( + self, deck_id: DeckId | None = None, notetype_id: NotetypeId | None = None ) -> 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) + self.editor.load_note( + mid=notetype_id, + deck_id=deck_id, + focus_to=0, ) - 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: OpChangesWithCount) -> None: - # only used for detecting changed sticky fields on close - self._last_added_note = note - - self.addHistory(note) - - tooltip(tr.importing_cards_added(count=changes.count), 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: @@ -354,35 +102,34 @@ class AddCards(QMainWindow): 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") + aqt.dialogs.markClosed("NewAddCards") self._close_event_has_cleaned_up = True self.mw.deferred_delete_and_garbage_collect(self) self.close() def ifCanClose(self, onOk: Callable) -> None: + self._close_callback = onOk + self.editor.web.eval("closeAddCards()") + + def _close_if_user_wants_to_discard_changes(self, prompt: bool) -> None: + if not prompt: + self._close_callback() + return + def callback(choice: int) -> None: if choice == 0: - onOk() + self._close_callback() - 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) + ask_user_dialog( + tr.adding_discard_current_input(), + callback=callback, + buttons=[ + QMessageBox.StandardButton.Discard, + (tr.adding_keep_editing(), QMessageBox.ButtonRole.RejectRole), + ], + ) def closeWithCallback(self, cb: Callable[[], None]) -> None: def doClose() -> None: @@ -390,25 +137,3 @@ class AddCards(QMainWindow): 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/addcards_legacy.py b/qt/aqt/addcards_legacy.py new file mode 100644 index 000000000..a27d86234 --- /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, OpChangesWithCount, 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: OpChangesWithCount) -> None: + # only used for detecting changed sticky fields on close + self._last_added_note = note + + self.addHistory(note) + + tooltip(tr.importing_cards_added(count=changes.count), 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 e222f62c2..0d4b1f2e9 100644 --- a/qt/aqt/browser/browser.py +++ b/qt/aqt/browser/browser.py @@ -8,7 +8,7 @@ import json import math import re from collections.abc import Callable, Sequence -from typing import Any, cast +from typing import Any from markdown import markdown @@ -22,7 +22,7 @@ from anki.cards import Card, CardId from anki.collection import Collection, Config, OpChanges, SearchNode from anki.consts import * from anki.decks import DeckId -from anki.errors import NotFoundError, SearchError +from anki.errors import SearchError from anki.lang import without_unicode_isolation from anki.models import NotetypeId from anki.notes import NoteId @@ -30,7 +30,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 @@ -80,7 +79,6 @@ from aqt.utils import ( tr, ) -from ..addcards import AddCards from ..changenotetype import change_notetype_dialog from .card_info import BrowserCardInfo from .find_and_replace import FindAndReplaceDialog @@ -114,7 +112,7 @@ class MockModel: class Browser(QMainWindow): mw: AnkiQt col: Collection - editor: Editor | None + editor: aqt.editor.NewEditor | None table: Table def __init__( @@ -192,15 +190,7 @@ class Browser(QMainWindow): # fixme: this will leave the splitter shown, but with no current # note being edited assert self.editor is not None - - note = self.editor.note - if note: - try: - note.load() - except NotFoundError: - self.editor.set_note(None) - return - self.editor.set_note(note) + self.editor.reload_note() if changes.browser_table and changes.card: self.card = self.table.get_single_selected_card() @@ -278,11 +268,10 @@ class Browser(QMainWindow): return None def add_card(self, deck_id: DeckId): - add_cards = cast(AddCards, aqt.dialogs.open("AddCards", self.mw)) - add_cards.set_deck(deck_id) - + args = [self.mw, deck_id] if note_type_id := self.get_active_note_type_id(): - add_cards.set_note_type(note_type_id) + args.append(note_type_id) + aqt.dialogs.open("NewAddCards", *args) # If in the Browser we open Preview and press Ctrl+W there, # both Preview and Browser windows get closed by Qt out of the box. @@ -403,7 +392,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 @@ -605,17 +594,19 @@ 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, editor_mode=aqt.editor.EditorMode.BROWSER, ) - gui_hooks.editor_did_init.remove(add_preview_button) @ensure_editor_saved def on_all_or_selected_rows_changed(self) -> None: @@ -819,7 +810,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 @@ -843,7 +834,7 @@ class Browser(QMainWindow): if self._previewer: self._previewer.close() - elif self.editor.note: + else: self._previewer = PreviewDialog(self, self.mw, self._on_preview_closed) self._previewer.open() self.toggle_preview_button_state(True) @@ -1265,7 +1256,7 @@ class Browser(QMainWindow): def cb(): assert self.editor is not None and self.editor.web is not None self.editor.web.setFocus() - self.editor.loadNote(focusTo=0) + self.editor.reload_note() assert self.editor is not None self.editor.call_after_note_saved(cb) diff --git a/qt/aqt/editcurrent.py b/qt/aqt/editcurrent.py index 6ec4938b7..1b42c5d83 100644 --- a/qt/aqt/editcurrent.py +++ b/qt/aqt/editcurrent.py @@ -6,13 +6,13 @@ 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.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 @@ -23,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, @@ -33,13 +33,7 @@ class EditCurrent(QMainWindow): self.editor.card = self.mw.reviewer.card self.editor.set_note(self.mw.reviewer.card.note(), focusTo=0) restoreGeom(self, "editcurrent") - close_button = self.form.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() @@ -47,24 +41,13 @@ class EditCurrent(QMainWindow): 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) + self.editor.reload_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") + 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..b74c0801a --- /dev/null +++ b/qt/aqt/editcurrent_legacy.py @@ -0,0 +1,93 @@ +# 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 diff --git a/qt/aqt/editor.py b/qt/aqt/editor.py index f2f267097..d6e0562e4 100644 --- a/qt/aqt/editor.py +++ b/qt/aqt/editor.py @@ -5,109 +5,55 @@ 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 dataclasses import dataclass from random import randrange -from typing import Any, Iterable, Match, cast +from typing import Any -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.decks import DeckId 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 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.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.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", -) + +def on_editor_ready(func: Callable) -> Callable: + @functools.wraps(func) + def decorated(self: NewEditor, *args: Any, **kwargs: Any) -> None: + if self._ready: + func(self, *args, **kwargs) + else: + self._ready_callbacks.append(lambda: func(self, *args, **kwargs)) + + return decorated -class EditorMode(Enum): - ADD_CARDS = 0 - EDIT_CURRENT = 1 - BROWSER = 2 +@dataclass +class NoteInfo: + "Used to hold partial note info fetched from the webview" + + id: NoteId | None + mid: NotetypeId + fields: list[str] + + def __post_init__(self) -> None: + if self.id is not None: + self.id = NoteId(int(self.id)) + if self.mid is not None: + self.mid = NotetypeId(int(self.mid)) -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: +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. @@ -129,7 +75,7 @@ class Editor: self.mw = mw self.widget = widget self.parentWindow = parentWindow - self.note: Note | None = None + self.nid: NoteId | None = None # legacy argument provided? if addMode is not None: editor_mode = EditorMode.ADD_CARDS if addMode else EditorMode.EDIT_CURRENT @@ -139,19 +85,16 @@ class Editor: # 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._ready = False + self._ready_callbacks: list[Callable[[], None]] = [] self._init_links() self.setupOuter() self.add_webview() self.setupWeb() self.setupShortcuts() - gui_hooks.editor_did_init(self) # Initial setup ############################################################ @@ -164,32 +107,17 @@ 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) 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() + editor_key = self.mw.pm.editor_key(self.editorMode) + self.web.load_sveltekit_page(f"editor/?mode={editor_key}") + self.web.allow_drops = True + def _set_ready(self) -> None: lefttopbtns: list[str] = [] gui_hooks.editor_did_init_left_buttons(lefttopbtns, self) @@ -218,6 +146,10 @@ require("anki/ui").loaded.then(() => require("anki/NoteEditor").instances[0].too ) self.web.eval(f"{lefttopbtns_js} {righttopbtns_js}") + gui_hooks.editor_did_init(self) + self._ready = True + for cb in self._ready_callbacks: + cb() # Top buttons ###################################################################### @@ -236,7 +168,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, @@ -247,7 +179,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 @@ -363,7 +295,12 @@ require("anki/ui").loaded.then(() => require("anki/NoteEditor").instances[0].too def _onFields(self) -> None: from aqt.fields import FieldDialog - FieldDialog(self.mw, self.note_type(), parent=self.parentWindow) + def on_note_info(note_info: NoteInfo) -> None: + note_type = self.mw.col.models.get(note_info.mid) + assert note_type is not None + FieldDialog(self.mw, note_type, parent=self.parentWindow) + + self.get_note_info(on_note_info) def onCardLayout(self) -> None: self.call_after_note_saved(self._onCardLayout) @@ -376,121 +313,43 @@ require("anki/ui").loaded.then(() => require("anki/NoteEditor").instances[0].too 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() + def on_note_info(note_info: NoteInfo) -> None: + if note_info.id: + note = self.mw.col.get_note(note_info.id) + else: + note = Note(self.mw.col, note_info.mid) + note.fields = note_info.fields + CardLayout( + self.mw, + note, + ord=ord, + parent=self.parentWindow, + fill_empty=False, + ) + if is_win: + self.parentWindow.activateWindow() + + self.get_note_info(on_note_info) # 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() + (type, _) = cmd.split(":", 1) 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() + pass # 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) + pass elif cmd.startswith("editorState"): (_, new_state_id, old_state_id) = cmd.split(":", 2) @@ -507,6 +366,8 @@ require("anki/ui").loaded.then(() => require("anki/NoteEditor").instances[0].too gui_hooks.editor_mask_editor_did_load_image( self, NoteId(int(path_or_nid)) ) + elif cmd == "editorReady": + self._set_ready() elif cmd in self._links: return self._links[cmd](self) @@ -514,9 +375,6 @@ require("anki/ui").loaded.then(() => require("anki/NoteEditor").instances[0].too 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: @@ -526,6 +384,17 @@ require("anki/ui").loaded.then(() => require("anki/NoteEditor").instances[0].too # Setting/unsetting the current note ###################################################################### + def set_nid( + self, + nid: NoteId | None, + mid: int, + focus_to: int | None = None, + ) -> None: + "Make note with ID `nid` the current note." + self.nid = nid + self.currentField = None + self.load_note(mid, focus_to=focus_to) + def set_note( self, note: Note | None, @@ -533,116 +402,62 @@ require("anki/ui").loaded.then(() => require("anki/NoteEditor").instances[0].too focusTo: int | None = None, ) -> None: "Make NOTE the current note." - self.note = note self.currentField = None - if self.note: - self.loadNote(focusTo=focusTo) + if note: + self.nid = note.id + self.load_note(mid=note.mid, focus_to=focusTo) elif hide: self.widget.hide() - def loadNoteKeepingFocus(self) -> None: - self.loadNote(self.currentField) - - 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"]} - + @on_editor_ready + def load_note( + self, + mid: int | None = None, + deck_id: DeckId | None = None, + original_note_id: NoteId | None = None, + focus_to: int | None = None, + ) -> None: self.widget.show() - note_fields_status = self.note.fields_check() - def oncallback(arg: Any) -> None: - if not self.note: + if not self.nid: 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: + if focus_to is not None: self.web.setFocus() 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) + load_args = dict( + nid=self.nid, + notetypeId=mid, + focusTo=focus_to, + originalNoteId=original_note_id, + reviewerCardId=self.mw.reviewer.card.id if self.mw.reviewer.card else None, + deckId=deck_id, + initial=True, + ) + js = f"loadNote({json.dumps(load_args)});" 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 + def reload_note(self) -> None: + self.web.eval("reloadNote();") - update_note(parent=self.widget, note=self.note).run_in_background( - initiator=self + def reload_note_if_empty( + self, deck_id: DeckId | None = None, notetype_id: NotetypeId | None = None + ) -> None: + self.web.eval( + f"reloadNoteIfEmpty({json.dumps(deck_id)}, {json.dumps(notetype_id)});" ) - 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: + if not self.nid: # calling code may not expect the callback to fire immediately self.mw.progress.single_shot(10, callback) return @@ -650,70 +465,6 @@ require("anki/ui").loaded.then(() => require("anki/NoteEditor").instances[0].too 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) @@ -722,1072 +473,99 @@ require("anki/ui").loaded.then(() => require("anki/NoteEditor").instances[0].too 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() + def onCut(self) -> None: + self.web.onCut() + + def onCopy(self) -> None: + self.web.onCopy() # 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 - ) + self.setup_mask_editor_for_new_note(image_path=image_path) else: - assert self.note is not None - self.setup_mask_editor_for_existing_note( - note_id=self.note.id, image_path=image_path - ) + self.setup_mask_editor_for_existing_note(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, - ): + def setup_mask_editor_for_new_note(self, image_path: str): """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)})" + f"setupMaskEditorForNewNote({json.dumps(image_path)})" "); " ) - @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) + def setup_mask_editor_for_existing_note(self, 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: + image_path: (Optional) Absolute path to image that should replace current + image + """ + self.web.eval( + 'require("anki/ui").loaded.then(() =>' + f"setupMaskEditorForExistingNote({json.dumps(image_path)})" + "); " ) - 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, + fields=NewEditor.onFields, + cards=NewEditor.onCardLayout, + paste=NewEditor.onPaste, + cut=NewEditor.onCut, + copy=NewEditor.onCopy, ) - 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 + def get_note_info(self, on_done: Callable[[NoteInfo], None]) -> None: + def wrapped_on_done(note_info: dict[str, Any]) -> None: + on_done(NoteInfo(**note_info)) + + self.web.evalWithCallback("getNoteInfo()", wrapped_on_done) # Pasting, drag & drop, and keyboard layouts ###################################################################### -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) - 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) + self.settings().setAttribute( # type: ignore + QWebEngineSettings.WebAttribute.JavascriptCanPaste, True + ) + self.settings().setAttribute( # type: ignore + QWebEngineSettings.WebAttribute.JavascriptCanAccessClipboard, True + ) 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) -gui_hooks.editor_will_munge_html.append(remove_null_bytes) -gui_hooks.editor_will_munge_html.append(reverse_url_quoting) - - -def set_cloze_button(editor: Editor) -> None: - action = "show" if editor.note_type()["type"] == MODEL_CLOZE else "hide" - editor.web.eval( - 'require("anki/ui").loaded.then(() =>' - f'require("anki/NoteEditor").instances[0].toolbar.toolbar.{action}("cloze")' - "); " - ) - - -def set_image_occlusion_button(editor: Editor) -> None: - action = "show" if editor.current_notetype_is_image_occlusion() else "hide" - editor.web.eval( - 'require("anki/ui").loaded.then(() =>' - f'require("anki/NoteEditor").instances[0].toolbar.toolbar.{action}("image-occlusion-button")' - "); " - ) - - -gui_hooks.editor_did_load_note.append(set_cloze_button) -gui_hooks.editor_did_load_note.append(set_image_occlusion_button) + 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..3f196584e --- /dev/null +++ b/qt/aqt/editor_legacy.py @@ -0,0 +1,1789 @@ +# 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}', true)") + 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 + + field_names = self.note.keys() + field_values = [ + self.mw.col.media.escape_media_filenames(val) for val in self.note.values() + ] + + 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(field_names)}, {json.dumps(field_values)}); + 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 diff --git a/qt/aqt/forms/editcurrent.ui b/qt/aqt/forms/editcurrent.ui index 14ecd21fa..d2102e306 100644 --- a/qt/aqt/forms/editcurrent.ui +++ b/qt/aqt/forms/editcurrent.ui @@ -28,16 +28,6 @@ - - - - Qt::Horizontal - - - QDialogButtonBox::Close - - - @@ -60,22 +50,4 @@ - - - buttonBox - rejected() - Dialog - close() - - - 316 - 260 - - - 286 - 274 - - - - 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 820e762d9..911d5056e 100644 --- a/qt/aqt/mediasrv.py +++ b/qt/aqt/mediasrv.py @@ -3,6 +3,7 @@ from __future__ import annotations +import asyncio import enum import logging import mimetypes @@ -16,6 +17,7 @@ from collections.abc import Callable from dataclasses import dataclass from errno import EPROTOTYPE from http import HTTPStatus +from typing import Any, Generic, cast import flask import flask_cors @@ -27,18 +29,25 @@ from waitress.server import create_server import aqt import aqt.main import aqt.operations -from anki import hooks +from anki import frontend_pb2, generic_pb2, hooks from anki.collection import OpChanges, OpChangesOnly, Progress, SearchNode from anki.decks import UpdateDeckConfigs from anki.scheduler.v3 import SchedulingStatesWithContext, SetSchedulingStatesRequest -from anki.utils import dev_mode +from anki.utils import dev_mode, from_json_bytes, to_json_bytes from aqt.changenotetype import ChangeNotetypeDialog from aqt.deckoptions import DeckOptionsDialog from aqt.operations import on_op_finished from aqt.operations.deck import update_deck_configs as update_deck_configs_op from aqt.progress import ProgressUpdate from aqt.qt import * -from aqt.utils import aqt_data_path, show_warning, tr +from aqt.utils import ( + aqt_data_path, + askUser, + openLink, + show_info, + show_warning, + tr, +) # https://forums.ankiweb.net/t/anki-crash-when-using-a-specific-deck/22266 waitress.wasyncore._DISCONNECTED = waitress.wasyncore._DISCONNECTED.union({EPROTOTYPE}) # type: ignore @@ -334,6 +343,7 @@ def is_sveltekit_page(path: str) -> bool: "import-csv", "import-page", "image-occlusion", + "editor", ] @@ -599,6 +609,304 @@ def deck_options_ready() -> bytes: return b"" +def editor_op_changes_request(endpoint: str) -> bytes: + output = raw_backend_request(endpoint)() + response = OpChanges() + response.ParseFromString(output) + + def handle_on_main() -> None: + from aqt.editor import NewEditor + + handler = aqt.mw.app.activeWindow() + if handler and isinstance(getattr(handler, "editor", None), NewEditor): + handler = handler.editor # type: ignore + on_op_finished(aqt.mw, response, handler) + + aqt.mw.taskman.run_on_main(handle_on_main) + + return output + + +def update_editor_note() -> bytes: + return editor_op_changes_request("update_notes") + + +def update_editor_notetype() -> bytes: + return editor_op_changes_request("update_notetype") + + +def add_editor_note() -> bytes: + return editor_op_changes_request("add_note") + + +def get_setting_json(getter: Callable[[str], Any]) -> bytes: + req = generic_pb2.String() + req.ParseFromString(request.data) + value = getter(req.val) + output = generic_pb2.Json(json=to_json_bytes(value)).SerializeToString() + return output + + +def set_setting_json(setter: Callable[[str, Any], Any]) -> bytes: + req = frontend_pb2.SetSettingJsonRequest() + req.ParseFromString(request.data) + setter(req.key, from_json_bytes(req.value_json)) + return b"" + + +def get_profile_config_json() -> bytes: + assert aqt.mw.pm.profile is not None + return get_setting_json(aqt.mw.pm.profile.get) + + +def set_profile_config_json() -> bytes: + assert aqt.mw.pm.profile is not None + return set_setting_json(aqt.mw.pm.profile.__setitem__) + + +def get_meta_json() -> bytes: + return get_setting_json(aqt.mw.pm.meta.get) + + +def set_meta_json() -> bytes: + return set_setting_json(aqt.mw.pm.meta.__setitem__) + + +def get_config_json() -> bytes: + try: + return get_setting_json(aqt.mw.col.conf.get_immutable) + except KeyError: + return generic_pb2.Json(json=b"null").SerializeToString() + + +def set_config_json() -> bytes: + return set_setting_json(aqt.mw.col.set_config) + + +def convert_pasted_image() -> bytes: + req = frontend_pb2.ConvertPastedImageRequest() + req.ParseFromString(request.data) + image = QImage.fromData(req.data) + buffer = QBuffer() + buffer.open(QBuffer.OpenModeFlag.ReadWrite) + if req.ext == "png": + quality = 50 + else: + quality = 80 + image.save(buffer, req.ext, quality) + buffer.reset() + data = bytes(cast(bytes, buffer.readAll())) + return frontend_pb2.ConvertPastedImageResponse(data=data).SerializeToString() + + +AsyncRequestReturnType = TypeVar("AsyncRequestReturnType") + + +class AsyncRequestHandler(Generic[AsyncRequestReturnType]): + def __init__(self, callback: Callable[[AsyncRequestHandler], None]) -> None: + self.callback = callback + self.loop = asyncio.get_event_loop() + self.future = self.loop.create_future() + + def run(self) -> None: + aqt.mw.taskman.run_on_main(lambda: self.callback(self)) + + def set_result(self, result: AsyncRequestReturnType) -> None: + self.loop.call_soon_threadsafe(self.future.set_result, result) + + async def get_result(self) -> AsyncRequestReturnType: + return await self.future + + +async def open_file_picker() -> bytes: + req = frontend_pb2.openFilePickerRequest() + req.ParseFromString(request.data) + + def callback(request_handler: AsyncRequestHandler) -> None: + from aqt.utils import getFile + + def cb(filename: str | None) -> None: + request_handler.set_result(filename) + + window = aqt.mw.app.activeWindow() + assert window is not None + getFile( + parent=window, + title=req.title, + cb=cast(Callable[[Any], None], cb), + filter=f"{req.filter_description} ({' '.join(f'*.{ext}' for ext in req.extensions)})", + key=req.key, + ) + + request_handler: AsyncRequestHandler[str | None] = AsyncRequestHandler(callback) + request_handler.run() + filename = await request_handler.get_result() + + return generic_pb2.String(val=filename if filename else "").SerializeToString() + + +def open_media() -> bytes: + from aqt.utils import openFolder + + req = generic_pb2.String() + req.ParseFromString(request.data) + path = os.path.join(aqt.mw.col.media.dir(), req.val) + aqt.mw.taskman.run_on_main(lambda: openFolder(path)) + + return b"" + + +def show_in_media_folder() -> bytes: + from aqt.utils import show_in_folder + + req = generic_pb2.String() + req.ParseFromString(request.data) + path = os.path.join(aqt.mw.col.media.dir(), req.val) + aqt.mw.taskman.run_on_main(lambda: show_in_folder(path)) + + return b"" + + +async def record_audio() -> bytes: + def callback(request_handler: AsyncRequestHandler) -> None: + from aqt.sound import record_audio + + def cb(path: str | None) -> None: + request_handler.set_result(path) + + window = aqt.mw.app.activeWindow() + assert window is not None + record_audio(window, aqt.mw, True, cb) + + request_handler: AsyncRequestHandler[str | None] = AsyncRequestHandler(callback) + request_handler.run() + path = await request_handler.get_result() + + return generic_pb2.String(val=path if path else "").SerializeToString() + + +def read_clipboard() -> bytes: + req = frontend_pb2.ReadClipboardRequest() + req.ParseFromString(request.data) + data = {} + clipboard = aqt.mw.app.clipboard() + assert clipboard is not None + mime_data = clipboard.mimeData(QClipboard.Mode.Clipboard) + assert mime_data is not None + for type in req.types: + data[type] = bytes(mime_data.data(type)) # type: ignore + + return frontend_pb2.ReadClipboardResponse(data=data).SerializeToString() + + +def write_clipboard() -> bytes: + req = frontend_pb2.WriteClipboardRequest() + req.ParseFromString(request.data) + clipboard = aqt.mw.app.clipboard() + assert clipboard is not None + mime_data = clipboard.mimeData(QClipboard.Mode.Clipboard) + assert mime_data is not None + for type, data in req.data.items(): + mime_data.setData(type, data) + return b"" + + +def close_add_cards() -> bytes: + req = generic_pb2.Bool() + req.ParseFromString(request.data) + + def handle_on_main() -> None: + from aqt.addcards import NewAddCards + + window = aqt.mw.app.activeWindow() + 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)) + return b"" + + +def close_edit_current() -> bytes: + def handle_on_main() -> None: + from aqt.editcurrent import NewEditCurrent + + window = aqt.mw.app.activeWindow() + if isinstance(window, NewEditCurrent): + window.close() + + aqt.mw.taskman.run_on_main(lambda: QTimer.singleShot(0, handle_on_main)) + return b"" + + +def open_link() -> bytes: + req = generic_pb2.String() + req.ParseFromString(request.data) + url = req.val + aqt.mw.taskman.run_on_main(lambda: openLink(url)) + return b"" + + +async def ask_user() -> bytes: + req = frontend_pb2.AskUserRequest() + req.ParseFromString(request.data) + + def callback(request_handler: AsyncRequestHandler) -> None: + kwargs: dict[str, Any] = dict(text=req.text) + if req.HasField("help"): + help_arg: Any + if req.help.WhichOneof("value") == "help_page": + help_arg = req.help.help_page + else: + help_arg = req.help.help_link + kwargs["help"] = help_arg + if req.HasField("title"): + kwargs["title"] = req.title + if req.HasField("default_no"): + kwargs["defaultno"] = req.default_no + answer = askUser(**kwargs) + request_handler.set_result(answer) + + request_handler: AsyncRequestHandler[bool] = AsyncRequestHandler(callback) + request_handler.run() + answer = await request_handler.get_result() + + return generic_pb2.Bool(val=answer).SerializeToString() + + +async def show_message_box() -> bytes: + req = frontend_pb2.ShowMessageBoxRequest() + req.ParseFromString(request.data) + + def callback(request_handler: AsyncRequestHandler) -> None: + kwargs: dict[str, Any] = dict(text=req.text) + if req.type == frontend_pb2.MessageBoxType.INFO: + icon = QMessageBox.Icon.Information + elif req.type == frontend_pb2.MessageBoxType.WARNING: + icon = QMessageBox.Icon.Warning + elif req.type == frontend_pb2.MessageBoxType.CRITICAL: + icon = QMessageBox.Icon.Critical + kwargs["icon"] = icon + if req.HasField("help"): + help_arg: Any + if req.help.WhichOneof("value") == "help_page": + help_arg = req.help.help_page + else: + help_arg = req.help.help_link + kwargs["help"] = help_arg + if req.HasField("title"): + kwargs["title"] = req.title + if req.HasField("text_format"): + kwargs["text_format"] = req.text_format + show_info(**kwargs) + request_handler.set_result(True) + + request_handler: AsyncRequestHandler[bool] = AsyncRequestHandler(callback) + request_handler.run() + answer = await request_handler.get_result() + + return generic_pb2.Bool(val=answer).SerializeToString() + + post_handler_list = [ congrats_info, get_deck_configs_for_update, @@ -614,6 +922,26 @@ post_handler_list = [ search_in_browser, deck_options_require_close, deck_options_ready, + update_editor_note, + update_editor_notetype, + add_editor_note, + get_profile_config_json, + set_profile_config_json, + get_meta_json, + set_meta_json, + get_config_json, + convert_pasted_image, + open_file_picker, + open_media, + show_in_media_folder, + record_audio, + read_clipboard, + write_clipboard, + close_add_cards, + close_edit_current, + open_link, + ask_user, + show_message_box, ] @@ -630,9 +958,15 @@ exposed_backend_list = [ # NotesService "get_field_names", "get_note", + "new_note", + "note_fields_check", + "defaults_for_adding", + "default_deck_for_notetype", # NotetypesService + "get_notetype", "get_notetype_names", "get_change_notetype_info", + "get_cloze_field_ords", # StatsService "card_stats", "get_review_logs", @@ -658,6 +992,21 @@ exposed_backend_list = [ # DeckConfigService "get_ignored_before_count", "get_retention_workload", + # CardRenderingService + "encode_iri_paths", + "decode_iri_paths", + "html_to_text_line", + # ConfigService + "set_config_json", + "get_config_bool", + # MediaService + "add_media_file", + "add_media_from_path", + "add_media_from_url", + "get_absolute_media_path", + "extract_media_files", + # CardsService + "get_card", ] @@ -686,7 +1035,25 @@ def _extract_collection_post_request(path: str) -> DynamicRequest | NotFound: # convert bytes/None into response def wrapped() -> Response: try: - if data := handler(): + import inspect + + if inspect.iscoroutinefunction(handler): + try: + loop = asyncio.get_event_loop() + if loop.is_running(): + import concurrent.futures + + with concurrent.futures.ThreadPoolExecutor() as executor: + future = executor.submit(asyncio.run, handler()) + data = future.result() + else: + data = loop.run_until_complete(handler()) + except RuntimeError: + data = asyncio.run(handler()) + else: + result = handler() + data = result + if data: response = flask.make_response(data) response.headers["Content-Type"] = "application/binary" else: diff --git a/qt/aqt/reviewer.py b/qt/aqt/reviewer.py index a8839c598..efa113729 100644 --- a/qt/aqt/reviewer.py +++ b/qt/aqt/reviewer.py @@ -1167,7 +1167,7 @@ timerStopped = false; def on_create_copy(self) -> None: if self.card: - aqt.dialogs.open("AddCards", self.mw).set_note( + aqt.dialogs.open("NewAddCards", self.mw).set_note( self.card.note(), self.card.current_deck_id() ) diff --git a/qt/pyproject.toml b/qt/pyproject.toml index 35ecaa200..ebe57bffb 100644 --- a/qt/pyproject.toml +++ b/qt/pyproject.toml @@ -5,7 +5,7 @@ requires-python = ">=3.9" license = "AGPL-3.0-or-later" dependencies = [ "beautifulsoup4", - "flask", + "flask[async]", "flask_cors", "jsonschema", "requests", 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. diff --git a/rslib/src/backend/media.rs b/rslib/src/backend/media.rs new file mode 100644 index 000000000..8b2f6a394 --- /dev/null +++ b/rslib/src/backend/media.rs @@ -0,0 +1,40 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use anki_proto::media::AddMediaFromUrlRequest; +use anki_proto::media::AddMediaFromUrlResponse; + +use crate::backend::Backend; +use crate::editor::retrieve_url; +use crate::error; + +impl crate::services::BackendMediaService for Backend { + fn add_media_from_url( + &self, + input: AddMediaFromUrlRequest, + ) -> error::Result { + let rt = self.runtime_handle(); + let mut guard = self.col.lock().unwrap(); + let col = guard.as_mut().unwrap(); + let media = col.media()?; + let fut = async move { + let response = match retrieve_url(&input.url).await { + Ok((filename, data)) => { + media + .add_file(&filename, &data) + .map(|fname| fname.to_string())?; + AddMediaFromUrlResponse { + filename: Some(filename), + error: None, + } + } + Err(e) => AddMediaFromUrlResponse { + filename: None, + error: Some(e.message(col.tr())), + }, + }; + Ok(response) + }; + rt.block_on(fut) + } +} diff --git a/rslib/src/backend/mod.rs b/rslib/src/backend/mod.rs index d15652675..ce4e72683 100644 --- a/rslib/src/backend/mod.rs +++ b/rslib/src/backend/mod.rs @@ -12,6 +12,7 @@ pub(crate) mod dbproxy; mod error; mod i18n; mod import_export; +mod media; mod ops; mod sync; diff --git a/rslib/src/editor.rs b/rslib/src/editor.rs new file mode 100644 index 000000000..4503a28c9 --- /dev/null +++ b/rslib/src/editor.rs @@ -0,0 +1,117 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use std::path::Path; +use std::time::Duration; + +use percent_encoding_iri::percent_decode_str; +use reqwest::Client; +use reqwest::Url; + +use crate::error::AnkiError; +use crate::error::Result; +use crate::invalid_input; + +/// Download file from URL. +/// Returns (filename, file_contents) tuple. +pub async fn retrieve_url(url: &str) -> Result<(String, Vec)> { + let is_local = url.to_lowercase().starts_with("file://"); + let (file_contents, content_type) = if is_local { + download_local_file(url).await? + } else { + download_remote_file(url).await? + }; + + let mut parsed_url = match Url::parse(url) { + Ok(url) => url, + Err(e) => invalid_input!("Invalid URL: {}", e), + }; + parsed_url.set_query(None); + let mut filename = parsed_url + .path_segments() + .and_then(|mut segments| segments.next_back()) + .unwrap_or("") + .to_string(); + + filename = match percent_decode_str(&filename).decode_utf8() { + Ok(decoded) => decoded.to_string(), + Err(e) => invalid_input!("Failed to decode filename: {}", e), + }; + + if filename.trim().is_empty() { + filename = "paste".to_string(); + } + + if let Some(mime_type) = content_type { + filename = add_extension_based_on_mime(&filename, &mime_type); + } + + Ok((filename.to_string(), file_contents)) +} + +async fn download_local_file(url: &str) -> Result<(Vec, Option)> { + let decoded_url = match percent_decode_str(url).decode_utf8() { + Ok(url) => url, + Err(e) => invalid_input!("Failed to decode file URL: {}", e), + }; + + let parsed_url = match Url::parse(&decoded_url) { + Ok(url) => url, + Err(e) => invalid_input!("Invalid file URL: {}", e), + }; + + let file_path = match parsed_url.to_file_path() { + Ok(path) => path, + Err(_) => invalid_input!("Invalid file path in URL"), + }; + + let file_contents = std::fs::read(&file_path).map_err(|e| AnkiError::FileIoError { + source: anki_io::FileIoError { + path: file_path.clone(), + op: anki_io::FileOp::Read, + source: e, + }, + })?; + + Ok((file_contents, None)) +} + +async fn download_remote_file(url: &str) -> Result<(Vec, Option)> { + let client = Client::builder() + .timeout(Duration::from_secs(30)) + .user_agent("Mozilla/5.0 (compatible; Anki)") + .build()?; + + let response = client.get(url).send().await?.error_for_status()?; + let content_type = response + .headers() + .get("content-type") + .and_then(|ct| ct.to_str().ok()) + .map(|s| s.to_string()); + + let file_contents = response.bytes().await?.to_vec(); + + Ok((file_contents, content_type)) +} + +fn add_extension_based_on_mime(filename: &str, content_type: &str) -> String { + let mut extension = ""; + if Path::new(filename).extension().is_none() { + extension = match content_type { + "audio/mpeg" => ".mp3", + "audio/ogg" => ".oga", + "audio/opus" => ".opus", + "audio/wav" => ".wav", + "audio/webm" => ".weba", + "audio/aac" => ".aac", + "image/jpeg" => ".jpg", + "image/png" => ".png", + "image/svg+xml" => ".svg", + "image/webp" => ".webp", + "image/avif" => ".avif", + _ => "", + }; + }; + + filename.to_string() + extension +} diff --git a/rslib/src/lib.rs b/rslib/src/lib.rs index 2258c3592..3af7e968b 100644 --- a/rslib/src/lib.rs +++ b/rslib/src/lib.rs @@ -16,6 +16,7 @@ pub mod config; pub mod dbcheck; pub mod deckconfig; pub mod decks; +pub mod editor; pub mod error; pub mod findreplace; pub mod i18n; diff --git a/rslib/src/media/service.rs b/rslib/src/media/service.rs index b2ec99d1d..b0515e65a 100644 --- a/rslib/src/media/service.rs +++ b/rslib/src/media/service.rs @@ -1,9 +1,11 @@ use std::collections::HashSet; +use std::path::Path; // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use anki_proto::generic; use anki_proto::media::AddMediaFileRequest; +use anki_proto::media::AddMediaFromPathRequest; use anki_proto::media::CheckMediaResponse; use anki_proto::media::TrashMediaFilesRequest; @@ -12,6 +14,7 @@ use crate::error; use crate::error::OrNotFound; use crate::notes::service::to_i64s; use crate::notetype::NotetypeId; +use crate::text::extract_media_refs; impl crate::services::MediaService for Collection { fn check_media(&mut self) -> error::Result { @@ -40,6 +43,19 @@ impl crate::services::MediaService for Collection { .into()) } + fn add_media_from_path( + &mut self, + input: AddMediaFromPathRequest, + ) -> error::Result { + let base_name = Path::new(&input.path) + .file_name() + .unwrap_or_default() + .to_str() + .unwrap_or_default(); + let data = std::fs::read(&input.path)?; + Ok(self.media()?.add_file(base_name, &data)?.to_string().into()) + } + fn trash_media_files(&mut self, input: TrashMediaFilesRequest) -> error::Result<()> { self.media()?.remove_files(&input.fnames) } @@ -66,4 +82,28 @@ impl crate::services::MediaService for Collection { Ok(files.into_iter().collect::>().into()) } + + fn extract_media_files( + &mut self, + html: anki_proto::generic::String, + ) -> error::Result { + let files = extract_media_refs(&html.val) + .iter() + .map(|r| r.fname_decoded.to_string()) + .collect::>(); + Ok(files.into()) + } + + fn get_absolute_media_path( + &mut self, + path: anki_proto::generic::String, + ) -> error::Result { + Ok(self + .media()? + .media_folder + .join(path.val) + .to_string_lossy() + .to_string() + .into()) + } } diff --git a/ts/editor/BrowserEditor.svelte b/ts/editor/BrowserEditor.svelte deleted file mode 100644 index 84aef7f99..000000000 --- a/ts/editor/BrowserEditor.svelte +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - - - diff --git a/ts/editor/DuplicateLink.svelte b/ts/editor/DuplicateLink.svelte deleted file mode 100644 index 3a83cb2f1..000000000 --- a/ts/editor/DuplicateLink.svelte +++ /dev/null @@ -1,25 +0,0 @@ - - - - -
bridgeCommand("dupes")}> - {tr.editingShowDuplicates()} - - - - diff --git a/ts/editor/NoteCreator.svelte b/ts/editor/NoteCreator.svelte deleted file mode 100644 index f4c205757..000000000 --- a/ts/editor/NoteCreator.svelte +++ /dev/null @@ -1,51 +0,0 @@ - - - - - - - - diff --git a/ts/editor/NoteEditor.svelte b/ts/editor/NoteEditor.svelte deleted file mode 100644 index 17ced575b..000000000 --- a/ts/editor/NoteEditor.svelte +++ /dev/null @@ -1,847 +0,0 @@ - - - - - - -
- - - - - {#if hint} - - - - - - {@html hint} - - - {/if} - - {#if imageOcclusionMode && ($ioMaskEditorVisible || imageOcclusionMode?.kind === "add")} -
- -
- {/if} - - {#if $ioMaskEditorVisible && isImageOcclusion && !isIOImageLoaded} - - {/if} - - {#if !$ioMaskEditorVisible} - - {#each fieldsData as field, index} - {@const content = fieldStores[index]} - - { - $focusedField = fields[index]; - setAddonButtonsDisabled(false); - bridgeCommand(`focus:${index}`); - }} - on:focusout={() => { - $focusedField = null; - setAddonButtonsDisabled(true); - bridgeCommand( - `blur:${index}:${getNoteId()}:${transformContentBeforeSave( - get(content), - )}`, - ); - }} - on:mouseenter={() => { - $hoveredField = fields[index]; - }} - on:mouseleave={() => { - $hoveredField = null; - }} - collapsed={fieldsCollapsed[index]} - dupe={cols[index] === "dupe"} - --description-font-size="{field.fontSize}px" - --description-content={`"${field.description}"`} - > - - toggleField(index)} - --icon-align="bottom" - > - - - {field.name} - - - - {#if cols[index] === "dupe"} - - {/if} - - {#if plainTextDefaults[index]} - toggleRichTextInput(index)} - /> - {:else} - togglePlainTextInput(index)} - /> - {/if} - - - - - - { - saveFieldNow(); - $focusedInput = null; - }} - bind:this={richTextInputs[index]} - isClozeField={field.isClozeField} - /> - - - - - { - saveFieldNow(); - $focusedInput = null; - }} - bind:this={plainTextInputs[index]} - /> - - - - {/each} - - - - - - { - updateTagsCollapsed(false); - }} - /> - updateTagsCollapsed(!$tagsCollapsed)} - > - {@html `${tagAmount > 0 ? tagAmount : ""} ${tr.editingTags()}`} - - - - - {/if} -
- - diff --git a/ts/editor/ReviewerEditor.svelte b/ts/editor/ReviewerEditor.svelte deleted file mode 100644 index 2c31b9fba..000000000 --- a/ts/editor/ReviewerEditor.svelte +++ /dev/null @@ -1,19 +0,0 @@ - - - - diff --git a/ts/lib/components/DeckChooser.svelte b/ts/lib/components/DeckChooser.svelte new file mode 100644 index 000000000..59ca750a4 --- /dev/null +++ b/ts/lib/components/DeckChooser.svelte @@ -0,0 +1,42 @@ + + + + diff --git a/ts/lib/components/HelpModal.svelte b/ts/lib/components/HelpModal.svelte index cf6292537..aa7e24232 100644 --- a/ts/lib/components/HelpModal.svelte +++ b/ts/lib/components/HelpModal.svelte @@ -6,16 +6,14 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import * as tr from "@generated/ftl"; import { renderMarkdown } from "@tslib/helpers"; import Carousel from "bootstrap/js/dist/carousel"; - import Modal from "bootstrap/js/dist/modal"; - import { createEventDispatcher, getContext, onDestroy, onMount } from "svelte"; + import { createEventDispatcher, onMount } from "svelte"; + import Modal from "./Modal.svelte"; import { infoCircle } from "$lib/components/icons"; - import { registerModalClosingHandler } from "$lib/sveltelib/modal-closing"; import { pageTheme } from "$lib/sveltelib/theme"; import Badge from "./Badge.svelte"; import Col from "./Col.svelte"; - import { modalsKey } from "./context-keys"; import HelpSection from "./HelpSection.svelte"; import Icon from "./Icon.svelte"; import Row from "./Row.svelte"; @@ -27,50 +25,20 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html export let helpSections: HelpItem[]; export let fsrs = false; - export const modalKey: string = Math.random().toString(36).substring(2); - - const modals = getContext>(modalsKey); - - let modal: Modal; let carousel: Carousel; - let modalRef: HTMLDivElement; + let modal: Modal; let carouselRef: HTMLDivElement; - function onOkClicked(): void { - modal.hide(); - } - const dispatch = createEventDispatcher(); - const { set: setModalOpen, remove: removeModalClosingHandler } = - registerModalClosingHandler(onOkClicked); - - function onShown() { - setModalOpen(true); - } - - function onHidden() { - setModalOpen(false); - } - onMount(() => { - modalRef.addEventListener("shown.bs.modal", onShown); - modalRef.addEventListener("hidden.bs.modal", onHidden); - modal = new Modal(modalRef, { keyboard: false }); carousel = new Carousel(carouselRef, { interval: false, ride: false }); /* Bootstrap's Carousel.Event interface doesn't seem to work as a type here */ carouselRef.addEventListener("slide.bs.carousel", (e: any) => { activeIndex = e.to; }); dispatch("mount", { modal: modal, carousel: carousel }); - modals.set(modalKey, modal); - }); - - onDestroy(() => { - removeModalClosingHandler(); - modalRef.removeEventListener("shown.bs.modal", onShown); - modalRef.removeEventListener("hidden.bs.modal", onHidden); }); let activeIndex = startIndex; @@ -80,119 +48,98 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -