diff --git a/src/calibre/srv/cdb.py b/src/calibre/srv/cdb.py index 81d86d46d1..54251bf769 100644 --- a/src/calibre/srv/cdb.py +++ b/src/calibre/srv/cdb.py @@ -124,24 +124,28 @@ def cdb_set_cover(ctx, rd, book_id, library_id): return tuple(dirtied) +def load_payload_data(rd): + raw = rd.read() + ct = rd.inheaders.get('Content-Type', all=True) + ct = {x.lower().partition(';')[0] for x in ct} + try: + if MSGPACK_MIME in ct: + return msgpack_loads(raw) + elif 'application/json' in ct: + return json_loads(raw) + else: + raise HTTPBadRequest('Only JSON or msgpack requests are supported') + except Exception: + raise HTTPBadRequest('Invalid encoded data') + + @endpoint('/cdb/set-fields/{book_id}/{library_id=None}', types={'book_id': int}, needs_db_write=True, postprocess=msgpack_or_json, methods=receive_data_methods, cache_control='no-cache') def cdb_set_fields(ctx, rd, book_id, library_id): db = get_db(ctx, rd, library_id) if ctx.restriction_for(rd, db): raise HTTPForbidden('Cannot use the set fields interface with a user who has per library restrictions') - raw = rd.read() - ct = rd.inheaders.get('Content-Type', all=True) - ct = {x.lower().partition(';')[0] for x in ct} - try: - if MSGPACK_MIME in ct: - data = msgpack_loads(raw) - elif 'application/json' in ct: - data = json_loads(raw) - else: - raise HTTPBadRequest('Only JSON or msgpack requests are supported') - except Exception: - raise HTTPBadRequest('Invalid encoded data') + data = load_payload_data(rd) try: changes, loaded_book_ids = data['changes'], frozenset(map(int, data.get('loaded_book_ids', ()))) all_dirtied = bool(data.get('all_dirtied')) @@ -172,3 +176,47 @@ def cdb_set_fields(ctx, rd, book_id, library_id): all_ids = dirtied if all_dirtied else (dirtied & loaded_book_ids) all_ids |= {book_id} return {bid: book_as_json(db, book_id) for bid in all_ids} + + +@endpoint('/cdb/copy-to-library/{target_library_id}/{library_id=None}', needs_db_write=True, + postprocess=msgpack_or_json, methods=receive_data_methods, cache_control='no-cache') +def cdb_copy_to_library(ctx, rd, target_library_id, library_id): + db_src = get_db(ctx, rd, library_id) + db_dest = get_db(ctx, rd, target_library_id) + if ctx.restriction_for(rd, db_src) or ctx.restriction_for(rd, db_dest): + raise HTTPForbidden('Cannot use the copy to library interface with a user who has per library restrictions') + data = load_payload_data(rd) + try: + book_ids = {int(x) for x in data['book_ids']} + move_books = bool(data.get('move', False)) + preserve_date = bool(data.get('preserve_date', True)) + duplicate_action = data.get('duplicate_action') or 'add' + automerge_action = data.get('automerge_action') or 'overwrite' + except Exception: + raise HTTPBadRequest('Invalid encoded data, must be of the form: {book_ids: [id1, id2, ..]}') + if duplicate_action not in ('add', 'add_formats_to_existing'): + raise HTTPBadRequest('duplicate_action must be one of: add, add_formats_to_existing') + if automerge_action not in ('overwrite', 'ignore', 'new record'): + raise HTTPBadRequest('automerge_action must be one of: overwrite, ignore, new record') + response = {} + identical_books_data = None + if duplicate_action != 'add': + identical_books_data = db_dest.data_for_find_identical_books() + to_remove = set() + from calibre.db.copy_to_library import copy_one_book + for book_id in book_ids: + try: + rdata = copy_one_book( + book_id, db_src, db_dest, duplicate_action=duplicate_action, automerge_action=automerge_action, + preserve_uuid=move_books, preserve_date=preserve_date, identical_books_data=identical_books_data) + if move_books: + to_remove.add(book_id) + response[book_id] = {'ok': True, 'payload': rdata} + except Exception: + import traceback + response[book_id] = {'ok': False, 'payload': traceback.format_exc()} + + if to_remove: + db_src.remove_books(to_remove, permanent=True) + + return response diff --git a/src/pyj/book_list/book_details.pyj b/src/pyj/book_list/book_details.pyj index 6e1ff3df5a..e2c3231d05 100644 --- a/src/pyj/book_list/book_details.pyj +++ b/src/pyj/book_list/book_details.pyj @@ -6,13 +6,14 @@ import traceback 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 ajax import ajax, ajax_send, encode_query_component +from book_list.delete_book import refresh_after_delete, 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 ( - book_metadata, cover_url, current_library_id, current_virtual_library, - download_url, library_data, load_status, set_book_metadata + all_libraries, book_metadata, cover_url, current_library_id, + current_virtual_library, download_url, library_data, load_status, + set_book_metadata ) from book_list.router import back, home, open_book, report_a_load_failure from book_list.theme import get_color, get_font_size @@ -20,8 +21,8 @@ from book_list.top_bar import add_button, clear_buttons, create_top_bar, set_tit 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 -from dom import add_extra_css, build_rule, clear, svgicon -from modals import create_custom_dialog, error_dialog +from dom import add_extra_css, build_rule, clear, ensure_id, svgicon +from modals import create_custom_dialog, error_dialog, warning_dialog from session import get_interface_data from utils import ( conditional_timeout, debounce, fmt_sidx, human_readable, parse_url_params, @@ -33,6 +34,7 @@ bd_counter = 0 CLASS_NAME = 'book-details-panel' SEARCH_INTERNET_CLASS = 'book-details-search-internet' +COPY_TO_LIBRARY_CLASS = 'book-details-copy-to-library' FORMAT_PRIORITIES = [ 'EPUB', 'AZW3', 'DOCX', 'LIT', 'MOBI', 'ODT', 'RTF', 'MD', 'MARKDOWN', 'TXT', 'PDF' ] @@ -384,6 +386,9 @@ add_extra_css(def(): style += build_rule(sel, margin='1ex 1em') style += build_rule(sel + ' ul > li', list_style_type='none') style += build_rule(sel + ' ul > li > a', padding='2ex 1em', display='block', width='100%') + + sel = '.' + COPY_TO_LIBRARY_CLASS + style += build_rule(sel, margin='1ex 1em') return style ) @@ -611,7 +616,13 @@ def create_more_actions_panel(container_id): action=def(): show_subsequent_panel('search_internet', replace=True) ), + create_item(title, subtitle=subtitle, action=toggle_fields), + + create_item(_('Copy to library'), subtitle=_('Copy or move this book to another calibre library'), + action=def(): + show_subsequent_panel('copy_to_library', replace=True) + ), ] container.appendChild(E.div()) create_item_list(container.lastChild, items) @@ -663,9 +674,99 @@ def search_internet(container_id): )) +def do_copy_to_library(book_id, target_library_id, target_library_name): + + def handle_result(move, close_func, end_type, xhr, ev): + close_func() + title = book_metadata(book_id).title + return_to_book_details() + if end_type is 'abort': + return + if end_type is not 'load': + error_dialog(_('Failed to copy book'), _( + 'Failed to copy the book "{}" click "Show details" for more information.').format(title), + xhr.error_html) + return + try: + response = JSON.parse(xhr.responseText)[book_id] + if not response: + raise Exception('bad') + except: + error_dialog(_('Failed to copy book'), _( + 'Failed to copy the book "{}" got an invalid response from calibre').format(title)) + return + if not response.ok: + error_dialog(_('Failed to copy book'), _( + 'Failed to copy the book "{}" click "Show details" for more information.').format(title), + response.payload + ) + return + if response.action is 'duplicate': + warning_dialog(_('Book already exists'), _( + 'Could not copy as a book with the same title and authors already exists in the {} library').format(target_library_name)) + + elif response.action is 'automerge': + warning_dialog(_('Book merged'), _( + 'The files from the book were merged into a book with the same title and authors in the {} library').format(target_library_name)) + if move: + refresh_after_delete(book_id, current_library_id()) + + + def trigger_copy(container_id, move, close_func): + data = {'book_ids':v'[book_id]', 'move': move} + container = document.getElementById(container_id) + clear(container) + container.appendChild(E.div( + _('Contacting calibre to copy book, please wait...'))) + ajax_send(f'cdb/copy-to-library/{target_library_id}/{current_library_id()}', + data, handle_result.bind(None, move, close_func)) + + create_custom_dialog(_('Copy to library'), def (container, close_func): + mi = book_metadata(book_id) + container_id = ensure_id(container) + container.appendChild(E.div( + E.div(_( + 'Copy "{title}" to the library "{target_library_name}"?').format( + title=mi.title, target_library_name=target_library_name) + ), + E.div( + class_='button-box', + create_button(_('Copy'), None, trigger_copy.bind(None, container_id, False, close_func), highlight=True), + '\xa0', + create_button(_('Move'), None, trigger_copy.bind(None, container_id, True, close_func)), + '\xa0', + create_button(_('Cancel'), None, close_func), + ) + )) + ) + + +def copy_to_library(container_id): + if not render_book.book_id or not book_metadata(render_book.book_id): + return return_to_book_details() + container = document.getElementById(container_id) + create_top_bar(container, title=_('Copy to library'), action=back, icon='close') + libraries = all_libraries() + container.appendChild(E.div(class_=COPY_TO_LIBRARY_CLASS)) + container = container.lastChild + if libraries.length < 2: + container.appendChild(E.div(_('There are no other calibre libraries available to copy the book to'))) + return + container.appendChild(E.h2(_('Choose the library to copy to below'))) + items = [] + for library_id, library_name in libraries: + if library_id is current_library_id(): + continue + items.push(create_item(library_name, action=do_copy_to_library.bind(None, render_book.book_id, library_id, library_name))) + container.appendChild(E.div()) + create_item_list(container.lastChild, items) + + + 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) +set_panel_handler('book_details^copy_to_library', copy_to_library) diff --git a/src/pyj/book_list/delete_book.pyj b/src/pyj/book_list/delete_book.pyj index 0a23342202..6347700596 100644 --- a/src/pyj/book_list/delete_book.pyj +++ b/src/pyj/book_list/delete_book.pyj @@ -22,6 +22,15 @@ def delete_from_cache(library_id, book_id, title): db.delete_books_matching(library_id, book_id) +def refresh_after_delete(book_id, library_id): + 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() + + 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))) @@ -29,12 +38,7 @@ def do_delete_from_library(parent, close_modal, library_id, book_id, title): 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() + refresh_after_delete(book_id, library_id) return clear(parent) msg = E.div() diff --git a/src/pyj/book_list/home.pyj b/src/pyj/book_list/home.pyj index 1f49471de8..da55ad2878 100644 --- a/src/pyj/book_list/home.pyj +++ b/src/pyj/book_list/home.pyj @@ -9,7 +9,7 @@ from ajax import ajax_send from book_list.constants import has_offline_support from book_list.cover_grid import BORDER_RADIUS from book_list.globals import get_db -from book_list.library_data import last_virtual_library_for, sync_library_books +from book_list.library_data import last_virtual_library_for, sync_library_books, all_libraries from book_list.router import open_book, update_window_title from book_list.top_bar import add_button, create_top_bar from book_list.ui import set_default_panel_handler, show_panel @@ -241,8 +241,7 @@ def init(container_id): container.appendChild(E.h2(_('Choose the calibre library to browse…'))) container.appendChild(E.div(style='display: flex; flex-wrap: wrap')) cl = container.lastChild - lids = sorted(interface_data.library_map, key=def(x): return interface_data.library_map[x];) - for library_id in lids: + for library_id, library_name in all_libraries(): library_name = interface_data.library_map[library_id] if library_name: cl.appendChild( diff --git a/src/pyj/book_list/library_data.pyj b/src/pyj/book_list/library_data.pyj index 19da14bcdb..1f2e72aea2 100644 --- a/src/pyj/book_list/library_data.pyj +++ b/src/pyj/book_list/library_data.pyj @@ -19,6 +19,12 @@ def current_library_id(): return q.library_id or get_interface_data().default_library_id +def all_libraries(): + interface_data = get_interface_data() + lids = sorted(interface_data.library_map, key=def(x): return interface_data.library_map[x];) + return [(lid, interface_data.library_map[lid]) for lid in lids] + + def current_virtual_library(): q = parse_url_params() return q.vl or ''