From 27a062cb19f98621a959c51fcb7739e565e227dc Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Thu, 25 Jan 2018 20:35:55 +0530 Subject: [PATCH] Content server: Implement deleting of books via the web interface --- src/calibre/srv/standalone.py | 6 +- src/pyj/book_list/add.pyj | 23 +++--- src/pyj/book_list/book_details.pyj | 9 ++- src/pyj/book_list/delete_book.pyj | 110 +++++++++++++++++++++++++++++ src/pyj/book_list/library_data.pyj | 10 +++ src/pyj/read_book/db.pyj | 44 ++++++++++++ 6 files changed, 191 insertions(+), 11 deletions(-) create mode 100644 src/pyj/book_list/delete_book.pyj diff --git a/src/calibre/srv/standalone.py b/src/calibre/srv/standalone.py index 2316699918..03ab6db388 100644 --- a/src/calibre/srv/standalone.py +++ b/src/calibre/srv/standalone.py @@ -11,6 +11,7 @@ import sys from calibre import as_unicode from calibre.constants import is_running_from_develop, isosx, iswindows, plugins from calibre.db.legacy import LibraryDatabase +from calibre.db.delete_service import shutdown as shutdown_delete_service from calibre.srv.bonjour import BonJour from calibre.srv.handler import Handler from calibre.srv.http_response import create_http_handler @@ -220,4 +221,7 @@ def main(args=sys.argv): # Needed for dynamic cover generation, which uses Qt for drawing from calibre.gui2 import ensure_app, load_builtin_fonts ensure_app(), load_builtin_fonts() - server.serve_forever() + try: + server.serve_forever() + finally: + shutdown_delete_service() diff --git a/src/pyj/book_list/add.pyj b/src/pyj/book_list/add.pyj index 0fab202de6..b9cad2c31d 100644 --- a/src/pyj/book_list/add.pyj +++ b/src/pyj/book_list/add.pyj @@ -82,6 +82,17 @@ def list_duplicate_book(container, container_id, job_id, data, file): container.appendChild(b) +def write_access_error(msg, xhr): + html = msg + '
' + if xhr.status is 403: + un = get_interface_data().username + if un: + html += _('You are not allowed to make changes to the library') + '
' + else: + html += _('You must be logged in to make changes to the library') + '
' + return html + '
' + xhr.error_html + + def on_complete(container_id, job_id, end_type, xhr, ev): idx = state.transfers.indexOf(xhr) if idx > -1: @@ -100,14 +111,8 @@ def on_complete(container_id, job_id, end_type, xhr, ev): elif end_type is 'abort': return else: - html = _('Failed to upload the file: {}').format(this.name) + '
' - if xhr.status is 403: - un = get_interface_data().username - if un: - html += _('You are not allowed to make changes to the library') + '
' - else: - html += _('You must be logged in to make changes to the library') + '
' - safe_set_inner_html(container, html + '
' + xhr.error_html) + html = write_access_error(_('Failed to upload the file: {}').format(this.name), xhr) + safe_set_inner_html(container, html) def fake_send(container_id, job_id): @@ -123,7 +128,7 @@ def send_file(file, container_id, job_id, add_duplicates): lid = loaded_books_query().library_id ad = 'y' if add_duplicates else 'n' xhr = ajax_send_file( - f'/cdb/add-book/{job_id}/{ad}/{encodeURIComponent(file.name)}/{lid}', + f'cdb/add-book/{job_id}/{ad}/{encodeURIComponent(file.name)}/{lid}', file, on_complete.bind(file, container_id, job_id), on_progress.bind(None, container_id, job_id)) state.transfers.push(xhr) return xhr diff --git a/src/pyj/book_list/book_details.pyj b/src/pyj/book_list/book_details.pyj index 31c96adf28..91c3d54a82 100644 --- a/src/pyj/book_list/book_details.pyj +++ b/src/pyj/book_list/book_details.pyj @@ -7,6 +7,7 @@ from elementmaker import E from gettext import gettext as _ from ajax import ajax, encode_query_component +from book_list.delete_book import start_delete_book from book_list.globals import get_session_data from book_list.item_list import create_item, create_item_list from book_list.library_data import ( @@ -15,7 +16,7 @@ from book_list.library_data import ( ) from book_list.router import back, home, open_book from book_list.theme import get_color, get_font_size -from book_list.top_bar import add_button, create_top_bar, set_title, clear_buttons +from book_list.top_bar import add_button, clear_buttons, create_top_bar, set_title from book_list.ui import query_as_href, set_panel_handler, show_panel from book_list.views import search_query_for from date import format_date @@ -415,6 +416,7 @@ def render_book(container_id, book_id): if not c: return metadata = book_metadata(book_id) + render_book.title = metadata.title set_title(c, metadata.title) authors = metadata.authors.join(' & ') if metadata.authors else _('Unknown') alt = _('{} by {}').format(metadata.title, authors) @@ -453,6 +455,7 @@ def add_top_bar_buttons(container_id): container = document.getElementById(container_id) if container: clear_buttons(container) + add_button(container, 'trash', action=delete_book, tooltip=_('Delete this book')) book_id = parse_url_params().book_id if book_id is '0': add_button(container, 'random', def(): fetch_metadata(container_id, 0);) @@ -630,6 +633,10 @@ def search_internet(container_id): )) + +def delete_book(): + start_delete_book(current_library_id(), render_book.book_id, render_book.title or _('Unknown')) + set_panel_handler('book_details', init) set_panel_handler('book_details^more_actions', create_more_actions_panel) set_panel_handler('book_details^search_internet', search_internet) diff --git a/src/pyj/book_list/delete_book.pyj b/src/pyj/book_list/delete_book.pyj new file mode 100644 index 0000000000..0a23342202 --- /dev/null +++ b/src/pyj/book_list/delete_book.pyj @@ -0,0 +1,110 @@ +# vim:fileencoding=utf-8 +# License: GPL v3 Copyright: 2018, Kovid Goyal +from __python__ import bound_methods, hash_literals + +from elementmaker import E +from gettext import gettext as _ + +from ajax import ajax +from book_list.add import write_access_error +from book_list.library_data import book_after, force_refresh_on_next_load +from book_list.router import back +from book_list.ui import show_panel +from dom import clear +from modals import create_custom_dialog +from read_book.db import get_db +from utils import safe_set_inner_html +from widgets import create_button + + +def delete_from_cache(library_id, book_id, title): + db = get_db() + db.delete_books_matching(library_id, book_id) + + +def do_delete_from_library(parent, close_modal, library_id, book_id, title): + parent.appendChild(E.div(_('Deleting {0} from library, please wait...').format(title))) + + def oncomplete(end_type, xhr, ev): + if end_type is 'load' or end_type is 'abort': + close_modal() + if end_type is 'load': + force_refresh_on_next_load() + next_book_id = book_after(book_id) + if next_book_id: + show_panel('book_details', {'book_id': str(next_book_id), 'library_id': library_id}, True) + else: + back() + return + clear(parent) + msg = E.div() + safe_set_inner_html(msg, write_access_error(_('Failed to delete {0}, with error:').format(title), xhr)) + parent.appendChild(E.div( + msg, + E.div(class_='button-box', + create_button(_('Close'), None, close_modal, True) + ) + )) + + ajax(f'cdb/delete-books/{book_id}/{library_id}', oncomplete, method='POST').send() + + +def confirm_delete_from_library(library_id, book_id, title): + create_custom_dialog(_('Are you sure?'), def(parent, close_modal): + + def action(doit): + if doit: + clear(parent) + do_delete_from_library(parent, close_modal, library_id, book_id, title) + else: + close_modal() + + msg = _('This will permanently delete {0} from your calibre library. Are you sure?').format(title) + m = E.div() + safe_set_inner_html(m, msg) + parent.appendChild(E.div( + m, + E.div(class_='button-box', + create_button(_('OK'), None, action.bind(None, True)), + '\xa0', + create_button(_('Cancel'), None, action.bind(None, False), highlight=True), + ) + )) + ) + + +def choose_which_delete(library_id, book_id, title): + + create_custom_dialog(_('Delete from?'), def(parent, close_modal): + + def action(from_cache, from_library): + close_modal() + if from_cache: + delete_from_cache(library_id, book_id, title) + if from_library: + confirm_delete_from_library(library_id, book_id, title) + + + parent.appendChild(E.div( + E.div(_('{0} is available both in the browser cache for offline reading and in' + ' your calibre library. Where do you want to delete it from?').format(title)), + E.div(class_='button-box', + create_button(_('Cache'), None, action.bind(None, True, False)), + '\xa0', + create_button(_('Library and cache'), None, action.bind(None, True, True)), + ) + )) + ) + + +def delete_book_stage2(library_id, book_id, title, has_offline_copies): + if has_offline_copies: + choose_which_delete(library_id, book_id, title) + else: + confirm_delete_from_library(library_id, book_id, title) + + +def start_delete_book(library_id, book_id, title): + book_id = int(book_id) + db = get_db() + db.has_book_matching(library_id, book_id, delete_book_stage2.bind(None, library_id, book_id, title)) diff --git a/src/pyj/book_list/library_data.pyj b/src/pyj/book_list/library_data.pyj index e1eb06052b..f25e956be1 100644 --- a/src/pyj/book_list/library_data.pyj +++ b/src/pyj/book_list/library_data.pyj @@ -97,6 +97,16 @@ def current_book_ids(): return library_data.previous_book_ids.concat(library_data.search_result.book_ids) +def book_after(book_id): + ids = current_book_ids() + idx = ids.indexOf(int(book_id)) + if idx > -1: + if idx < ids.length - 1: + return ids[idx + 1] + if idx > 0: # wrap around + return ids[0] + + def on_data_loaded(end_type, xhr, ev): load_status.current_fetch = None def bad_load(msg): diff --git a/src/pyj/read_book/db.pyj b/src/pyj/read_book/db.pyj index 631f2274b8..3d50b6dafd 100644 --- a/src/pyj/read_book/db.pyj +++ b/src/pyj/read_book/db.pyj @@ -348,6 +348,25 @@ class DB: if cursor: cursor.continue() + def has_book_matching(self, library_id, book_id, proceed): + # Should really be using a multiEntry index to avoid iterating over all + # books in JS, but since the number of stored books is not that large, + # I can't be bothered. + c = self.idb.transaction(['books'], 'readonly').objectStore('books').index('recent_index').openCursor(None, 'prev') + c.onerror = def(event): + proceed(False) + book_id = int(book_id) + c.onsuccess = def (ev): + cursor = ev.target.result + if cursor: + book = cursor.value + if book.key[0] is library_id and book.key[1] is book_id: + proceed(True) + return + cursor.continue() + else: + proceed(False) + def delete_book(self, book, proceed): c = self.idb.transaction(['books', 'files'], 'readwrite') files = c.objectStore('files') @@ -366,6 +385,31 @@ class DB: books.delete(book.key) next_step() + def delete_books_matching(self, library_id, book_id, proceed): + c = self.idb.transaction(['books'], 'readonly').objectStore('books').index('recent_index').openCursor(None, 'prev') + c.onerror = def(event): + pass + book_id = int(book_id) + matches = v'[]' + + def delete_all(): + if matches.length: + book = matches.pop() + self.delete_book(book, delete_all) + else: + if proceed: + proceed() + + c.onsuccess = def (ev): + cursor = ev.target.result + if cursor: + book = cursor.value + if book.key[0] is library_id and book.key[1] is book_id: + matches.push(book) + cursor.continue() + else: + delete_all() + def get_db(callback, show_read_book_error): if not get_db.ans: