Server: Editing notes with image insertion basically works

This commit is contained in:
Kovid Goyal 2023-10-21 12:02:45 +05:30
parent a941d66031
commit ec86d24535
No known key found for this signature in database
GPG Key ID: 06BC317B515ACE7C
3 changed files with 104 additions and 32 deletions

View File

@ -4,11 +4,14 @@
__license__ = 'GPL v3' __license__ = 'GPL v3'
__copyright__ = '2015, Kovid Goyal <kovid at kovidgoyal.net>' __copyright__ = '2015, Kovid Goyal <kovid at kovidgoyal.net>'
import base64
import errno import errno
import os import os
import re
from contextlib import suppress from contextlib import suppress
from functools import partial from functools import partial
from io import BytesIO from io import BytesIO
from json import load as load_json_file
from threading import Lock from threading import Lock
from calibre import fit_image, guess_type, sanitize_file_name from calibre import fit_image, guess_type, sanitize_file_name
@ -22,7 +25,7 @@ from calibre.ebooks.metadata import authors_to_string
from calibre.ebooks.metadata.meta import set_metadata from calibre.ebooks.metadata.meta import set_metadata
from calibre.ebooks.metadata.opf2 import metadata_to_opf from calibre.ebooks.metadata.opf2 import metadata_to_opf
from calibre.library.save_to_disk import find_plugboard from calibre.library.save_to_disk import find_plugboard
from calibre.srv.errors import BookNotFound, HTTPNotFound from calibre.srv.errors import BookNotFound, HTTPBadRequest, HTTPNotFound
from calibre.srv.routes import endpoint, json from calibre.srv.routes import endpoint, json
from calibre.srv.utils import get_db, get_use_roman, http_date from calibre.srv.utils import get_db, get_use_roman, http_date
from calibre.utils.config_base import tweaks from calibre.utils.config_base import tweaks
@ -355,7 +358,14 @@ def get(ctx, rd, what, book_id, library_id):
raise HTTPNotFound(f'No {what.lower()} format for the book {book_id!r}') raise HTTPNotFound(f'No {what.lower()} format for the book {book_id!r}')
@endpoint('/get-note/{field}/{item_id}/{library_id=None}') def resource_hash_to_url(ctx, scheme, digest, library_id):
kw = {'scheme': scheme, 'digest': digest}
if library_id:
kw['library_id'] = library_id
return ctx.url_for('/get-note-resource', **kw)
@endpoint('/get-note/{field}/{item_id}/{library_id=None}', types={'item_id': int})
def get_note(ctx, rd, field, item_id, library_id): def get_note(ctx, rd, field, item_id, library_id):
db = get_db(ctx, rd, library_id) db = get_db(ctx, rd, library_id)
if db is None: if db is None:
@ -374,10 +384,7 @@ def get_note(ctx, rd, field, item_id, library_id):
pat = re.compile(rf'{RESOURCE_URL_SCHEME}://({{}})'.format('|'.join(map(r, resources)))) pat = re.compile(rf'{RESOURCE_URL_SCHEME}://({{}})'.format('|'.join(map(r, resources))))
def sub(m): def sub(m):
s, d = m.group(1).split('/', 1) s, d = m.group(1).split('/', 1)
kw = {'scheme': s, 'digest': d} return resource_hash_to_url(ctx, s, d, library_id)
if library_id:
kw['library_id'] = library_id
return ctx.url_for('/get-note-resource', **kw)
note_data['doc'] = pat.sub(sub, html) note_data['doc'] = pat.sub(sub, html)
rd.outheaders['Content-Type'] = 'text/html; charset=UTF-8' rd.outheaders['Content-Type'] = 'text/html; charset=UTF-8'
rd.outheaders['Last-Modified'] = http_date(note_data['mtime']) rd.outheaders['Last-Modified'] = http_date(note_data['mtime'])
@ -398,3 +405,50 @@ def get_note_resource(ctx, rd, scheme, digest, library_id):
fname_for_content_disposition(name), fname_for_content_disposition(name, as_encoded_unicode=True)) fname_for_content_disposition(name), fname_for_content_disposition(name, as_encoded_unicode=True))
rd.outheaders['Last-Modified'] = http_date(d['mtime']) rd.outheaders['Last-Modified'] = http_date(d['mtime'])
return d['data'] return d['data']
@endpoint('/set-note/{field}/{item_id}/{library_id=None}', needs_db_write=True, methods={'POST'}, types={'item_id': int})
def set_note(ctx, rd, field, item_id, library_id):
db = get_db(ctx, rd, library_id)
if db is None:
raise HTTPNotFound(f'Library {library_id} not found')
try:
data = load_json_file(rd.request_body_file)
if not isinstance(data, dict):
raise Exception('note data must be a dict')
html, searchable_text, images = data['html'], data['searchable_text'], data['images']
except Exception as err:
raise HTTPBadRequest(f'Invalid query: {err}')
srv_replacements = {}
db_replacements = {}
resources = []
res_pat = re.compile(r'get-note-resource/([a-zA-Z0-9]+)/([a-zA-Z0-9]+)')
for key, img in images.items():
try:
is_new_image = img['data'].startswith('data:')
if is_new_image:
d = img['data'].encode('ascii')
idx = d.index(b',')
d = memoryview(d)[idx:]
img_data = base64.standard_b64decode(d)
fname = img['filename']
else:
m = res_pat.search(img['data'])
scheme, digest = m.group(1), m.group(2)
resources.append(f'{scheme}:{digest}')
except Exception as err:
raise HTTPBadRequest(f'Invalid query: {err}')
if is_new_image:
chash = db.add_notes_resource(img_data, fname)
scheme, digest = chash.split(':', 1)
resources.append(chash)
srv_replacements[key] = resource_hash_to_url(ctx, scheme, digest, library_id)
db_replacements[key] = f'{RESOURCE_URL_SCHEME}://{scheme}/{digest}'
db_html = srv_html = html
if db_replacements:
db_html = re.sub('|'.join(map(re.escape, db_replacements)), lambda m: db_replacements[m.group()], html)
if srv_replacements:
srv_html = re.sub('|'.join(map(re.escape, srv_replacements)), lambda m: srv_replacements[m.group()], html)
db.set_notes_for(field, item_id, db_html, searchable_text, resources, True)
rd.outheaders['Content-Type'] = 'text/html; charset=UTF-8'
return srv_html

