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