mirror of
https://github.com/kovidgoyal/calibre.git
synced 2025-07-07 10:14:46 -04:00
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:
parent
0daad01a04
commit
053056c0d9
@ -124,24 +124,28 @@ def cdb_set_cover(ctx, rd, book_id, library_id):
|
|||||||
return tuple(dirtied)
|
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},
|
@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')
|
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):
|
def cdb_set_fields(ctx, rd, book_id, library_id):
|
||||||
db = get_db(ctx, rd, library_id)
|
db = get_db(ctx, rd, library_id)
|
||||||
if ctx.restriction_for(rd, db):
|
if ctx.restriction_for(rd, db):
|
||||||
raise HTTPForbidden('Cannot use the set fields interface with a user who has per library restrictions')
|
raise HTTPForbidden('Cannot use the set fields interface with a user who has per library restrictions')
|
||||||
raw = rd.read()
|
data = load_payload_data(rd)
|
||||||
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')
|
|
||||||
try:
|
try:
|
||||||
changes, loaded_book_ids = data['changes'], frozenset(map(int, data.get('loaded_book_ids', ())))
|
changes, loaded_book_ids = data['changes'], frozenset(map(int, data.get('loaded_book_ids', ())))
|
||||||
all_dirtied = bool(data.get('all_dirtied'))
|
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 = dirtied if all_dirtied else (dirtied & loaded_book_ids)
|
||||||
all_ids |= {book_id}
|
all_ids |= {book_id}
|
||||||
return {bid: book_as_json(db, book_id) for bid in all_ids}
|
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
|
||||||
|
@ -6,13 +6,14 @@ import traceback
|
|||||||
from elementmaker import E
|
from elementmaker import E
|
||||||
from gettext import gettext as _
|
from gettext import gettext as _
|
||||||
|
|
||||||
from ajax import ajax, encode_query_component
|
from ajax import ajax, ajax_send, encode_query_component
|
||||||
from book_list.delete_book import start_delete_book
|
from book_list.delete_book import refresh_after_delete, start_delete_book
|
||||||
from book_list.globals import get_session_data
|
from book_list.globals import get_session_data
|
||||||
from book_list.item_list import create_item, create_item_list
|
from book_list.item_list import create_item, create_item_list
|
||||||
from book_list.library_data import (
|
from book_list.library_data import (
|
||||||
book_metadata, cover_url, current_library_id, current_virtual_library,
|
all_libraries, book_metadata, cover_url, current_library_id,
|
||||||
download_url, library_data, load_status, set_book_metadata
|
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.router import back, home, open_book, report_a_load_failure
|
||||||
from book_list.theme import get_color, get_font_size
|
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.ui import query_as_href, set_panel_handler, show_panel
|
||||||
from book_list.views import search_query_for
|
from book_list.views import search_query_for
|
||||||
from date import format_date
|
from date import format_date
|
||||||
from dom import add_extra_css, build_rule, clear, svgicon
|
from dom import add_extra_css, build_rule, clear, ensure_id, svgicon
|
||||||
from modals import create_custom_dialog, error_dialog
|
from modals import create_custom_dialog, error_dialog, warning_dialog
|
||||||
from session import get_interface_data
|
from session import get_interface_data
|
||||||
from utils import (
|
from utils import (
|
||||||
conditional_timeout, debounce, fmt_sidx, human_readable, parse_url_params,
|
conditional_timeout, debounce, fmt_sidx, human_readable, parse_url_params,
|
||||||
@ -33,6 +34,7 @@ bd_counter = 0
|
|||||||
|
|
||||||
CLASS_NAME = 'book-details-panel'
|
CLASS_NAME = 'book-details-panel'
|
||||||
SEARCH_INTERNET_CLASS = 'book-details-search-internet'
|
SEARCH_INTERNET_CLASS = 'book-details-search-internet'
|
||||||
|
COPY_TO_LIBRARY_CLASS = 'book-details-copy-to-library'
|
||||||
FORMAT_PRIORITIES = [
|
FORMAT_PRIORITIES = [
|
||||||
'EPUB', 'AZW3', 'DOCX', 'LIT', 'MOBI', 'ODT', 'RTF', 'MD', 'MARKDOWN', 'TXT', 'PDF'
|
'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, margin='1ex 1em')
|
||||||
style += build_rule(sel + ' ul > li', list_style_type='none')
|
style += build_rule(sel + ' ul > li', list_style_type='none')
|
||||||
style += build_rule(sel + ' ul > li > a', padding='2ex 1em', display='block', width='100%')
|
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
|
return style
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -611,7 +616,13 @@ def create_more_actions_panel(container_id):
|
|||||||
action=def():
|
action=def():
|
||||||
show_subsequent_panel('search_internet', replace=True)
|
show_subsequent_panel('search_internet', replace=True)
|
||||||
),
|
),
|
||||||
|
|
||||||
create_item(title, subtitle=subtitle, action=toggle_fields),
|
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())
|
container.appendChild(E.div())
|
||||||
create_item_list(container.lastChild, items)
|
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():
|
def delete_book():
|
||||||
start_delete_book(current_library_id(), render_book.book_id, render_book.title or _('Unknown'))
|
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', init)
|
||||||
set_panel_handler('book_details^more_actions', create_more_actions_panel)
|
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^search_internet', search_internet)
|
||||||
|
set_panel_handler('book_details^copy_to_library', copy_to_library)
|
||||||
|
@ -22,6 +22,15 @@ def delete_from_cache(library_id, book_id, title):
|
|||||||
db.delete_books_matching(library_id, book_id)
|
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):
|
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)))
|
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':
|
if end_type is 'load' or end_type is 'abort':
|
||||||
close_modal()
|
close_modal()
|
||||||
if end_type is 'load':
|
if end_type is 'load':
|
||||||
force_refresh_on_next_load()
|
refresh_after_delete(book_id, library_id)
|
||||||
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
|
return
|
||||||
clear(parent)
|
clear(parent)
|
||||||
msg = E.div()
|
msg = E.div()
|
||||||
|
@ -9,7 +9,7 @@ from ajax import ajax_send
|
|||||||
from book_list.constants import has_offline_support
|
from book_list.constants import has_offline_support
|
||||||
from book_list.cover_grid import BORDER_RADIUS
|
from book_list.cover_grid import BORDER_RADIUS
|
||||||
from book_list.globals import get_db
|
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.router import open_book, update_window_title
|
||||||
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_default_panel_handler, show_panel
|
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.h2(_('Choose the calibre library to browse…')))
|
||||||
container.appendChild(E.div(style='display: flex; flex-wrap: wrap'))
|
container.appendChild(E.div(style='display: flex; flex-wrap: wrap'))
|
||||||
cl = container.lastChild
|
cl = container.lastChild
|
||||||
lids = sorted(interface_data.library_map, key=def(x): return interface_data.library_map[x];)
|
for library_id, library_name in all_libraries():
|
||||||
for library_id in lids:
|
|
||||||
library_name = interface_data.library_map[library_id]
|
library_name = interface_data.library_map[library_id]
|
||||||
if library_name:
|
if library_name:
|
||||||
cl.appendChild(
|
cl.appendChild(
|
||||||
|
@ -19,6 +19,12 @@ def current_library_id():
|
|||||||
return q.library_id or get_interface_data().default_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():
|
def current_virtual_library():
|
||||||
q = parse_url_params()
|
q = parse_url_params()
|
||||||
return q.vl or ''
|
return q.vl or ''
|
||||||
|
Loading…
x
Reference in New Issue
Block a user