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