From 99396e5811685abdee68dfea8c265d6aa407da37 Mon Sep 17 00:00:00 2001 From: Abdo Date: Mon, 9 Jun 2025 18:04:57 +0300 Subject: [PATCH] Start work on copy & paste handling --- proto/anki/frontend.proto | 17 + qt/aqt/editor.py | 52 +-- qt/aqt/mediasrv.py | 35 +- qt/aqt/utils.py | 46 +++ ts/lib/html-filter/element.ts | 8 +- ts/lib/html-filter/index.ts | 5 + ts/routes/editor/NoteEditor.svelte | 7 + .../editor/rich-text-input/data-transfer.ts | 346 ++++++++++++++++++ .../rich-text-input/rich-text-resolve.ts | 22 +- 9 files changed, 496 insertions(+), 42 deletions(-) create mode 100644 ts/routes/editor/rich-text-input/data-transfer.ts diff --git a/proto/anki/frontend.proto b/proto/anki/frontend.proto index 94c96351f..1036e6b26 100644 --- a/proto/anki/frontend.proto +++ b/proto/anki/frontend.proto @@ -31,6 +31,9 @@ service FrontendService { // Editor rpc editorUpdateNote(notes.UpdateNotesRequest) returns (generic.Empty); + rpc convertPastedImage(ConvertPastedImageRequest) + returns (ConvertPastedImageResponse); + rpc retrieveUrl(generic.String) returns (RetrieveUrlResponse); // Profile config rpc GetProfileConfigJson(generic.String) returns (generic.Json); @@ -53,6 +56,20 @@ message SetSchedulingStatesRequest { scheduler.SchedulingStates states = 2; } +message ConvertPastedImageRequest { + bytes data = 1; + string ext = 2; +} + +message ConvertPastedImageResponse { + bytes data = 1; +} + +message RetrieveUrlResponse { + string filename = 1; + string error = 2; +} + message SetSettingJsonRequest { string key = 1; bytes value_json = 2; diff --git a/qt/aqt/editor.py b/qt/aqt/editor.py index c0f6f1a61..6b3cbc3bd 100644 --- a/qt/aqt/editor.py +++ b/qt/aqt/editor.py @@ -31,7 +31,6 @@ import aqt.sound from anki._legacy import deprecated from anki.cards import Card from anki.collection import Config -from anki.consts import MODEL_CLOZE from anki.hooks import runFilter from anki.httpclient import HttpClient from anki.models import NotetypeDict, NotetypeId, StockNotetype @@ -173,6 +172,7 @@ class Editor: def setupWeb(self) -> None: 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] = [] @@ -1124,37 +1124,37 @@ class EditorWebView(AnkiWebView): def onMiddleClickPaste(self) -> None: self._onPaste(QClipboard.Mode.Selection) - def dragEnterEvent(self, evt: QDragEnterEvent | None) -> None: - assert evt is not None - evt.accept() + # 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 + # 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 + # 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())) + # 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 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 + # if not html: + # return - self.editor.doDrop(html, internal, extended, cursor_pos) + # self.editor.doDrop(html, internal, extended, cursor_pos) # returns (html, isInternal) def _processMime( diff --git a/qt/aqt/mediasrv.py b/qt/aqt/mediasrv.py index b263cd1b3..c32e55685 100644 --- a/qt/aqt/mediasrv.py +++ b/qt/aqt/mediasrv.py @@ -16,7 +16,7 @@ from collections.abc import Callable from dataclasses import dataclass from errno import EPROTOTYPE from http import HTTPStatus -from typing import Any +from typing import Any, cast import flask import flask_cors @@ -665,6 +665,34 @@ 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() + + +def retrieve_url() -> bytes: + from aqt.utils import retrieve_url + + req = generic_pb2.String() + req.ParseFromString(request.data) + url = req.val + filename, error = retrieve_url(url) + return frontend_pb2.RetrieveUrlResponse( + filename=filename, error=error + ).SerializeToString() + + post_handler_list = [ congrats_info, get_deck_configs_for_update, @@ -686,6 +714,8 @@ post_handler_list = [ get_meta_json, set_meta_json, get_config_json, + convert_pasted_image, + retrieve_url, ] @@ -739,6 +769,9 @@ exposed_backend_list = [ "decode_iri_paths", # ConfigService "set_config_json", + "get_config_bool", + # MediaService + "add_media_file", ] diff --git a/qt/aqt/utils.py b/qt/aqt/utils.py index 6ae8bace8..44e2557c5 100644 --- a/qt/aqt/utils.py +++ b/qt/aqt/utils.py @@ -9,16 +9,19 @@ import re import shutil import subprocess import sys +import urllib from collections.abc import Callable, Sequence from functools import partial, wraps from pathlib import Path from typing import TYPE_CHECKING, Any, Literal, Union +import requests from send2trash import send2trash import aqt from anki._legacy import DeprecatedNamesMixinForModule from anki.collection import Collection, HelpPage +from anki.httpclient import HttpClient from anki.lang import TR, tr_legacyglobal # pylint: disable=unused-import from anki.utils import ( call, @@ -134,6 +137,49 @@ def openLink(link: str | QUrl) -> None: QDesktopServices.openUrl(QUrl(link)) +def retrieve_url(url: str) -> tuple[str, str]: + "Download file into media folder and return local filename or None." + + local = url.lower().startswith("file://") + 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 "", error_msg + 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 "", 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 = aqt.mw.col.media.add_extension_based_on_mime(fname, content_type) + + return aqt.mw.col.media.write_data(fname, filecontents), "" + + class MessageBox(QMessageBox): def __init__( self, diff --git a/ts/lib/html-filter/element.ts b/ts/lib/html-filter/element.ts index b3ee90753..391fd22b8 100644 --- a/ts/lib/html-filter/element.ts +++ b/ts/lib/html-filter/element.ts @@ -34,6 +34,12 @@ const allow = (attrs: string[]): FilterMethod => (element: Element): void => element, ); +function convertToDiv(element: Element): void { + const div = document.createElement("div"); + div.innerHTML = element.innerHTML; + element.replaceWith(div); +} + function unwrapElement(element: Element): void { element.replaceWith(...element.childNodes); } @@ -50,7 +56,7 @@ const tagsAllowedBasic: TagsAllowed = { BR: allowNone, IMG: allow(["SRC", "ALT"]), DIV: allowNone, - P: allowNone, + P: convertToDiv, SUB: allowNone, SUP: allowNone, TITLE: removeElement, diff --git a/ts/lib/html-filter/index.ts b/ts/lib/html-filter/index.ts index fee6e607d..83812af0b 100644 --- a/ts/lib/html-filter/index.ts +++ b/ts/lib/html-filter/index.ts @@ -33,6 +33,11 @@ const outputHTMLProcessors: Record string> = }; export function filterHTML(html: string, internal: boolean, extended: boolean): string { + // https://anki.tenderapp.com/discussions/ankidesktop/39543-anki-is-replacing-the-character-by-when-i-exit-the-html-edit-mode-ctrlshiftx + if (html.indexOf(">") < 0) { + return html; + } + const template = document.createElement("template"); template.innerHTML = html; diff --git a/ts/routes/editor/NoteEditor.svelte b/ts/routes/editor/NoteEditor.svelte index 00a0a13a2..309642dce 100644 --- a/ts/routes/editor/NoteEditor.svelte +++ b/ts/routes/editor/NoteEditor.svelte @@ -861,6 +861,13 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html } + + +