Content server: Implement the "Copy to library" function. To use it click the three dots in the top right corner of a book's page and choose "Copy to library". Fixes #1810486 [[Feature] Copy to another library on the server](https://bugs.launchpad.net/calibre/+bug/1810486)

This commit is contained in:
Kovid Goyal 2019-01-28 14:41:59 +05:30
parent 0daad01a04
commit 053056c0d9
No known key found for this signature in database
GPG Key ID: 06BC317B515ACE7C
5 changed files with 185 additions and 27 deletions

View File

@ -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

View File

@ -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)

View File

@ -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()

View File

@ -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(

View File

@ -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 ''