View File

@ -419,21 +419,16 @@ class CommentsEditorBoss:
document.body.lastChild.innerHTML = data.html document.body.lastChild.innerHTML = data.html
def get_html(self, data): def get_html(self, data):
c = document.body.lastChild c = document.body.lastChild.cloneNode(True)
images = v'{}' images = v'{}'
for img in c.getElementByTagName('img'): if data.extract_images:
if img.src and img.src.startsWith('data:'): for img in c.getElementsByTagName('img'):
key = short_uuid4() if img.src:
images[key] = {'data': img.src, 'filename': img.dataset.filename} key = short_uuid4()
img.src = key images[key] = {'data': img.src, 'filename': img.dataset.filename}
v'delete img.dataset.filename' img.src = key
self.comm.send_message('html', html=c.innerHTML, extra_data={'images': images}) v'delete img.dataset.filename'
for img in c.getElementByTagName('img'): self.comm.send_message('html', html=c.innerHTML, extra_data={'images': images, 'text': c.innerText})
if img.src:
d = images[img.src]
if d:
img.src = d.data
img.dataset.filename = d.filename
def exec_command(self, data): def exec_command(self, data):
document.execCommand(data.name, False, data.value) document.execCommand(data.name, False, data.value)
@ -469,6 +464,7 @@ class Editor:
self.id = iframe.id self.id = iframe.id
self.ready = False self.ready = False
self.pending_set_html = None self.pending_set_html = None
self.pending_get_html = v'[]'
self.get_html_callbacks = v'[]' self.get_html_callbacks = v'[]'
self.iframe_obj = iframe self.iframe_obj = iframe
@ -496,8 +492,9 @@ class Editor:
if self.pending_set_html is not None: if self.pending_set_html is not None:
self.set_html(self.pending_set_html) self.set_html(self.pending_set_html)
self.pending_set_html = None self.pending_set_html = None
if self.get_html_callbacks.length: if self.pending_get_html.length > 0:
self.get_html(self.get_html_callback) for x in self.pending_get_html:
self.get_html(x.proceed, x.extract_images)
def set_html(self, html): def set_html(self, html):
if not self.ready: if not self.ready:
@ -506,15 +503,17 @@ class Editor:
rgba = get_color_as_rgba('window-foreground') rgba = get_color_as_rgba('window-foreground')
self.iframe_wrapper.send_message('set_html', html=html, color_scheme=color_scheme(), color=f'rgba({rgba[0]},{rgba[1]},{rgba[2]},{rgba[3]})') self.iframe_wrapper.send_message('set_html', html=html, color_scheme=color_scheme(), color=f'rgba({rgba[0]},{rgba[1]},{rgba[2]},{rgba[3]})')
def get_html(self, proceed): def get_html(self, proceed, extract_images):
self.get_html_callbacks.push(proceed) self.get_html_callbacks.push(proceed)
if self.ready: if self.ready:
self.iframe_wrapper.send_message('get_html') self.iframe_wrapper.send_message('get_html', extract_images=v'!!extract_images')
else:
self.pending_get_html.push({'proceed': proceed, 'extract_images': v'!!extract_images'})
def on_html_received(self, data): def on_html_received(self, data):
if self.get_html_callbacks.length: if self.get_html_callbacks.length:
for f in self.get_html_callbacks: for f in self.get_html_callbacks:
f(data.html, data.img_urls) f(data.html, data.extra_data)
self.get_html_callbacks = v'[]' self.get_html_callbacks = v'[]'
def exec_command(self, name, value=None): def exec_command(self, name, value=None):
@ -607,11 +606,11 @@ def set_comments_html(container, html):
editor.set_html(html or '') editor.set_html(html or '')
def get_comments_html(container, proceed): def get_comments_html(container, proceed, extract_images):
iframe = container.querySelector('iframe') iframe = container.querySelector('iframe')
eid = iframe.getAttribute('id') eid = iframe.getAttribute('id')
editor = registry[eid] editor = registry[eid]
editor.get_html(proceed) editor.get_html(proceed, extract_images)
def develop(container): def develop(container):

