Notes editor: When pasting HTML with images offer to download remote images in the pasted content

This commit is contained in:
Kovid Goyal 2023-11-19 21:16:31 +05:30
parent c2a2185dbe
commit dca1d9a3ac
No known key found for this signature in database
GPG Key ID: 06BC317B515ACE7C
2 changed files with 94 additions and 2 deletions

View File

@ -7,6 +7,7 @@ import re
import sys import sys
import weakref import weakref
from collections import defaultdict from collections import defaultdict
from threading import Thread
from contextlib import contextmanager from contextlib import contextmanager
from functools import partial from functools import partial
from html5_parser import parse from html5_parser import parse
@ -21,14 +22,16 @@ from qt.core import (
QVBoxLayout, QWidget, pyqtSignal, pyqtSlot, QVBoxLayout, QWidget, pyqtSignal, pyqtSlot,
) )
from calibre import fit_image, xml_replace_entities from calibre import browser, fit_image, xml_replace_entities
from calibre.db.constants import DATA_DIR_NAME from calibre.db.constants import DATA_DIR_NAME
from calibre.ebooks.chardet import xml_to_unicode from calibre.ebooks.chardet import xml_to_unicode
from calibre.gui2 import ( from calibre.gui2 import (
NO_URL_FORMATTING, choose_dir, choose_files, error_dialog, gprefs, is_dark_theme, NO_URL_FORMATTING, choose_dir, choose_files, error_dialog, gprefs, is_dark_theme,
safe_open_url, question_dialog, safe_open_url,
) )
from calibre.gui2 import FunctionDispatcher
from calibre.gui2.book_details import resolved_css from calibre.gui2.book_details import resolved_css
from calibre.gui2.dialogs.progress import ProgressDialog
from calibre.gui2.flow_toolbar import create_flow_toolbar from calibre.gui2.flow_toolbar import create_flow_toolbar
from calibre.gui2.widgets import LineEditECM from calibre.gui2.widgets import LineEditECM
from calibre.gui2.widgets2 import to_plain_text from calibre.gui2.widgets2 import to_plain_text
@ -282,6 +285,7 @@ class EditorWidget(QTextEdit, LineEditECM): # {{{
data_changed = pyqtSignal() data_changed = pyqtSignal()
insert_images_separately = False insert_images_separately = False
can_store_images = False
@property @property
def readonly(self): def readonly(self):
@ -561,9 +565,87 @@ class EditorWidget(QTextEdit, LineEditECM): # {{{
self.focus_self() self.focus_self()
def do_paste(self): def do_paste(self):
images_before = set()
for fmt in self.document().allFormats():
if fmt.isImageFormat():
images_before.add(fmt.toImageFormat().name())
self.paste() self.paste()
if self.can_store_images:
added = set()
for fmt in self.document().allFormats():
if fmt.isImageFormat():
name = fmt.toImageFormat().name()
if name not in images_before and name.partition(':')[0] in ('https', 'http'):
added.add(name)
if added and question_dialog(
self, _('Download images'), _(
'Download all remote images in the pasted content?')):
self.download_images(added)
self.focus_self() self.focus_self()
def download_images(self, urls):
from calibre.web import get_download_filename_from_response
br = browser()
d = self.document()
c = self.textCursor()
c.setPosition(0)
pos_map = {}
while True:
c = d.find(OBJECT_REPLACEMENT_CHAR, c, QTextDocument.FindFlag.FindCaseSensitively)
if c.isNull():
break
fmt = c.charFormat()
if fmt.isImageFormat():
name = fmt.toImageFormat().name()
if name in urls:
pos_map[name] = c.position()
pd = ProgressDialog(title=_('Downloading images...'), min=0, max=0, parent=self)
pd.canceled_signal.connect(pd.accept)
data_map = {}
error_map = {}
set_msg = FunctionDispatcher(pd.set_msg, parent=pd)
accept = FunctionDispatcher(pd.accept, parent=pd)
def do_download():
for url, pos in pos_map.items():
if pd.canceled:
return
set_msg(_('Downloading {}...').format(url))
try:
res = br.open_novisit(url, timeout=30)
fname = get_download_filename_from_response(res)
data = res.read()
except Exception as err:
error_map[url] = err
else:
data_map[url] = fname, data
accept()
Thread(target=do_download, daemon=True).start()
pd.exec()
if pd.canceled:
return
for url, pos in pos_map.items():
fname, data = data_map[url]
newname = self.commit_downloaded_image(data, fname)
c = self.textCursor()
c.setPosition(pos)
alignment = QTextFrameFormat.Position.InFlow
f = self.frame_for_cursor(c)
if f is not None:
alignment = f.frameFormat().position()
fmt = c.charFormat()
i = fmt.toImageFormat()
i.setName(newname)
c.deletePreviousChar()
c.insertImage(i, alignment)
if error_map:
m = '\n'.join(
_('Could not download {0} with error:').format(url) + '\n\t' + str(err) + '\n\n' for url, err in error_map.items())
error_dialog(self, _('Failed to download some images'), _(
'Some images could not be downloaded, click "Show details" to see which ones'), det_msg=m, show=True)
def do_paste_and_match_style(self): def do_paste_and_match_style(self):
text = QApplication.instance().clipboard().text() text = QApplication.instance().clipboard().text()
if text: if text:

View File

@ -202,6 +202,7 @@ class NoteEditorWidget(EditorWidget):
insert_images_separately = True insert_images_separately = True
db = field = item_id = item_val = None db = field = item_id = item_val = None
images = None images = None
can_store_images = True
def resource_digest_from_qurl(self, qurl): def resource_digest_from_qurl(self, qurl):
alg = qurl.host() alg = qurl.host()
@ -240,6 +241,15 @@ class NoteEditorWidget(EditorWidget):
self.document().addResource(rtype, qurl, r) # cache the resource self.document().addResource(rtype, qurl, r) # cache the resource
return r return r
def commit_downloaded_image(self, data, suggested_filename):
digest = hash_data(data)
if digest in self.images:
ir = self.images[digest]
else:
self.images[digest] = ir = ImageResource(suggested_filename, digest, data=data)
alg, digest = ir.digest.split(':', 1)
return RESOURCE_URL_SCHEME + f'://{alg}/{digest}?placement={uuid4()}'
def get_html_callback(self, root, text): def get_html_callback(self, root, text):
self.searchable_text = text.replace(OBJECT_REPLACEMENT_CHAR, '') self.searchable_text = text.replace(OBJECT_REPLACEMENT_CHAR, '')
self.referenced_resources = set() self.referenced_resources = set()