1# vim:fileencoding=utf-8
2# License: GPL v3 Copyright: 2018, Kovid Goyal <kovid at kovidgoyal.net>
3from __python__ import bound_methods, hash_literals
4
5from elementmaker import E
6
7import traceback
8from ajax import ajax_send
9from book_list.book_details import (
10    add_stars_to, basic_table_rules, fetch_metadata, field_sorter, no_book,
11    report_load_failure
12)
13from book_list.comments_editor import (
14    create_comments_editor, focus_comments_editor, get_comments_html,
15    set_comments_html
16)
17from book_list.globals import get_current_query
18from book_list.library_data import (
19    book_metadata, cover_url, current_library_id, field_names_for, library_data,
20    load_status, loaded_book_ids, set_book_metadata
21)
22from book_list.router import back
23from book_list.theme import get_color
24from book_list.top_bar import create_top_bar, set_title
25from book_list.ui import set_panel_handler, show_panel
26from date import UNDEFINED_DATE_ISO, format_date
27from dom import add_extra_css, build_rule, clear, svgicon
28from file_uploads import (
29    update_status_widget, upload_files_widget, upload_status_widget
30)
31from gettext import gettext as _
32from modals import error_dialog, question_dialog
33from session import get_interface_data
34from utils import (
35    conditional_timeout, fmt_sidx, parse_url_params, safe_set_inner_html
36)
37from widgets import create_button
38
39CLASS_NAME = 'edit-metadata-panel'
40IGNORED_FIELDS = {'sort', 'uuid', 'id', 'urls_from_identifiers', 'lang_names', 'last_modified', 'path', 'marked', 'size', 'ondevice', 'cover', 'au_map', 'isbn'}
41def identity(x):
42    return x
43value_to_json = identity
44changes = {}
45has_changes = False
46
47add_extra_css(def():
48    sel = '.' + CLASS_NAME + ' '
49    style = basic_table_rules(sel)
50    style += build_rule(sel + 'table.metadata', margin_left='1rem')
51    style += build_rule(sel + 'table.metadata td', padding_bottom='0.5ex', padding_top='0.5ex', cursor='pointer')
52    style += build_rule(sel + 'table.metadata tr:hover', color='red')
53    style += build_rule(sel + 'table.metadata tr:active', transform='scale(1.5)')
54
55    style += build_rule(sel + '.completions', display='flex', flex_wrap='wrap', align_items='center', margin_bottom='0.5ex')
56    style += build_rule(sel + '.completions > div', margin='0.5ex 0.5rem', margin_left='0', padding='0.5ex 0.5rem', border='solid 1px currentColor', border_radius='1ex', cursor='pointer')
57    style += build_rule(sel + '.completions > div:active', transform='scale(1.5)')
58    style += build_rule(sel + '.completions > div:hover', background=get_color('window-foreground'), color=get_color('window-background'))
59
60    style += build_rule(sel + '.rating-edit-container', display='flex', flex_wrap='wrap', align_items='center', list_style_type='none')
61    style += build_rule(sel + '.rating-edit-container > li', margin='0.5ex 0.5rem', margin_left='0', padding='0.5ex 0.5rem', border='solid 1px currentColor', border_radius='1ex', cursor='pointer')
62    style += build_rule(sel + '.rating-edit-container > li.current-rating', color=get_color('window-background'), background=get_color('window-foreground'))
63    return style
64)
65
66
67def resolved_metadata(mi, field):
68    if Object.prototype.hasOwnProperty.call(changes, field):
69        return changes[field]
70    return mi[field]
71
72
73def resolved_formats(val):
74    val = list(val or v'[]')
75    if changes.added_formats:
76        for data in changes.added_formats:
77            ext = data.ext.toUpperCase()
78            if ext and ext not in val:
79                val.push(ext)
80    if changes.removed_formats:
81        for fmt in changes.removed_formats:
82            fmt = fmt.toUpperCase()
83            if fmt in val:
84                val.remove(fmt)
85    val.sort()
86    return val
87
88
89def truncated_html(val):
90    ans = val.replace(/<[^>]+>/g, '')
91    if ans.length > 40:
92        ans = ans[:40] + '…'
93    return ans
94
95
96def onsubmit_field2(container_id, book_id, field, value):
97    nonlocal has_changes
98    c = document.getElementById(container_id)
99    if not c:
100        return
101    d = c.querySelector('div[data-ctype="edit"]')
102    if not d:
103        return
104    is_series = value.series_index is not undefined
105    if is_series:
106        unchanged = value.series_name is book_metadata(book_id)[field] and value.series_index is book_metadata(book_id)[field + '_index']
107    else:
108        unchanged = value is book_metadata(book_id)[field]
109    if unchanged:
110        on_close(container_id)
111        return
112
113    clear(d)
114    d.appendChild(E.div(style='margin: 1ex 1rem', _('Contacting server, please wait') + '…'))
115    if is_series:
116        changes[field] = value_to_json(value.series_name)
117        changes[field + '_index'] = float(value.series_index)
118    else:
119        changes[field] = value_to_json(value)
120    has_changes = True
121    show_book(container_id, book_id)
122    on_close(container_id)
123
124
125def onsubmit_field(get_value, container_id, book_id, field):
126    c = document.getElementById(container_id)
127    if not c:
128        return
129    d = c.querySelector('div[data-ctype="edit"]')
130    if not d:
131        return
132    if get_value is html_edit_get_value:
133        html_edit_get_value(d, onsubmit_field2.bind(None, container_id, book_id, field))
134    else:
135        ok, value = get_value(d)
136        if not ok:
137            return
138        # needed to avoid console error about form submission failing because form is removed from DOM in onsubmit handler
139        window.setTimeout(onsubmit_field2, 0, container_id, book_id, field, value)
140
141
142def create_form(widget, get_value, container_id, book_id, field):
143    submit_action = onsubmit_field.bind(None, get_value, container_id, book_id, field)
144    button = create_button(_('OK'), action=submit_action)
145    widget.classList.add('metadata-editor')
146    form = E.form(
147        action='javascript: void(0)', onsubmit=submit_action, style='margin: 1ex auto',
148        E.div(widget, style='margin-bottom: 1ex'),
149        E.div(class_='edit-form-button-container', button), E.input(type='submit', style='display:none'),
150    )
151    return form
152
153
154# Simple line edit {{{
155
156def line_edit_get_value(container):
157    return True, container.querySelector('input[type="text"]').value
158
159
160def simple_line_edit(container_id, book_id, field, fm, div, mi):
161    nonlocal value_to_json
162    name = fm.name or field
163    le = E.input(type='text', name=name.replace('#', '_c_'), autocomplete=True, style='width: 100%')
164    le.value = resolved_metadata(mi, field) or ''
165    form = create_form(le, line_edit_get_value, container_id, book_id, field)
166    div.appendChild(E.div(style='margin: 0.5ex 1rem', _('Edit the "{}" below').format(name)))
167    div.appendChild(E.div(style='margin: 0.5ex 1rem', form))
168    le.focus(), le.select()
169    value_to_json = identity
170# }}}
171
172# Text edit {{{
173
174def text_edit_get_value(container):
175    return True, container.querySelector('textarea').value
176
177
178def text_edit(container_id, book_id, field, fm, div, mi, get_name):
179    nonlocal value_to_json
180    name = fm.name or field
181    le = E.textarea(name=name.replace('#', '_c_'), spellcheck='true', wrap='soft', style='width: 100%; min-height: 70vh')
182    le.value = resolved_metadata(mi, get_name or field) or ''
183    form = create_form(le, text_edit_get_value, container_id, book_id, field)
184    div.appendChild(E.div(style='margin: 0.5ex 1rem', _('Edit the "{}" below').format(name)))
185    div.appendChild(E.div(style='margin: 0.5ex 1rem', form))
186    le.focus()
187
188
189def html_edit_get_value(container, proceed):
190    get_comments_html(container, proceed)
191
192
193def html_edit(container_id, book_id, field, fm, div, mi):
194    nonlocal value_to_json
195    value_to_json = identity
196    val = resolved_metadata(mi, field) or ''
197    name = fm.name or field
198    c = E.div(style='width: 100%; min-height: 75vh')
199    form = create_form(c, html_edit_get_value, container_id, book_id, field)
200    editor = create_comments_editor(c)
201    set_comments_html(c, val)
202    div.appendChild(E.div(style='margin: 0.5ex 1rem', _('Edit the "{}" below').format(name)))
203    div.appendChild(E.div(style='margin: 0.5ex 1rem', form))
204    focus_comments_editor(c)
205    value_to_json = identity
206    editor.init()
207# }}}
208
209# Number edit {{{
210
211def number_edit_get_value(container):
212    return True, container.querySelector('input[type="number"]').value
213
214
215def number_edit(container_id, book_id, field, fm, div, mi):
216    nonlocal value_to_json
217    name = fm.name or field
218    le = E.input(type='number', name=name.replace('#', '_c_'), step='any' if fm.datatype is 'float' else '1')
219    val = resolved_metadata(mi, field)
220    if val?:
221        le.value = val
222    else:
223        le.value = ''
224    form = create_form(le, number_edit_get_value, container_id, book_id, field)
225    div.appendChild(E.div(style='margin: 0.5ex 1rem', _('Edit the "{}" below').format(name)))
226    div.appendChild(E.div(style='margin: 0.5ex 1rem', form))
227    le.focus(), le.select()
228
229    def safe_parse(x):
230        f = parseFloat if fm.datatype is 'float' else parseInt
231        ans = f(x)
232        if isNaN(ans):
233            ans = None
234        return ans
235    value_to_json = safe_parse
236# }}}
237
238# Line edit with completions {{{
239
240def remove_item(container_id, name):
241    c = document.getElementById(container_id)
242    if not c:
243        return
244    le = c.querySelector('[data-ctype="edit"] input')
245    val = le.value or ''
246    val = value_to_json(val)
247    val = [x for x in val if x is not name]
248    le.value = val.join(update_completions.list_to_ui)
249    le.focus()
250    line_edit_updated(container_id, le.dataset.field)
251
252
253def add_completion(container_id, name):
254    c = document.getElementById(container_id)
255    if not c:
256        return
257    le = c.querySelector('[data-ctype="edit"] input')
258    val = le.value or ''
259    val = value_to_json(val)
260    if jstype(val) is 'string':
261        le.value = name
262    elif val:
263        if val.length:
264            val[-1] = name
265        else:
266            val.push(name)
267        le.value = val.join(update_completions.list_to_ui) + update_completions.list_to_ui
268    le.focus()
269    line_edit_updated(container_id, le.dataset.field)
270
271
272def show_completions(container_id, div, field, prefix, names):
273    clear(div)
274    completions = E.div(class_='completions')
275    if names.length:
276        div.appendChild(E.div(_('Tap to add:')))
277    div.appendChild(completions)
278    for i, name in enumerate(names):
279        completions.appendChild(E.div(E.span(style='color: green', svgicon('plus'), '\xa0'), name, onclick=add_completion.bind(None, container_id, name)))
280        if i >= 50:
281            break
282
283
284def query_contains(haystack, needle):
285    return haystack.toLowerCase().indexOf(needle) is not -1
286
287
288def query_startswitch(haystack, needle):
289    return haystack.toLowerCase().indexOf(needle) is 0
290
291
292def update_removals(container_id):
293    c = document.getElementById(container_id)
294    if not c:
295        return
296    d = c.querySelector('div[data-ctype="edit"]')
297    if not d or d.style.display is not 'block':
298        return
299    div = d.lastChild.previousSibling
300    clear(div)
301    val = d.querySelector('input')?.value or ''
302    val = value_to_json(val)
303    if jstype(val) is 'string' or not val.length:
304        return
305    div.appendChild(E.div(_('Tap to remove:')))
306    removals = E.div(class_='completions')
307    div.appendChild(removals)
308    for i, name in enumerate(val):
309        removals.appendChild(E.div(E.span(style='color: ' + get_color('window-error-foreground'), svgicon('eraser'), '\xa0'), name, onclick=remove_item.bind(None, container_id, name)))
310        if i >= 50:
311            break
312
313
314def update_completions(container_id, ok, field, names):
315    c = document.getElementById(container_id)
316    if not c:
317        return
318    d = c.querySelector('div[data-ctype="edit"]')
319    if not d or d.style.display is not 'block':
320        return
321    div = d.lastChild
322    clear(div)
323    if not ok:
324        err = E.div()
325        safe_set_inner_html(err, names)
326        div.appendChild(E.div(
327            _('Failed to download items for completion, with error:'), err
328        ))
329        return
330    val = d.querySelector('input').value or ''
331    val = value_to_json(val)
332    if jstype(val) is 'string':
333        prefix = val
334    else:
335        prefix = val[-1] if val.length else ''
336    if prefix is update_completions.prefix:
337        return
338    needle = prefix.toLowerCase().strip()
339
340    if needle:
341        interface_data = get_interface_data()
342        universe = update_completions.names if update_completions.prefix and needle.startswith(update_completions.prefix.toLowerCase()) else names
343        q = query_contains if interface_data.completion_mode is 'contains' else query_startswitch
344        matching_names = [x for x in universe if q(x, needle) and x is not prefix]
345    else:
346        matching_names = []
347    update_completions.prefix = prefix
348    update_completions.names = matching_names
349    show_completions(container_id, div, field, prefix, matching_names)
350
351
352update_completions.list_to_ui = None
353update_completions.names = v'[]'
354update_completions.prefix = ''
355
356
357def line_edit_updated(container_id, field):
358    field_names_for(field, update_completions.bind(None, container_id))
359    update_removals(container_id)
360
361
362def multiple_line_edit(list_to_ui, ui_to_list, container_id, book_id, field, fm, div, mi):
363    nonlocal value_to_json
364    update_completions.list_to_ui = list_to_ui
365    name = fm.name or field
366    le = E.input(
367        type='text', name=name.replace('#', '_c_'),
368        style='width: 100%', autocomplete='off', data_field=field,
369        oninput=line_edit_updated.bind(None, container_id, field)
370    )
371    val = (resolved_metadata(mi, field) or v'[]')
372    if field is 'languages':
373        val = [mi.lang_names[l] or l for l in val]
374    if list_to_ui:
375        val = val.join(list_to_ui)
376    le.value = val
377    form = create_form(le, line_edit_get_value, container_id, book_id, field)
378    if list_to_ui:
379        div.appendChild(E.div(style='margin: 0.5ex 1rem', _(
380            'Edit the "{0}" below. Multiple items can be separated by "{1}".').format(name, list_to_ui.strip())))
381    else:
382        div.appendChild(E.div(style='margin: 0.5ex 1rem', _(
383            'Edit the "{0}" below.').format(name)))
384    div.appendChild(E.div(style='margin: 0.5ex 1rem', form))
385    div.appendChild(E.div(style='margin: 0.5ex 1rem'))
386    div.appendChild(E.div(E.span(_('Loading all {}...').format(name)), style='margin: 0.5ex 1rem'))
387    le.focus(), le.select()
388    if list_to_ui:
389        value_to_json = def(raw):
390            seen = {}
391            ans = v'[]'
392            for x in [a.strip() for a in raw.split(ui_to_list) if a.strip()]:
393                if not seen[x]:
394                    seen[x] = True
395                    ans.push(x)
396            return ans
397    else:
398        value_to_json = identity
399    field_names_for(field, update_completions.bind(None, container_id))
400    update_removals(container_id)
401# }}}
402
403# Series edit {{{
404
405def series_edit_get_value(container):
406    val = {
407        'series_name': container.querySelector('input[type="text"]').value,
408        'series_index': parseFloat(parseFloat(container.querySelector('input[type="number"]').value).toFixed(2)),
409    }
410    return True, val
411
412
413def series_edit(container_id, book_id, field, fm, div, mi):
414    nonlocal value_to_json
415    name = fm.name or field
416    le = E.input(type='text', name=name.replace('#', '_c_'), style='width: 100%', oninput=line_edit_updated.bind(None, container_id, field), data_field=field)
417    le.value = resolved_metadata(mi, field) or ''
418    value_to_json = identity
419    ne = E.input(type='number', step='any', name=name.replace('#', '_c_') + '_index')
420    ne.value = parseFloat(parseFloat(resolved_metadata(mi, field + '_index')).toFixed(2))
421    table = E.table(style='width: 100%',
422        E.tr(E.td(_('Name:') + '\xa0'), E.td(le, style='width: 99%; padding-bottom: 1ex')),
423        E.tr(E.td(_('Number:') + '\xa0'), E.td(ne))
424    )
425    form = create_form(table, series_edit_get_value, container_id, book_id, field)
426    div.appendChild(E.div(style='margin: 0.5ex 1rem', _('Edit the "{}" below.').format(name)))
427    div.appendChild(E.div(style='margin: 0.5ex 1rem', form))
428    div.appendChild(E.div(style='margin: 0.5ex 1rem'))
429    div.appendChild(E.div(E.span(_('Loading all {}...').format(name)), style='margin: 0.5ex 1rem'))
430    le.focus(), le.select()
431    field_names_for(field, update_completions.bind(None, container_id))
432# }}}
433
434# Date edit {{{
435
436def date_edit_get_value(container):
437    return True, container.querySelector('input[type="date"]').value
438
439def date_to_datetime(raw):
440    if not raw:
441        return UNDEFINED_DATE_ISO
442    return raw + 'T12:00:00+00:00'  # we use 12 so that the date is the same in most timezones
443
444
445def date_edit(container_id, book_id, field, fm, div, mi):
446    nonlocal value_to_json
447    value_to_json = date_to_datetime
448    name = fm.name or field
449    le = E.input(type='date', name=name.replace('#', '_c_'), min=UNDEFINED_DATE_ISO.split('T')[0], pattern="[0-9]{4}-[0-9]{2}-[0-9]{2}")
450    val = resolved_metadata(mi, field) or ''
451    if val:
452        val = format_date(val, 'yyyy-MM-dd')
453    le.value = val or ''
454    form = create_form(le, date_edit_get_value, container_id, book_id, field)
455
456    def clear(ev):
457        ev.currentTarget.closest('form').querySelector('input').value = ''
458
459    def today(ev):
460        ev.currentTarget.closest('form').querySelector('input').value = Date().toISOString().substr(0, 10)
461
462    form.firstChild.appendChild(
463        E.span(
464        '\xa0',
465        create_button(_('Clear'), action=clear),
466        '\xa0',
467        create_button(_('Today'), action=today),
468    ))
469    div.appendChild(E.div(style='margin: 0.5ex 1rem', _('Edit the "{}" below.').format(name)))
470    div.appendChild(E.div(style='margin: 0.5ex 1rem', form))
471    le.focus(), le.select()
472
473# }}}
474
475# Identifiers edit {{{
476
477def remove_identifier(evt):
478    li = evt.currentTarget.closest('li')
479    li.parentNode.removeChild(li)
480
481
482def add_identifier(container_id, name, val):
483    c = document.getElementById(container_id)?.querySelector('.identifiers-edit')
484    if not c:
485        return
486    b = create_button(_('Remove'), action=remove_identifier)
487    c.appendChild(E.li(style='padding-bottom: 1ex; margin-bottom: 1ex; border-bottom: solid 1px currentColor',
488        E.table(style='width: 100%',
489            E.tr(E.td(_('Type:') + '\xa0'), E.td(style='padding-bottom: 1ex; width: 99%', E.input(type='text', style='width:100%', autocomplete=True, value=name or '')), E.td(rowspan='2', style='padding: 0.5ex 1rem; vertical-align: middle', b)),
490
491            E.tr(E.td(_('Value:') + '\xa0'), E.td(E.input(type='text', style='width: 100%', autocomplete=True, value=val or ''))),
492    )))
493
494
495def identifiers_get_value(container):
496    ans = {}
497    for li in container.querySelectorAll('.identifiers-edit > li'):
498        n, v = li.querySelectorAll('input')
499        n, v = n.value, v.value
500        if n and v:
501            ans[n] = v
502    return True, ans
503
504
505def identifiers_edit(container_id, book_id, field, fm, div, mi):
506    nonlocal value_to_json
507    name = fm.name or field
508    val = resolved_metadata(mi, 'identifiers') or {}
509    c = E.ul(class_='identifiers-edit', style='list-style-type: none')
510    form = create_form(c, identifiers_get_value, container_id, book_id, field)
511    bc = form.querySelector('.edit-form-button-container')
512    bc.insertBefore(create_button(_('Add identifier'), None, add_identifier.bind(None, container_id, '', '')), bc.firstChild)
513    bc.insertBefore(document.createTextNode('\xa0'), bc.lastChild)
514    div.appendChild(E.div(style='margin: 0.5ex 1rem', _('Edit the "{}" below.').format(name)))
515    div.appendChild(E.div(style='margin: 0.5ex 1rem', form))
516    for k in Object.keys(val):
517        add_identifier(container_id, k, val[k])
518    value_to_json = identity
519# }}}
520
521# Rating edit {{{
522
523def rating_get_value(container):
524    return True, parseInt(container.querySelector('.current-rating[data-rating]').getAttribute('data-rating'))
525
526
527def set_rating(evt):
528    li = evt.currentTarget
529    for nli in li.closest('ul').childNodes:
530        nli.classList.remove('current-rating')
531    li.classList.add('current-rating')
532
533
534def rating_edit(container_id, book_id, field, fm, div, mi):
535    nonlocal value_to_json
536    val = resolved_metadata(mi, field) or 0
537    numbers = list(range(11)) if fm.display?.allow_half_stars else list(range(0, 11, 2))
538    name = fm.name or field
539    c = E.ul(class_='rating-edit-container')
540    for n in numbers:
541        s = E.li(data_rating=n + '', onclick=set_rating)
542        c.appendChild(s)
543        if n is val:
544            s.classList.add('current-rating')
545        if n:
546            add_stars_to(s, n, numbers.length > 6)
547        else:
548            s.appendChild(document.createTextNode(_('Unrated')))
549    form = create_form(c, rating_get_value, container_id, book_id, field)
550    div.appendChild(E.div(style='margin: 0.5ex 1rem', _('Choose the "{}" below').format(name)))
551    div.appendChild(E.div(style='margin: 0.5ex 1rem', form))
552    value_to_json = identity
553# }}}
554
555# Enum edit {{{
556
557def enum_get_value(container):
558    return True, container.querySelector('.current-rating[data-rating]').getAttribute('data-rating')
559
560
561def enum_edit(container_id, book_id, field, fm, div, mi):
562    nonlocal value_to_json
563    val = resolved_metadata(mi, field) or ''
564    name = fm.name or field
565    c = E.ul(class_='rating-edit-container')
566    for n in v'[""]'.concat(fm.display.enum_values):
567        s = E.li(data_rating=n + '', onclick=set_rating)
568        c.appendChild(s)
569        if n is val:
570            s.classList.add('current-rating')
571        s.appendChild(document.createTextNode(n or _('Blank')))
572    form = create_form(c, enum_get_value, container_id, book_id, field)
573    div.appendChild(E.div(style='margin: 0.5ex 1rem', _('Choose the "{}" below').format(name)))
574    div.appendChild(E.div(style='margin: 0.5ex 1rem', form))
575    value_to_json = identity
576# }}}
577
578# Bool edit {{{
579
580def bool_edit(container_id, book_id, field, fm, div, mi):
581    nonlocal value_to_json
582    val = resolved_metadata(mi, field)
583    if val:
584        val = 'y'
585    else:
586        if val is False:
587            val = 'n'
588        else:
589            val = ''
590    name = fm.name or field
591    c = E.ul(class_='rating-edit-container')
592    names = {'': _('Blank'), 'y': _('Yes'), 'n': _('No')}
593    for n in v"['', 'y', 'n']":
594        s = E.li(data_rating=n + '', onclick=set_rating)
595        c.appendChild(s)
596        if n is val:
597            s.classList.add('current-rating')
598        s.appendChild(document.createTextNode(names[n]))
599    form = create_form(c, enum_get_value, container_id, book_id, field)
600    div.appendChild(E.div(style='margin: 0.5ex 1rem', _('Choose the "{}" below').format(name)))
601    div.appendChild(E.div(style='margin: 0.5ex 1rem', form))
602    val_map = {'': None, 'y': True, 'n': False}
603    value_to_json = def(x):
604        return val_map[x]
605# }}}
606
607# Cover edit {{{
608
609def cover_chosen(top_container_id, book_id, container_id, files):
610    nonlocal has_changes
611    container = document.getElementById(container_id)
612    if not container:
613        return
614    if not files[0]:
615        return
616    file = files[0]
617    changes.cover = file
618    has_changes = True
619    on_close(top_container_id)
620cover_chosen.counter = 0
621
622
623def remove_cover(top_container_id, book_id):
624    nonlocal has_changes
625    changes.cover = '--remove--'
626    has_changes = True
627    on_close(top_container_id)
628
629
630def cover_edit(container_id, book_id, field, fm, div, mi):
631    upload_files_widget(div, cover_chosen.bind(None, container_id, book_id), _(
632        'Change the cover by <a>selecting the cover image</a> or drag and drop of the cover image here.'),
633    single_file=True, accept_extensions='png jpeg jpg')
634    div.appendChild(E.div(
635        style='padding: 1rem',
636        create_button(_('Remove existing cover'), action=remove_cover.bind(None, container_id, book_id))))
637
638# }}}
639
640# Formats edit {{{
641
642def format_added(top_container_id, book_id, container_id, files):
643    nonlocal has_changes
644    container = document.getElementById(container_id)
645    if not container:
646        return
647    if not files[0]:
648        return
649    added = changes.added_formats or v'[]'
650    for file in files:
651        ext = file.name.rpartition('.')[-1]
652        data = {'name': file.name, 'size': file.size, 'type': file.type, 'data_url': None, 'ext': ext}
653        added.push(data)
654        r = FileReader()
655        r.onload = def(evt):
656            data.data_url = evt.target.result
657        r.readAsDataURL(file)
658    changes.added_formats = added
659    has_changes = True
660    on_close(top_container_id)
661
662
663def remove_format(top_container_id, book_id, fmt):
664    nonlocal has_changes
665    has_changes = True
666    removed_formats = changes.removed_formats or v'[]'
667    removed_formats.push(fmt.toUpperCase())
668    changes.removed_formats = removed_formats
669    on_close(top_container_id)
670
671
672def formats_edit(container_id, book_id, field, fm, div, mi):
673    upload_files_widget(div, format_added.bind(None, container_id, book_id), _(
674        'Add a format by <a>selecting the book file</a> or drag and drop of the book file here.'),
675    single_file=True)
676    remove_buttons = E.div(style='padding: 1rem; display: flex; flex-wrap: wrap; align-content: flex-start')
677    formats = resolved_formats(mi.formats)
678    for i, fmt in enumerate(formats):
679        remove_buttons.appendChild(create_button(
680            _('Remove {}').format(fmt.upper()), action=remove_format.bind(None, container_id, book_id, fmt.upper())))
681        remove_buttons.lastChild.style.marginBottom = '1ex'
682        remove_buttons.lastChild.style.marginRight = '1rem'
683
684    div.appendChild(remove_buttons)
685
686# }}}
687
688def edit_field(container_id, book_id, field):
689    nonlocal value_to_json
690    fm = library_data.field_metadata[field]
691    c = document.getElementById(container_id)
692    mi = book_metadata(book_id)
693    if not c or not fm or not mi:
694        return
695    d = c.querySelector('div[data-ctype="edit"]')
696    d.style.display = 'block'
697    d.previousSibling.style.display = 'none'
698    clear(d)
699    update_completions.ui_to_list = None
700    update_completions.list_to_ui = None
701    update_completions.names = v'[]'
702    update_completions.prefix = ''
703    if field is 'authors':
704        multiple_line_edit(' & ', '&', container_id, book_id, field, fm, d, mi)
705    elif field is 'cover':
706        cover_edit(container_id, book_id, field, fm, d, mi)
707    elif field is 'formats':
708        formats_edit(container_id, book_id, field, fm, d, mi)
709    elif fm.datatype is 'series':
710        series_edit(container_id, book_id, field, fm, d, mi)
711    elif fm.datatype is 'datetime':
712        date_edit(container_id, book_id, field, fm, d, mi)
713    elif fm.datatype is 'rating':
714        rating_edit(container_id, book_id, field, fm, d, mi)
715    elif fm.datatype is 'enumeration':
716        enum_edit(container_id, book_id, field, fm, d, mi)
717    elif fm.datatype is 'bool':
718        bool_edit(container_id, book_id, field, fm, d, mi)
719    elif fm.datatype is 'int' or fm.datatype is 'float':
720        number_edit(container_id, book_id, field, fm, d, mi)
721    elif field is 'identifiers':
722        identifiers_edit(container_id, book_id, field, fm, d, mi)
723    elif fm.datatype is 'comments' or field is 'comments':
724        ias = fm.display?.interpret_as
725        if ias is 'short-text':
726            simple_line_edit(container_id, book_id, field, fm, d, mi)
727        elif ias is 'long-text':
728            text_edit(container_id, book_id, field, fm, d, mi)
729        elif ias is 'markdown':
730            text_edit(container_id, book_id, field, fm, d, mi, field + '#markdown#')
731        else:
732            html_edit(container_id, book_id, field, fm, d, mi)
733    else:
734        if fm.link_column:
735            multiple_line_edit(fm.is_multiple?.list_to_ui, fm.is_multiple?.ui_to_list, container_id, book_id, field, fm, d, mi)
736        else:
737            simple_line_edit(container_id, book_id, field, fm, d, mi)
738    if field is 'title':
739        value_to_json = def(x):
740            return x or _('Untitled')
741    elif field is 'authors':
742        value_to_json = def(x):
743            ans = [a.strip() for a in x.split('&') if a.strip()]
744            if not ans.length:
745                ans = [_('Unknown')]
746            return ans
747
748
749def render_metadata(mi, table, container_id, book_id):  # {{{
750    field_metadata = library_data.field_metadata
751    interface_data = get_interface_data()
752    current_edit_action = None
753
754    def allowed_fields(field):
755        fm = field_metadata[field]
756        if not fm:
757            return False
758        if field.endswith('_index'):
759            pfm = field_metadata[field[:-len('_index')]]
760            if pfm and pfm.datatype is 'series':
761                return False
762        if fm.datatype is 'composite':
763            return False
764        if field.startswith('#'):
765            return True
766        if field in IGNORED_FIELDS or field.endswith('_sort') or field[0] is '@':
767            return False
768        return True
769
770    fields = library_data.book_display_fields
771    if not fields or not fields.length:
772        fields = sorted(filter(allowed_fields, mi), key=field_sorter(field_metadata))
773    else:
774        fields = filter(allowed_fields, fields)
775    fields = list(fields)
776    added_fields = {f:True for f in fields}
777    if not added_fields.title:
778        added_fields.title = True
779        fields.insert(0, 'title')
780    for other_field in Object.keys(library_data.field_metadata):
781        if not added_fields[other_field] and allowed_fields(other_field) and other_field not in IGNORED_FIELDS:
782            fields.push(other_field)
783
784    def add_row(name, val, is_html=False, join=None):
785        if val is undefined or val is None:
786            val = v'[" "]' if join else '\xa0'
787        def add_val(v):
788            if not v.appendChild:
789                v += ''
790            if v.appendChild:
791                table.lastChild.lastChild.appendChild(v)
792            else:
793                table.lastChild.lastChild.appendChild(document.createTextNode(v))
794
795        table.appendChild(E.tr(onclick=current_edit_action, E.td(name + ':'), E.td()))
796        if is_html:
797            table.lastChild.lastChild.appendChild(document.createTextNode(truncated_html(val + '')))
798        else:
799            if not join:
800                add_val(val)
801            else:
802                for v in val:
803                    add_val(v)
804                    if v is not val[-1]:
805                        table.lastChild.lastChild.appendChild(document.createTextNode(join))
806        return table.lastChild.lastChild
807
808    def process_composite(field, fm, name, val):
809        if fm.display and fm.display.contains_html:
810            add_row(name, val, is_html=True)
811        elif fm.is_multiple and fm.is_multiple.list_to_ui:
812            all_vals = filter(None, map(str.strip, val.split(fm.is_multiple.list_to_ui)))
813            add_row(name, all_vals, join=fm.is_multiple.list_to_ui)
814        else:
815            add_row(name, val)
816
817    def process_authors(field, fm, name, val):
818        add_row(name, val, join=' & ')
819
820    def process_publisher(field, fm, name, val):
821        add_row(name, val)
822
823    def process_rating(field, fm, name, val):
824        stars = E.span()
825        val = int(val or 0)
826        if val > 0:
827            for i in range(val // 2):
828                stars.appendChild(svgicon('star'))
829            if fm.display.allow_half_stars and (val % 2):
830                stars.appendChild(svgicon('star-half'))
831            add_row(name, stars)
832        else:
833            add_row(name, None)
834
835    def process_identifiers(field, fm, name, val):
836        if val:
837            keys = Object.keys(val)
838            if keys.length:
839                table.appendChild(E.tr(onclick=current_edit_action, E.td(name + ':'), E.td()))
840                td = table.lastChild.lastChild
841                for k in keys:
842                    if td.childNodes.length:
843                        td.appendChild(document.createTextNode(', '))
844                    td.appendChild(document.createTextNode(k))
845                return
846        add_row(name, None)
847
848    def process_languages(field, fm, name, val):
849        if val and val.length:
850            table.appendChild(E.tr(onclick=current_edit_action, E.td(name + ':'), E.td()))
851            td = table.lastChild.lastChild
852            for k in val:
853                lang = mi.lang_names[k] or k
854                td.appendChild(document.createTextNode(lang))
855                if k is not val[-1]:
856                    td.appendChild(document.createTextNode(', '))
857            return
858        add_row(name, None)
859
860    def process_datetime(field, fm, name, val):
861        if val:
862            fmt = interface_data['gui_' + field + '_display_format'] or (fm['display'] or {}).date_format
863            add_row(name, format_date(val, fmt))
864        else:
865            add_row(name, None)
866
867    def process_series(field, fm, name, val):
868        if val:
869            ifield = field + '_index'
870            try:
871                ival = float(resolved_metadata(mi, ifield))
872            except Exception:
873                ival = 1.0
874            ival = fmt_sidx(ival, use_roman=interface_data.use_roman_numerals_for_series_number)
875            table.appendChild(E.tr(onclick=current_edit_action, E.td(name + ':'), E.td()))
876            s = safe_set_inner_html(E.span(), _('{0} of <i>{1}</i>').format(ival, val))
877            table.lastChild.lastChild.appendChild(s)
878        else:
879            add_row(name, None)
880
881    def process_formats(field, fm, name, val):
882        val = resolved_formats(val)
883        if val.length:
884            join = fm.is_multiple.list_to_ui if fm.is_multiple else None
885            add_row(name, val, join=join)
886        else:
887            add_row(name, None)
888
889    def process_field(field, fm):
890        name = fm.name or field
891        datatype = fm.datatype
892        val = resolved_metadata(mi, field)
893        if field is 'comments' or datatype is 'comments':
894            add_row(name, truncated_html(val or ''))
895            return
896        func = None
897        if datatype is 'composite':
898            func = process_composite
899        elif datatype is 'rating':
900            func = process_rating
901        elif field is 'identifiers':
902            func = process_identifiers
903        elif field is 'authors':
904            func = process_authors
905        elif field is 'publisher':
906            func = process_publisher
907        elif field is 'languages':
908            func = process_languages
909        elif field is 'formats':
910            func = process_formats
911        elif datatype is 'datetime':
912            func = process_datetime
913        elif datatype is 'series':
914            func = process_series
915        if func:
916            func(field, fm, name, val)
917        else:
918            if datatype is 'text' or datatype is 'enumeration':
919                if val is not undefined and val is not None:
920                    join = fm.is_multiple.list_to_ui if fm.is_multiple else None
921                    add_row(name, val, join=join)
922                else:
923                    add_row(name, None)
924            elif datatype is 'bool':
925                add_row(name, _('Yes') if val else (_('No') if val? else ''))
926            elif datatype is 'int' or datatype is 'float':
927                if val is not undefined and val is not None:
928                    fmt = (fm.display or {}).number_format
929                    if fmt:
930                        val = fmt.format(val)
931                    else:
932                        val += ''
933                    add_row(name, val)
934                else:
935                    add_row(name, None)
936
937    for field in fields:
938        fm = field_metadata[field]
939        if not fm:
940            continue
941        current_edit_action = edit_field.bind(None, container_id, book_id, field)
942        try:
943            process_field(field, fm)
944        except Exception:
945            print('Failed to render metadata field: ' + field)
946            traceback.print_exc()
947
948    current_edit_action = edit_field.bind(None, container_id, book_id, 'cover')
949    table.appendChild(E.tr(onclick=current_edit_action, E.td(_('Cover') + ':'), E.td()))
950    img = E.img(
951        style='max-width: 300px; max-height: 400px',
952    )
953    if changes.cover:
954        if changes.cover is '--remove--':
955            img.removeAttribute('src')
956            changes.cover = None
957        else:
958            r = FileReader()
959            r.onload = def(evt):
960                img.src = evt.target.result
961                changes.cover = evt.target.result
962            r.readAsDataURL(changes.cover)
963            v'delete changes.cover'
964    else:
965        img.src = cover_url(book_id)
966    table.lastChild.lastChild.appendChild(img)
967# }}}
968
969
970def changes_submitted(container_id, book_id, end_type, xhr, ev):
971    nonlocal changes, has_changes
972    changes = {}
973    has_changes = False
974    if end_type is 'abort':
975        on_close(container_id)
976        return
977    if end_type is not 'load':
978        error_dialog(_('Failed to update metadata on server'), _(
979            'Updating metadata for the book: {} failed.').format(book_id), xhr.error_html)
980        return
981    try:
982        dirtied = JSON.parse(xhr.responseText)
983    except Exception as err:
984        error_dialog(_('Could not update metadata for book'), _('Server returned an invalid response'), err.toString())
985        return
986
987    cq = get_current_query()
988    if cq.from_read_book:
989        window.parent.postMessage(
990            {'type': 'update_cached_book_metadata', 'library_id': cq.library_id, 'book_id': cq.book_id, 'metadata': dirtied[book_id], 'from_read_book': cq.from_read_book},
991            document.location.protocol + '//' + document.location.host
992        )
993    else:
994        for bid in dirtied:
995            set_book_metadata(bid, dirtied[bid])
996    on_close(container_id)
997
998
999def on_progress(container_id, book_id, loaded, total, xhr):
1000    container = document.getElementById(container_id)
1001    if container and total:
1002        update_status_widget(container, loaded, total)
1003
1004
1005def submit_changes(container_id, book_id):
1006    c = document.getElementById(container_id)
1007    d = c.querySelector('div[data-ctype="show"]')
1008    clear(d)
1009    d.appendChild(E.div(style='margin: 1ex 1rem', _('Uploading changes to server, please wait...')))
1010    data = {'changes': changes, 'loaded_book_ids': loaded_book_ids()}
1011    w = upload_status_widget()
1012    d.appendChild(w)
1013    ajax_send(
1014        f'cdb/set-fields/{book_id}/{current_library_id()}', data, changes_submitted.bind(None, container_id, book_id), on_progress.bind(None, container_id, book_id))
1015
1016
1017def show_book(container_id, book_id):
1018    container = document.getElementById(container_id)
1019    mi = book_metadata(book_id)
1020    if not container or not mi:
1021        return
1022    div = container.querySelector('div[data-ctype="show"]')
1023    if not div:
1024        return
1025    clear(div)
1026    if has_changes:
1027        b = create_button(_('Apply changes'), action=submit_changes.bind(None, container_id, book_id))
1028        div.appendChild(E.div(style='margin: 1ex 1rem', b))
1029    else:
1030        div.appendChild(E.div(style='margin: 1ex 1rem', _(
1031            'Tap any field below to edit it')))
1032    div.appendChild(E.table(class_='metadata'))
1033    render_metadata(mi, div.lastChild, container_id, book_id)
1034    if has_changes:
1035        b = create_button(_('Apply changes'), action=submit_changes.bind(None, container_id, book_id))
1036        div.appendChild(E.div(style='margin: 1ex 1rem', b))
1037
1038
1039def on_close(container_id):
1040    c = document.getElementById(container_id)
1041    if c:
1042        d = c.querySelector('div[data-ctype="edit"]')
1043        if d:
1044            if d.style.display is 'block':
1045                d.style.display = 'none'
1046                d.previousSibling.style.display = 'block'
1047                clear(d), clear(d.previousSibling)
1048                q = parse_url_params()
1049                show_book(container_id, int(q.book_id))
1050                c.querySelector(f'.{CLASS_NAME}').focus()
1051                return
1052        cq = get_current_query()
1053        if cq.from_read_book:
1054            window.parent.postMessage(
1055                {'type': 'edit_metadata_closed', 'from_read_book': cq.from_read_book},
1056                document.location.protocol + '//' + document.location.host
1057            )
1058        else:
1059            if has_changes:
1060                question_dialog(_('Are you sure'), _(
1061                    'Any changes you have made will be discarded.'
1062                    ' Do you wish to discard all changes?'), def (yes):
1063                        if yes:
1064                            back()
1065                )
1066            else:
1067                back()
1068
1069
1070def proceed_after_succesful_fetch_metadata(container_id, book_id):
1071    nonlocal changes, has_changes
1072    changes = {}
1073    has_changes = False
1074    container = document.getElementById(container_id)
1075    mi = book_metadata(book_id)
1076    if not mi or not container:
1077        if get_current_query().from_read_book:
1078            container.textContent = _('Failed to read metadata for book')
1079            return
1080        show_panel('book_details', query=parse_url_params(), replace=True)
1081        return
1082    set_title(container, _('Edit metadata for {}').format(mi.title))
1083    clear(container.lastChild)
1084    container.lastChild.appendChild(E.div(data_ctype='show', style='display:block'))
1085    container.lastChild.appendChild(E.div(data_ctype='edit', style='display:none'))
1086    show_book(container_id, book_id)
1087
1088
1089def create_edit_metadata(container):
1090    q = parse_url_params()
1091    current_book_id = q.book_id
1092    if not current_book_id:
1093        no_book(container)
1094        return
1095    current_book_id = int(current_book_id)
1096    container_id = container.parentNode.id
1097    if not book_metadata(current_book_id):
1098        fetch_metadata(container_id, current_book_id, proceed_after_succesful_fetch_metadata)
1099    else:
1100        proceed_after_succesful_fetch_metadata(container_id, current_book_id)
1101
1102
1103def check_for_books_loaded():
1104    container = this
1105    if load_status.loading:
1106        conditional_timeout(container.id, 5, check_for_books_loaded)
1107        return
1108    container = container.lastChild
1109    clear(container)
1110    if not load_status.ok:
1111        report_load_failure(container)
1112        return
1113    create_edit_metadata(container)
1114
1115
1116def handle_keypress(container_id, ev):
1117    if not ev.altKey and not ev.ctrlKey and not ev.metaKey and not ev.shiftKey:
1118        if ev.key is 'Escape':
1119            ev.preventDefault(), ev.stopPropagation()
1120            on_close(container_id)
1121
1122
1123def init(container_id):
1124    container = document.getElementById(container_id)
1125    create_top_bar(container, title=_('Edit metadata'), action=on_close.bind(None, container_id), icon='close')
1126    container.appendChild(E.div(class_=CLASS_NAME, tabindex='0', onkeydown=handle_keypress.bind(None, container_id)))
1127    container.lastChild.focus()
1128    container.lastChild.appendChild(E.div(_('Loading books from the calibre library, please wait...'), style='margin: 1ex 1em'))
1129    conditional_timeout(container_id, 5, check_for_books_loaded)
1130
1131
1132set_panel_handler('edit_metadata', init)
1133