View File

@ -13,6 +13,7 @@ from book_list.router import back, home, show_note
from book_list.top_bar import add_button, create_top_bar from book_list.top_bar import add_button, create_top_bar
from book_list.ui import set_panel_handler from book_list.ui import set_panel_handler
from gettext import gettext as _ from gettext import gettext as _
from modals import ajax_send_progress_dialog, error_dialog
from utils import parse_url_params, safe_set_inner_html, sandboxed_html from utils import parse_url_params, safe_set_inner_html, sandboxed_html
@ -144,11 +145,29 @@ def save(container_id):
c = document.getElementById(container_id) c = document.getElementById(container_id)
if not c: if not c:
return return
get_comments_html(c, def(html, extra_data): q = parse_url_params()
print(html) url = 'set-note/' + encodeURIComponent(q.field) + '/' + encodeURIComponent(q.item_id)
console.log(extra_data) if q.library_id:
print('TODO: Implement saving') url += '/' + encodeURIComponent(q.library_id)
) close_action = get_close_action()[0]
def on_complete(end_type, xhr, evt):
nonlocal current_note_markup
if end_type is 'abort':
close_action()
return
if end_type is not 'load':
error_dialog(_('Failed to send notes to server'), _(
'Updating the notes for: {} failed.').format(q.item), xhr.error_html)
return
q.html = xhr.responseText
current_note_markup = q
close_action()
def on_got_html(html, extra_data):
ajax_send_progress_dialog(url, {'html': html, 'searchable_text': extra_data.text, 'images': extra_data.images}, on_complete, _('Sending note data to calibre server...'))
get_comments_html(c, on_got_html, True)
def init_edit(container_id): def init_edit(container_id):