1# vim:fileencoding=utf-8 2# License: GPL v3 Copyright: 2016, Kovid Goyal <kovid at kovidgoyal.net> 3from __python__ import hash_literals 4 5from elementmaker import E 6 7import traceback 8from ajax import ajax, ajax_send, encode_query_component 9from book_list.delete_book import refresh_after_delete, start_delete_book 10from book_list.globals import get_session_data 11from book_list.item_list import create_item, create_item_list 12from book_list.library_data import ( 13 all_libraries, book_after, book_metadata, cover_url, current_library_id, 14 current_virtual_library, download_url, library_data, load_status, 15 set_book_metadata 16) 17from book_list.router import back, home, open_book, report_a_load_failure 18from book_list.theme import get_color, get_color_as_rgba, get_font_size 19from book_list.top_bar import add_button, clear_buttons, create_top_bar, set_title 20from book_list.ui import query_as_href, set_panel_handler, show_panel 21from book_list.views import search_query_for 22from date import format_date 23from dom import add_extra_css, build_rule, clear, ensure_id, svgicon, unique_id 24from gettext import gettext as _ 25from modals import create_custom_dialog, error_dialog, warning_dialog 26from read_book.touch import ( 27 copy_touch, install_handlers, interpret_single_gesture, touch_id, update_touch 28) 29from session import get_interface_data 30from utils import ( 31 conditional_timeout, debounce, fmt_sidx, human_readable, parse_url_params, 32 safe_set_inner_html, sandboxed_html 33) 34from widgets import create_button, create_spinner 35 36bd_counter = 0 37 38CLASS_NAME = 'book-details-panel' 39SEARCH_INTERNET_CLASS = 'book-details-search-internet' 40COPY_TO_LIBRARY_CLASS = 'book-details-copy-to-library' 41FORMAT_PRIORITIES = [ 42 'EPUB', 'AZW3', 'DOCX', 'LIT', 'MOBI', 'ODT', 'RTF', 'MD', 'MARKDOWN', 'TXT', 'PDF' 43] 44 45def sort_formats_key(fmt): 46 ans = FORMAT_PRIORITIES.indexOf(fmt) 47 if ans < 0: 48 ans = FORMAT_PRIORITIES.length 49 return ans 50 51def get_preferred_format(metadata, output_format, input_formats, for_download): 52 formats = (metadata and metadata.formats) or v'[]' 53 formats = [f.toUpperCase() for f in formats] 54 fmt = 'EPUB' if output_format is 'PDF' else output_format 55 if formats.length and formats.indexOf(fmt) is -1: 56 found = False 57 formats = sorted(formats, key=sort_formats_key) 58 for q in formats: 59 if input_formats[q]: 60 fmt = q 61 found = True 62 break 63 if for_download and not found and formats.length: 64 fmt = formats[0] 65 return fmt.toUpperCase() 66 67IGNORED_FIELDS = {'title', 'sort', 'uuid', 'id', 'urls_from_identifiers', 'lang_names', 'last_modified', 'path'} 68 69default_sort = {f:i+1 for i, f in enumerate(('title', 'title_sort', 'authors', 'author_sort', 'series', 'rating', 'pubdate', 'tags', 'timestamp', 'pubdate', 'identifiers', 'languages', 'publisher', 'last_modified'))} 70default_sort['formats'] = 999 71 72def field_sorter(field_metadata): 73 return def(field): 74 lvl = '{:03d}'.format(default_sort[field] or 998) 75 fm = (field_metadata[field] or {})[field] or {} 76 return lvl + (fm.name or 'zzzzz') 77 78 79def href_for_search(name, val, use_quotes=True): 80 query = ('{}:"={}"' if use_quotes else '{}:{}').format(name, str.replace(val, '"', r'\"')) 81 q = search_query_for(query) 82 return query_as_href(q) 83 84 85def on_fmt_click(ev): 86 fmt = ev.currentTarget.dataset.format 87 book_id = int(ev.currentTarget.dataset.bookId) 88 title, sz = this 89 90 create_custom_dialog(title, def(parent, close_modal): 91 92 def action(which): 93 close_modal() 94 which(book_id, fmt) 95 96 parent.appendChild(E.div( 97 E.div(_('What would you like to do with the {} format?').format(fmt)), 98 E.div(class_='button-box', 99 create_button(_('Read'), 'book', action.bind(None, read_format)), 100 '\xa0', 101 create_button(_('Download'), 'cloud-download', download_url(book_id, fmt), 102 _('File size: {}').format(human_readable(sz)), 103 download_filename=f'{title}.{fmt.toLowerCase()}') 104 ) 105 )) 106 ) 107 108 109def adjust_iframe_height(iframe): 110 de = iframe.contentWindow.document.documentElement 111 # scrollHeight is inaccurate on Firefox 112 iframe.style.height = de.offsetHeight + 5 + 'px' 113 iframe.dataset.last_window_width = window.innerWidth + '' 114 return de 115 116 117def setup_iframe(iframe): 118 de = adjust_iframe_height(iframe) 119 for a in de.querySelectorAll('a[href]'): 120 a.setAttribute('target', '_blank') 121 122 def forward_touch_events(ev): 123 container = window.top.document.getElementById(render_book.container_id) 124 if container: 125 dup = v'new ev.constructor(ev.type, ev)' 126 container.dispatchEvent(dup) 127 128 for key in ('start', 'move', 'end', 'cancel'): 129 iframe.contentWindow.addEventListener(f'touch{key}', forward_touch_events) 130 131 132def adjust_all_iframes(ev): 133 for iframe in document.querySelectorAll(f'.{CLASS_NAME} iframe'): 134 ww = parseInt(iframe.dataset.last_window_width) 135 if ww is not window.innerWidth: 136 adjust_iframe_height(iframe) 137 138 139def add_stars_to(stars, val, allow_half_stars): 140 for i in range(val // 2): 141 stars.appendChild(svgicon('star')) 142 if allow_half_stars and (val % 2): 143 stars.appendChild(svgicon('star-half')) 144 145 146if window?: 147 window.addEventListener('resize', debounce(adjust_all_iframes, 250)) 148 149def adjusting_sandboxed_html(html, extra_css): 150 color = get_color_as_rgba('window-foreground') 151 css = f'html, body {{ overflow: hidden; color: rgba({color[0]}, {color[1]}, {color[2]}, {color[3]}) }}' 152 if extra_css: 153 css += '\n\n' + extra_css 154 # allow-same-origin is needed for resizing and allow-popups is needed for 155 # target="_blank" 156 ans = sandboxed_html(html, css, 'allow-same-origin allow-popups allow-popups-to-escape-sandbox') 157 ans.addEventListener('load', def(ev): setup_iframe(ev.target);) 158 ans.style.height = '50vh' 159 ans.dataset.last_window_width = '0' 160 return ans 161 162 163def render_metadata(mi, table, book_id, iframe_css): # {{{ 164 field_metadata = library_data.field_metadata 165 interface_data = get_interface_data() 166 def allowed_fields(field): 167 if field.endswith('_index'): 168 fm = field_metadata[field[:-len('_index')]] 169 if fm and fm.datatype is 'series': 170 return False 171 if field.startswith('#'): 172 return True 173 if field in IGNORED_FIELDS or field.endswith('_sort'): 174 return False 175 if mi[field] is undefined: 176 return False 177 return True 178 179 fields = library_data.book_display_fields 180 if not fields or not fields.length or get_session_data().get('show_all_metadata'): 181 fields = sorted(filter(allowed_fields, mi), key=field_sorter(field_metadata)) 182 else: 183 fields = filter(allowed_fields, fields) 184 comments = v'[]' 185 186 def add_row(name, val, is_searchable=False, is_html=False, join=None, search_text=None, use_quotes=True): 187 if val is undefined or val is None: 188 return 189 def add_val(v): 190 if not v.appendChild: 191 v += '' 192 if is_searchable: 193 text_rep = search_text or v 194 table.lastChild.lastChild.appendChild(E.a( 195 v, 196 title=_('Click to see books with {0}: {1}').format(name, text_rep), class_='blue-link', 197 href=href_for_search(is_searchable, text_rep, use_quotes=use_quotes) 198 )) 199 else: 200 if v.appendChild: 201 table.lastChild.lastChild.appendChild(v) 202 else: 203 table.lastChild.lastChild.appendChild(document.createTextNode(v)) 204 205 table.appendChild(E.tr(E.td(name + ':'), E.td())) 206 if is_html and /[<>]/.test(val + ''): 207 table.lastChild.lastChild.appendChild(adjusting_sandboxed_html(val + '', iframe_css)) 208 else: 209 if not join: 210 add_val(val) 211 else: 212 for v in val: 213 add_val(v) 214 if v is not val[-1]: 215 table.lastChild.lastChild.appendChild(document.createTextNode(join)) 216 return table.lastChild.lastChild 217 218 def process_composite(field, fm, name, val): 219 if fm.display and fm.display.contains_html: 220 add_row(name, val, is_html=True) 221 return 222 if fm.is_multiple and fm.is_multiple.list_to_ui: 223 all_vals = filter(None, map(str.strip, val.split(fm.is_multiple.list_to_ui))) 224 add_row(name, all_vals, is_searchable=field, join=fm.is_multiple.list_to_ui) 225 else: 226 add_row(name, val, is_searchable=field) 227 228 def process_authors(field, fm, name, val): 229 add_row(name, val, is_searchable=field, join=' & ') 230 231 def process_publisher(field, fm, name, val): 232 add_row(name, val, is_searchable=field) 233 234 def process_formats(field, fm, name, val): 235 if val.length and book_id?: 236 table.appendChild(E.tr(E.td(name + ':'), E.td())) 237 td = table.lastChild.lastChild 238 for fmt in val: 239 fmt = fmt.toUpperCase() 240 td.appendChild(E.a( 241 fmt, class_='blue-link', href='javascript:void(0)', 242 title=_('Read or download this book in the {} format').format(fmt), 243 onclick=on_fmt_click.bind(v'[mi.title, mi.format_sizes[fmt] || 0]'), 244 data_format=fmt, data_book_id='' + book_id)) 245 if fmt is not val[-1]: 246 td.appendChild(document.createTextNode(', ')) 247 248 def process_rating(field, fm, name, val): 249 stars = E.span() 250 val = int(val or 0) 251 if val > 0: 252 add_stars_to(stars, val, fm.display?.allow_half_stars) 253 add_row(name, stars, is_searchable=field, search_text=val/2 + '') 254 255 def process_identifiers(field, fm, name, val): 256 257 def ids_sorter(url_map, k): 258 x = url_map[k] 259 if not x: 260 return '' 261 return (x[0] or '').toLowerCase() 262 263 if val: 264 keys = Object.keys(val) 265 if keys.length: 266 td = E.td() 267 url_map = {k:v'[text, url]' for text, k, val, url in mi.urls_from_identifiers or v'[]'} 268 for k in sorted(keys, key=ids_sorter.bind(None, url_map)): 269 idval = val[k] 270 x = url_map[k] 271 if x: 272 if td.childNodes.length: 273 td.appendChild(document.createTextNode(', ')) 274 td.appendChild(E.a(class_='blue-link', title='{}:{}'.format(k, idval), target='_new', href=x[1], x[0])) 275 if td.childNodes.length: 276 table.appendChild(E.tr(E.td(name + ':'), td)) 277 278 def process_size(field, fm, name, val): 279 if val: 280 try: 281 add_row(name, human_readable(int(val))) 282 except: 283 add_row(name, val+'') 284 285 def process_languages(field, fm, name, val): 286 if val and val.length: 287 table.appendChild(E.tr(E.td(name + ':'), E.td())) 288 td = table.lastChild.lastChild 289 for k in val: 290 if mi.lang_names: 291 lang = mi.lang_names[k] or k 292 else: 293 lang = k 294 td.appendChild(E.a(lang, 295 class_='blue-link', 296 title=_('Click to see books with language: {}').format(lang), 297 href=href_for_search(field, k) 298 )) 299 if k is not val[-1]: 300 td.appendChild(document.createTextNode(', ')) 301 302 def process_datetime(field, fm, name, val): 303 if val: 304 fmt = interface_data['gui_' + field + '_display_format'] or (fm['display'] or {}).date_format 305 formatted_val = format_date(val, fmt) 306 if formatted_val: 307 add_row(name, formatted_val, is_searchable=field, search_text=val) 308 309 def process_series(field, fm, name, val): 310 if val: 311 ifield = field + '_index' 312 try: 313 ival = float(mi[ifield]) 314 except Exception: 315 ival = 1.0 316 ival = fmt_sidx(ival, use_roman=interface_data.use_roman_numerals_for_series_number) 317 table.appendChild(E.tr(E.td(name + ':'), E.td())) 318 s = safe_set_inner_html(E.span(), _('{0} of <a>{1}</a>').format(ival, val)) 319 a = s.getElementsByTagName('a') 320 if a and a.length: 321 a = a[0] 322 a.setAttribute('href', href_for_search(field, val)) 323 a.setAttribute('title', _('Click to see books with {0}: {1}').format(name, val)) 324 a.setAttribute('class', 'blue-link') 325 else: 326 print("WARNING: Translation of series template is incorrect as it does not have an <a> tag") 327 table.lastChild.lastChild.appendChild(s) 328 329 def process_field(field, fm): 330 name = fm.name or field 331 datatype = fm.datatype 332 val = mi[field] 333 if field is 'comments' or datatype is 'comments': 334 if not val: 335 return 336 ias = fm.display?.interpret_as or 'html' 337 hp = fm.display?.heading_position or 'hide' 338 if ias is 'long-text': 339 if hp is 'side': 340 add_row(name, val).style.whiteSpace = 'pre-wrap' 341 return 342 val = E.pre(val, style='white-space:pre-wrap').outerHTML 343 elif ias is 'short-text': 344 if hp is 'side': 345 add_row(name, val) 346 return 347 val = E.span(val).outerHTML 348 else: 349 if field is 'comments' and '<' not in val: 350 val = '\n'.join(['<p class="description">{}</p>'.format(x.replace(/\n/g, '<br>')) for x in val.split('\n\n')]) 351 if hp is 'side': 352 add_row(name, val, is_html=True) 353 return 354 comments.push(v'[field, val]') 355 return 356 func = None 357 if datatype is 'composite': 358 func = process_composite 359 elif field is 'formats': 360 func = process_formats 361 elif datatype is 'rating': 362 func = process_rating 363 elif field is 'identifiers': 364 func = process_identifiers 365 elif field is 'authors': 366 func = process_authors 367 elif field is 'publisher': 368 func = process_publisher 369 elif field is 'languages': 370 func = process_languages 371 elif field is 'size': 372 func = process_size 373 elif datatype is 'datetime': 374 func = process_datetime 375 elif datatype is 'series': 376 func = process_series 377 if func: 378 func(field, fm, name, val) 379 else: 380 if datatype is 'text' or datatype is 'enumeration': 381 if val is not undefined and val is not None: 382 join = fm.is_multiple.list_to_ui if fm.is_multiple else None 383 add_row(name, val, join=join, is_searchable=field) 384 elif datatype is 'bool': 385 if library_data.bools_are_tristate: 386 v = _('Yes') if val else ('' if val is undefined or val is None else _('No')) 387 else: 388 v = _('Yes') if val else _('No') 389 if v: 390 add_row(name, v, is_searchable=field, use_quotes=False) 391 elif datatype is 'int' or datatype is 'float': 392 if val is not undefined and val is not None: 393 fmt = (fm.display or {}).number_format 394 if fmt: 395 formatted_val = fmt.format(val) 396 if formatted_val: 397 val += '' 398 add_row(name, formatted_val, is_searchable=field, search_text=val) 399 else: 400 val += '' 401 add_row(name, val, is_searchable=field, search_text=val) 402 403 for field in fields: 404 fm = field_metadata[field] 405 if not fm: 406 continue 407 try: 408 process_field(field, fm) 409 except Exception: 410 print('Failed to render metadata field: ' + field) 411 traceback.print_exc() 412 413 all_html = '' 414 for field, comment in comments: 415 if comment: 416 fm = field_metadata[field] 417 if fm.display?.heading_position is 'above': 418 name = fm.name or field 419 all_html += f'<h3>{name}</h3>' 420 all_html += comment 421 iframe = adjusting_sandboxed_html(all_html, iframe_css) 422 iframe.style.marginTop = '2ex' 423 table.parentNode.appendChild(iframe) 424# }}} 425 426 427def basic_table_rules(sel): 428 style = '' 429 style += build_rule(sel + 'table.metadata td', vertical_align='top') 430 style += build_rule(sel + 'table.metadata td:first-of-type', font_weight='bold', padding_right='1em', white_space='nowrap') 431 style += build_rule(sel + 'table.metadata td:last-of-type', overflow_wrap='break-word') 432 return style 433 434 435add_extra_css(def(): 436 sel = '.' + CLASS_NAME + ' ' 437 style = basic_table_rules(sel) 438 style += build_rule(sel + ' .next-book-button:hover', transform='scale(1.5)') 439 440 sel = '.' + SEARCH_INTERNET_CLASS 441 style += build_rule(sel, margin='1ex 1em') 442 style += build_rule(sel + ' ul > li', list_style_type='none') 443 style += build_rule(sel + ' ul > li > a', padding='2ex 1em', display='block', width='100%') 444 445 sel = '.' + COPY_TO_LIBRARY_CLASS 446 style += build_rule(sel, margin='1ex 1em') 447 return style 448) 449 450current_fetch = None 451 452def no_book(container): 453 container.appendChild(E.div( 454 style='margin: 1ex 1em', 455 _('No book found') 456 )) 457 458 459def on_img_err(err): 460 img = err.target 461 if img.parentNode: 462 img.parentNode.style.display = 'none' 463 464def preferred_format(book_id, for_download): 465 interface_data = get_interface_data() 466 return get_preferred_format(book_metadata(book_id), interface_data.output_format, interface_data.input_formats, for_download) 467 468 469def read_format(book_id, fmt): 470 open_book(book_id, fmt) 471 472 473def read_book(book_id): 474 fmt = preferred_format(book_id) 475 read_format(book_id, fmt) 476 477 478def download_format(book_id, fmt): 479 window.location = download_url(book_id, fmt) 480 481 482def download_book(book_id): 483 fmt = preferred_format(book_id, True) 484 download_format(book_id, fmt) 485 486 487def next_book(book_id, delta): 488 next_book_id = book_after(book_id, delta) 489 if next_book_id: 490 q = parse_url_params() 491 q.book_id = next_book_id + '' 492 show_panel('book_details', query=q, replace=True) 493 494 495def render_book(container_id, book_id): 496 render_book.book_id = book_id 497 render_book.container_id = container_id 498 is_random = parse_url_params().book_id is '0' 499 c = document.getElementById(container_id) 500 if not c: 501 return 502 install_touch_handlers(c, book_id) 503 metadata = book_metadata(book_id) 504 render_book.title = metadata.title 505 set_title(c, metadata.title) 506 authors = metadata.authors.join(' & ') if metadata.authors else _('Unknown') 507 alt = _('{} by {}\n\nClick to read').format(metadata.title, authors) 508 border_radius = 20 509 button_style = f'cursor: pointer; border-radius: {border_radius//5}px;' 510 button_style += 'color: #ccc; background-color:rgba(0, 0, 0, 0.75);' 511 button_style += 'display: flex; justify-content: center; align-items: center; padding: 2px;' 512 if is_random: 513 button_style += 'display: none;' 514 515 def prev_next_button(is_prev): 516 return E.div( 517 style=button_style, 518 title=_('Previous book [Ctrl+Left]') if is_prev else _('Next book [Ctrl+Right]'), 519 class_='next-book-button', 520 svgicon('chevron-left' if is_prev else 'chevron-right'), 521 onclick=next_book.bind(None, book_id, (-1 if is_prev else 1)) 522 ) 523 524 imgdiv = E.div(style='position: relative', 525 E.img( 526 alt=alt, title=alt, data_title=metadata.title, data_authors=authors, 527 onclick=read_book.bind(None, book_id), 528 style='cursor: pointer; border-radius: 20px; max-width: calc(100vw - 2em); max-height: calc(100vh - 4ex - {}); display: block; width:auto; height:auto; border-radius: {}px'.format(get_font_size('title'), border_radius 529 )), 530 E.div( 531 style=f'position: absolute; top:0; width: 100%; padding: {border_radius//2}px; box-sizing: border-box; font-size: 2rem; display: flex; justify-content: space-between; color: {get_color("button-text")}', 532 prev_next_button(True), 533 prev_next_button(), 534 ), 535 ) 536 img = imgdiv.getElementsByTagName('img')[0] 537 img.onerror = on_img_err 538 img.src = cover_url(book_id) 539 c = c.lastChild 540 c.appendChild(E.div( 541 style='display:flex; padding: 1ex 1em; align-items: flex-start; justify-content: flex-start; flex-wrap: wrap', 542 E.div(style='margin-right: 1em; flex-grow: 3; max-width: 500px', data_book_id='' + book_id), 543 imgdiv 544 )) 545 container = c.lastChild.firstChild 546 read_button = create_button(_('Read'), 'book', read_book.bind(None, book_id), _('Read this book [V]')) 547 fmt = preferred_format(book_id, True) 548 download_button = create_button(_('Download'), 'cloud-download', download_url(book_id, fmt), 549 _('Download this book in the {0} format ({1})').format(fmt, human_readable(metadata.format_sizes[fmt] or 0)), 550 download_filename=f'{metadata.title}.{fmt.toLowerCase()}') 551 row = E.div(read_button, '\xa0\xa0\xa0', download_button, style='margin-bottom: 1ex') 552 if not metadata.formats or not metadata.formats.length: 553 row.style.display = 'none' 554 container.appendChild(row) 555 md = E.div(style='margin-bottom: 2ex') 556 table = E.table(class_='metadata') 557 container.appendChild(md) 558 md.appendChild(table) 559 560 render_metadata(metadata, table, book_id) 561 562 563def add_top_bar_buttons(container_id): 564 container = document.getElementById(container_id) 565 if container: 566 clear_buttons(container) 567 add_button(container, 'convert', action=convert_book, tooltip=_('Convert this book to another format [C]')) 568 add_button(container, 'edit', action=edit_metadata, tooltip=_('Edit the metadata for this book [E]')) 569 add_button(container, 'trash', action=delete_book, tooltip=_('Delete this book')) 570 book_id = parse_url_params().book_id 571 if book_id is '0': 572 add_button(container, 'random', tooltip=_('Show a random book'), action=def(): 573 fetch_metadata(container_id, 0, proceed_after_succesful_fetch_metadata) 574 ) 575 add_button(container, 'ellipsis-v', action=show_subsequent_panel.bind(None, 'more_actions'), tooltip=_('More actions')) 576 577 578def proceed_after_succesful_fetch_metadata(container_id, book_id): 579 render_book(container_id, book_id) 580 add_top_bar_buttons(container_id) 581 582 583def metadata_fetched(container_id, book_id, proceed, end_type, xhr, event): 584 nonlocal current_fetch 585 if current_fetch is None or current_fetch is not xhr: 586 return # Fetching was aborted 587 current_fetch = None 588 c = document.getElementById(container_id) 589 if not c: 590 return 591 c = c.lastChild 592 if end_type is 'load': 593 try: 594 data = JSON.parse(xhr.responseText) 595 except Exception as err: 596 error_dialog(_('Could not fetch metadata for book'), _('Server returned an invalid response'), err.toString()) 597 return 598 clear(c) 599 book_id = int(data['id']) 600 set_book_metadata(book_id, data) 601 proceed(container_id, book_id) 602 elif end_type is not 'abort': 603 clear(c) 604 c.appendChild(E.div( 605 style='margin: 1ex 1em', 606 _('Could not fetch metadata for book'), 607 E.div(style='margin: 1ex 1em') 608 )) 609 safe_set_inner_html(c.lastChild.lastChild, xhr.error_html) 610 611def fetch_metadata(container_id, book_id, proceed): 612 nonlocal current_fetch 613 container = document.getElementById(container_id) 614 if not container: 615 return 616 if current_fetch: 617 current_fetch.abort() 618 current_fetch = ajax('interface-data/book-metadata/' + book_id, metadata_fetched.bind(None, container_id, book_id, proceed), 619 query={'library_id':current_library_id(), 'vl':current_virtual_library()}) 620 current_fetch.send() 621 container = container.lastChild 622 clear(container) 623 container.appendChild(E.div( 624 style='margin: 1ex 1em', 625 create_spinner(), '\xa0' + _('Fetching metadata for the book, please wait') + '…', 626 )) 627 628 629def install_touch_handlers(container, book_id): 630 ongoing_touches = {} 631 gesture_id = 0 632 container_id = container.id 633 634 def has_active_touches(): 635 for tid in ongoing_touches: 636 t = ongoing_touches[tid] 637 if t.active: 638 return True 639 return False 640 641 def handle_touch(ev): 642 nonlocal gesture_id, ongoing_touches 643 container = document.getElementById(container_id) 644 if not container: 645 return 646 if ev.type is 'touchstart': 647 for touch in ev.changedTouches: 648 ongoing_touches[touch_id(touch)] = copy_touch(touch) 649 gesture_id += 1 650 elif ev.type is 'touchmove': 651 for touch in ev.changedTouches: 652 t = ongoing_touches[touch_id(touch)] 653 if t: 654 update_touch(t, touch) 655 elif ev.type is 'touchcancel': 656 for touch in ev.changedTouches: 657 v'delete ongoing_touches[touch_id(touch)]' 658 elif ev.type is 'touchend': 659 for touch in ev.changedTouches: 660 t = ongoing_touches[touch_id(touch)] 661 if t: 662 t.active = False 663 update_touch(t, touch) 664 if not has_active_touches(): 665 touches = ongoing_touches 666 ongoing_touches = {} 667 num = len(touches) 668 if num is 1: 669 gesture = interpret_single_gesture(touches[Object.keys(touches)[0]], gesture_id) 670 if gesture.type is 'swipe' and gesture.axis is 'horizontal': 671 delta = -1 if gesture.direction is 'right' else 1 672 next_book(book_id, delta) 673 674 install_handlers(container, { 675 'handle_touchstart': handle_touch, 676 'handle_touchmove': handle_touch, 677 'handle_touchend': handle_touch, 678 'handle_touchcancel': handle_touch, 679 }, True) 680 681 682def create_book_details(container): 683 q = parse_url_params() 684 current_book_id = q.book_id 685 if current_book_id is undefined or current_book_id is None: 686 no_book(container) 687 return 688 current_book_id = int(current_book_id) 689 container_id = container.parentNode.id 690 if current_book_id is not 0 and book_metadata(current_book_id): 691 render_book(container_id, current_book_id) 692 add_top_bar_buttons(container_id) 693 else: 694 fetch_metadata(container_id, current_book_id, proceed_after_succesful_fetch_metadata) 695 696 697def report_load_failure(container): 698 report_a_load_failure( 699 container, _('Failed to load books from calibre library, with error:'), 700 load_status.error_html) 701 702 703 704def check_for_books_loaded(): 705 container = this 706 if load_status.loading: 707 conditional_timeout(container.id, 5, check_for_books_loaded) 708 return 709 container = container.lastChild 710 clear(container) 711 if not load_status.ok: 712 report_load_failure(container) 713 return 714 create_book_details(container) 715 716 717def onkeydown(container_id, close_action, ev): 718 if render_book.book_id: 719 if ev.ctrlKey: 720 if ev.key is 'ArrowLeft': 721 next_book(render_book.book_id, -1) 722 ev.preventDefault(), ev.stopPropagation() 723 elif ev.key is 'ArrowRight': 724 next_book(render_book.book_id, 1) 725 ev.preventDefault(), ev.stopPropagation() 726 elif not ev.ctrlKey and not ev.metaKey and not ev.shiftKey: 727 if ev.key is 'Escape': 728 ev.preventDefault(), ev.stopPropagation() 729 close_action() 730 elif ev.key is 'Delete': 731 ev.preventDefault(), ev.stopPropagation() 732 delete_book() 733 elif ev.key is 'v' or ev.key is 'V': 734 read_book(render_book.book_id) 735 elif ev.key is 'c' or ev.key is 'C': 736 convert_book() 737 elif ev.key is 'e' or ev.key is 'E': 738 edit_metadata() 739 740 741def init(container_id): 742 container = document.getElementById(container_id) 743 close_action, close_icon = back, 'close' 744 q = parse_url_params() 745 ca = q.close_action 746 if ca is 'home': 747 close_action, close_icon = def(): home();, 'home' 748 elif ca is 'book_list': 749 close_action = def(): 750 show_panel('book_list', {'book_id':q.book_id}) 751 create_top_bar(container, title=_('Book details'), action=close_action, icon=close_icon) 752 window.scrollTo(0, 0) # Ensure we are at the top of the window 753 container.appendChild(E.div(class_=CLASS_NAME, tabindex='0')) 754 container.lastChild.addEventListener('keydown', onkeydown.bind(None, container_id, close_action), {'passive': False, 'capture': True}) 755 container.lastChild.focus() 756 container.lastChild.appendChild(E.div(_('Loading books from the calibre library, please wait...'), style='margin: 1ex 1em')) 757 conditional_timeout(container_id, 5, check_for_books_loaded) 758 759 760def show_subsequent_panel(name, replace=False): 761 q = parse_url_params() 762 q.book_id = (render_book.book_id or q.book_id) + '' 763 show_panel('book_details^' + name, query=q, replace=replace) 764 765 766def edit_metadata(): 767 q = parse_url_params() 768 q.book_id = (render_book.book_id or q.book_id) + '' 769 show_panel('edit_metadata', query=q, replace=False) 770 771 772def convert_book(): 773 q = parse_url_params() 774 q.book_id = (render_book.book_id or q.book_id) + '' 775 show_panel('convert_book', query=q, replace=False) 776 777 778def create_more_actions_panel(container_id): 779 container = document.getElementById(container_id) 780 create_top_bar(container, title=_('More actions…'), action=back, icon='close') 781 if get_session_data().get('show_all_metadata'): 782 title, subtitle = _('Show important metadata'), _('Show only the important metadata fields in the book details') 783 else: 784 title, subtitle = _('Show all metadata'), _('Show all metadata fields in the book details') 785 items = [ 786 create_item(_('Search the internet'), subtitle=_('Search for this author or book on various websites'), 787 action=def(): 788 show_subsequent_panel('search_internet', replace=True) 789 ), 790 791 create_item(title, subtitle=subtitle, action=toggle_fields), 792 793 create_item(_('Copy to library'), subtitle=_('Copy or move this book to another calibre library'), 794 action=def(): 795 show_subsequent_panel('copy_to_library', replace=True) 796 ), 797 ] 798 container.appendChild(E.div()) 799 create_item_list(container.lastChild, items) 800 801 802def return_to_book_details(): 803 q = parse_url_params() 804 show_panel('book_details', query=q, replace=True) 805 806 807def toggle_fields(): 808 sd = get_session_data() 809 sd.set('show_all_metadata', False if sd.get('show_all_metadata') else True) 810 return_to_book_details() 811 812 813def url_for(template, data): 814 def eqc(x): 815 return encode_query_component(x).replace(/%20/g, '+') 816 return template.format(title=eqc(data.title), author=eqc(data.author)) 817 818 819def search_internet(container_id): 820 if not render_book.book_id or not book_metadata(render_book.book_id): 821 return return_to_book_details() 822 container = document.getElementById(container_id) 823 create_top_bar(container, title=_('Search the internet'), action=back, icon='close') 824 mi = book_metadata(render_book.book_id) 825 data = {'title':mi.title, 'author':mi.authors[0] if mi.authors else _('Unknown')} 826 interface_data = get_interface_data() 827 828 def link_for(name, template): 829 return E.a(name, class_='simple-link', href=url_for(template, data), target="_blank") 830 831 author_links = E.ul() 832 book_links = E.ul() 833 834 if interface_data.search_the_net_urls: 835 for entry in interface_data.search_the_net_urls: 836 links = book_links if entry.type is 'book' else author_links 837 links.appendChild(E.li(link_for(entry.name, entry.url))) 838 for name, url in [ 839 (_('Goodreads'), 'https://www.goodreads.com/book/author/{author}'), 840 (_('Wikipedia'), 'https://en.wikipedia.org/w/index.php?search={author}'), 841 (_('Google Books'), 'https://www.google.com/search?tbm=bks&q=inauthor:%22{author}%22'), 842 ]: 843 author_links.appendChild(E.li(link_for(name, url))) 844 845 for name, url in [ 846 (_('Goodreads'), 'https://www.goodreads.com/search?q={author}+{title}&search%5Bsource%5D=goodreads&search_type=books&tab=books'), 847 (_('Google Books'), 'https://www.google.com/search?tbm=bks&q=inauthor:%22{author}%22+intitle:%22{title}%22'), 848 (_('Amazon'), 'https://www.amazon.com/s/ref=nb_sb_noss?url=search-alias%3Dstripbooks&field-keywords={author}+{title}'), 849 ]: 850 book_links.appendChild(E.li(link_for(name, url))) 851 852 container.appendChild(E.div(class_=SEARCH_INTERNET_CLASS, 853 safe_set_inner_html(E.h2(), _('Search for the author <i>{}</i> at:').format(data.author)), 854 author_links, 855 E.hr(), 856 safe_set_inner_html(E.h2(), _('Search for the book <i>{}</i> at:').format(data.title)), 857 book_links, 858 )) 859 860 861# Copy to library {{{ 862 863def do_copy_to_library(book_id, target_library_id, target_library_name): 864 865 def handle_result(move, close_func, end_type, xhr, ev): 866 close_func() 867 title = book_metadata(book_id).title 868 return_to_book_details() 869 if end_type is 'abort': 870 return 871 if end_type is not 'load': 872 error_dialog(_('Failed to copy book'), _( 873 'Failed to copy the book "{}". Click "Show details" for more information.').format(title), 874 xhr.error_html) 875 return 876 try: 877 response = JSON.parse(xhr.responseText)[book_id] 878 if not response: 879 raise Exception('bad') 880 except: 881 error_dialog(_('Failed to copy book'), _( 882 'Failed to copy the book "{}" because of an invalid response from calibre').format(title)) 883 return 884 if not response.ok: 885 error_dialog(_('Failed to copy book'), _( 886 'Failed to copy the book "{}". Click "Show details" for more information.').format(title), 887 response.payload 888 ) 889 return 890 if response.action is 'duplicate': 891 warning_dialog(_('Book already exists'), _( 892 'Could not copy as a book with the same title and authors already exists in the {} library').format(target_library_name)) 893 894 elif response.action is 'automerge': 895 warning_dialog(_('Book merged'), _( 896 'The files from the book were merged into a book with the same title and authors in the {} library').format(target_library_name)) 897 if move: 898 refresh_after_delete(book_id, current_library_id()) 899 900 901 def trigger_copy(container_id, move, close_func): 902 try: 903 choice = document.querySelector(f'#{dupes_id} input[name="dupes"]:checked').value 904 except: 905 choice = document.querySelector(f'#{dupes_id} input[name="dupes"]').value 906 sd.set('copy_to_library_dupes', choice) 907 duplicate_action, automerge_action = choice.split(';', 2) 908 909 data = {'book_ids':v'[book_id]', 'move': move, 'duplicate_action': duplicate_action, 'automerge_action': automerge_action} 910 container = document.getElementById(container_id) 911 clear(container) 912 container.appendChild(E.div( 913 _('Contacting calibre to copy book, please wait...'))) 914 ajax_send(f'cdb/copy-to-library/{target_library_id}/{current_library_id()}', 915 data, handle_result.bind(None, move, close_func)) 916 917 def radio(value, text): 918 return E.div(style='margin-top: 1rem', E.input(type='radio', name='dupes', value=value, checked=value is saved_value), '\xa0', E.span(text)) 919 920 title = book_metadata(book_id).title 921 dupes_id = unique_id() 922 sd = get_session_data() 923 saved_value = sd.get('copy_to_library_dupes') 924 create_custom_dialog(_( 925 'Copy book to "{target_library_name}"').format(target_library_name=target_library_name), 926 def (container, close_func): 927 container_id = ensure_id(container) 928 container.appendChild(E.div( 929 E.div(_('Copying: {}').format(title)), 930 E.div(id=dupes_id, 931 E.p(_('If there are already existing books in "{}" with the same title and authors,' 932 ' how would you like to handle them?').format(target_library_name)), 933 radio('add;overwrite', _('Copy anyway')), 934 radio('ignore;overwrite', _('Do not copy')), 935 radio('add_formats_to_existing;overwrite', _('Merge into existing books, overwriting existing files')), 936 radio('add_formats_to_existing;ignore', _('Merge into existing books, keeping existing files')), 937 radio('add_formats_to_existing;new record', _('Merge into existing books, putting conflicting files into a new book record')), 938 ), 939 E.div( 940 class_='button-box', 941 create_button(_('Copy'), None, trigger_copy.bind(None, container_id, False, close_func), highlight=True), 942 '\xa0', 943 create_button(_('Move'), None, trigger_copy.bind(None, container_id, True, close_func)), 944 '\xa0', 945 create_button(_('Cancel'), None, close_func), 946 ) 947 )) 948 if not container.querySelector(f'#{dupes_id} input[name="dupes"]:checked'): 949 container.querySelector(f'#{dupes_id} input[name="dupes"]').checked = True 950 951 ) 952 953 954def copy_to_library(container_id): 955 if not render_book.book_id or not book_metadata(render_book.book_id): 956 return return_to_book_details() 957 container = document.getElementById(container_id) 958 create_top_bar(container, title=_('Copy to library'), action=back, icon='close') 959 libraries = all_libraries() 960 container.appendChild(E.div(class_=COPY_TO_LIBRARY_CLASS)) 961 container = container.lastChild 962 if libraries.length < 2: 963 container.appendChild(E.div(_('There are no other calibre libraries available to copy the book to'))) 964 return 965 container.appendChild(E.h2(_('Choose the library to copy to below'))) 966 items = [] 967 for library_id, library_name in libraries: 968 if library_id is current_library_id(): 969 continue 970 items.push(create_item(library_name, action=do_copy_to_library.bind(None, render_book.book_id, library_id, library_name))) 971 container.appendChild(E.div()) 972 create_item_list(container.lastChild, items) 973# }}} 974 975 976def delete_book(): 977 start_delete_book(current_library_id(), render_book.book_id, render_book.title or _('Unknown')) 978 979set_panel_handler('book_details', init) 980set_panel_handler('book_details^more_actions', create_more_actions_panel) 981set_panel_handler('book_details^search_internet', search_internet) 982set_panel_handler('book_details^copy_to_library', copy_to_library) 983