diff --git a/src/calibre/srv/content.py b/src/calibre/srv/content.py index 909d64ac93..6509a56b25 100644 --- a/src/calibre/srv/content.py +++ b/src/calibre/srv/content.py @@ -4,11 +4,14 @@ __license__ = 'GPL v3' __copyright__ = '2015, Kovid Goyal ' +import base64 import errno import os +import re from contextlib import suppress from functools import partial from io import BytesIO +from json import load as load_json_file from threading import Lock 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.opf2 import metadata_to_opf 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.utils import get_db, get_use_roman, http_date 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}') -@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): db = get_db(ctx, rd, library_id) 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)))) def sub(m): s, d = m.group(1).split('/', 1) - kw = {'scheme': s, 'digest': d} - if library_id: - kw['library_id'] = library_id - return ctx.url_for('/get-note-resource', **kw) + return resource_hash_to_url(ctx, s, d, library_id) note_data['doc'] = pat.sub(sub, html) rd.outheaders['Content-Type'] = 'text/html; charset=UTF-8' 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)) rd.outheaders['Last-Modified'] = http_date(d['mtime']) 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 diff --git a/src/pyj/book_list/comments_editor.pyj b/src/pyj/book_list/comments_editor.pyj index cbcc5b52b3..99c4e24ba3 100644 --- a/src/pyj/book_list/comments_editor.pyj +++ b/src/pyj/book_list/comments_editor.pyj @@ -419,21 +419,16 @@ class CommentsEditorBoss: document.body.lastChild.innerHTML = data.html def get_html(self, data): - c = document.body.lastChild + c = document.body.lastChild.cloneNode(True) images = v'{}' - for img in c.getElementByTagName('img'): - if img.src and img.src.startsWith('data:'): - key = short_uuid4() - images[key] = {'data': img.src, 'filename': img.dataset.filename} - img.src = key - v'delete img.dataset.filename' - self.comm.send_message('html', html=c.innerHTML, extra_data={'images': images}) - for img in c.getElementByTagName('img'): - if img.src: - d = images[img.src] - if d: - img.src = d.data - img.dataset.filename = d.filename + if data.extract_images: + for img in c.getElementsByTagName('img'): + if img.src: + key = short_uuid4() + images[key] = {'data': img.src, 'filename': img.dataset.filename} + img.src = key + v'delete img.dataset.filename' + self.comm.send_message('html', html=c.innerHTML, extra_data={'images': images, 'text': c.innerText}) def exec_command(self, data): document.execCommand(data.name, False, data.value) @@ -469,6 +464,7 @@ class Editor: self.id = iframe.id self.ready = False self.pending_set_html = None + self.pending_get_html = v'[]' self.get_html_callbacks = v'[]' self.iframe_obj = iframe @@ -496,8 +492,9 @@ class Editor: if self.pending_set_html is not None: self.set_html(self.pending_set_html) self.pending_set_html = None - if self.get_html_callbacks.length: - self.get_html(self.get_html_callback) + if self.pending_get_html.length > 0: + for x in self.pending_get_html: + self.get_html(x.proceed, x.extract_images) def set_html(self, html): if not self.ready: @@ -506,15 +503,17 @@ class Editor: 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]})') - def get_html(self, proceed): + def get_html(self, proceed, extract_images): self.get_html_callbacks.push(proceed) 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): if self.get_html_callbacks.length: for f in self.get_html_callbacks: - f(data.html, data.img_urls) + f(data.html, data.extra_data) self.get_html_callbacks = v'[]' def exec_command(self, name, value=None): @@ -607,11 +606,11 @@ def set_comments_html(container, html): editor.set_html(html or '') -def get_comments_html(container, proceed): +def get_comments_html(container, proceed, extract_images): iframe = container.querySelector('iframe') eid = iframe.getAttribute('id') editor = registry[eid] - editor.get_html(proceed) + editor.get_html(proceed, extract_images) def develop(container): diff --git a/src/pyj/book_list/show_note.pyj b/src/pyj/book_list/show_note.pyj index b8f91ed495..3866c5f45e 100644 --- a/src/pyj/book_list/show_note.pyj +++ b/src/pyj/book_list/show_note.pyj @@ -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.ui import set_panel_handler 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 @@ -144,11 +145,29 @@ def save(container_id): c = document.getElementById(container_id) if not c: return - get_comments_html(c, def(html, extra_data): - print(html) - console.log(extra_data) - print('TODO: Implement saving') - ) + q = parse_url_params() + url = 'set-note/' + encodeURIComponent(q.field) + '/' + encodeURIComponent(q.item_id) + if q.library_id: + 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):