1# vim:fileencoding=utf-8 2# License: GPL v3 Copyright: 2017, Kovid Goyal <kovid at kovidgoyal.net> 3# globals: NodeFilter 4from __python__ import bound_methods, hash_literals 5 6from elementmaker import E 7from gettext import gettext as _ 8 9from book_list.details_list import sandbox_css, BORDER_RADIUS 10from book_list.library_data import library_data 11from date import format_date 12from dom import build_rule, clear, set_css, svgicon 13from session import get_interface_data 14from utils import fmt_sidx, safe_set_inner_html, sandboxed_html 15 16CUSTOM_LIST_CLASS = 'book-list-custom-list' 17ITEM_CLASS = CUSTOM_LIST_CLASS + '-item' 18 19def description(): 20 return _('A customizable list (see Preferences->Sharing over the net->Book list template)') 21 22def custom_list_css(): 23 ans = '' 24 sel = '.' + CUSTOM_LIST_CLASS 25 ans += build_rule(sel, cursor='pointer', user_select='none') 26 sel += ' .' + ITEM_CLASS 27 ans += build_rule(sel, margin='1ex 1em', padding_bottom='1ex', border_bottom='solid 1px currentColor') 28 ans += build_rule(f'{sel}:hover .custom-list-left', transform='scale(1.2)') 29 ans += build_rule(f'{sel}:active .custom-list-left', transform='scale(2)') 30 s = sel + ' .custom-list-left' 31 ans += build_rule(s, margin_right='1em') 32 ans += build_rule(s + ' > img', border_radius=BORDER_RADIUS+'px') 33 sel += ' iframe' 34 # To enable clicking anywhere on the item to load book details to work, we 35 # have to set pointer-events: none 36 # That has the side effect of disabling text selection 37 ans += build_rule(sel, flex_grow='10', cursor='pointer', pointer_events='none') 38 return ans 39 40 41def default_template(): 42 # Should never actually be needed 43 if not default_template.ans: 44 default_template.ans = { 45 'thumbnail': True, 46 'thumbnail_height': 140, 47 'height': 'auto', 48 'comments_fields': v"['comments']", 49 'lines': [ 50 _('<b>{title}</b> by {authors}'), 51 _('{series_index} of <i>{series}</i>') + '|||{rating}', 52 '{tags}', 53 _('Date: {timestamp}') + '|||' + _('Published: {pubdate}') + '|||' + _('Publisher: {publisher}'), 54 '', 55 ] 56 } 57 return default_template.ans 58 59 60def render_field(field, mi, book_id): # {{{ 61 if field is 'id': 62 return book_id + '' 63 field_metadata = library_data.field_metadata 64 fm = field_metadata[field] 65 if not fm: 66 return 67 val = mi[field] 68 if val is undefined or val is None: 69 return 70 interface_data = get_interface_data() 71 72 def add_val(val, is_html=False, join=None): 73 if is_html and /[<>]/.test(val + ''): 74 return safe_set_inner_html(E.span(), val) 75 if join: 76 val = val.join(join) 77 else: 78 val += '' 79 return val 80 81 def process_composite(field, fm, name, val): 82 if fm.display and fm.display.contains_html: 83 return add_val(val, is_html=True) 84 if fm.is_multiple and fm.is_multiple.list_to_ui: 85 all_vals = filter(None, map(str.strip, val.split(fm.is_multiple.list_to_ui))) 86 return add_val(all_vals, join=fm.is_multiple.list_to_ui) 87 return add_val(val) 88 89 def process_authors(field, fm, name, val): 90 return add_val(val, join=' & ') 91 92 def process_publisher(field, fm, name, val): 93 return add_val(val) 94 95 def process_formats(field, fm, name, val): 96 return add_val(val, join=', ') 97 98 def process_rating(field, fm, name, val): 99 stars = E.span() 100 val = int(val or 0) 101 if val > 0: 102 for i in range(val // 2): 103 stars.appendChild(svgicon('star')) 104 if fm.display.allow_half_stars and (val % 2): 105 stars.appendChild(svgicon('star-half')) 106 return stars 107 108 def process_identifiers(field, fm, name, val): 109 if val: 110 keys = Object.keys(val) 111 if keys.length: 112 ans = v'[]' 113 for key in keys: 114 ans.push(key + ':' + val[key]) 115 return add_val(ans, join=', ') 116 117 def process_languages(field, fm, name, val): 118 if val and val.length: 119 langs = [mi.lang_names[k] for k in val] 120 return add_val(langs, join=', ') 121 122 def process_datetime(field, fm, name, val): 123 if val: 124 fmt = interface_data['gui_' + field + '_display_format'] or (fm['display'] or {}).date_format 125 return add_val(format_date(val, fmt)) 126 127 def process_series(field, fm, name, val): 128 if val: 129 return add_val(val) 130 131 def process_series_index(field, fm, name, val): 132 sval = mi[field[:-6]] 133 if sval: 134 if val is None or val is undefined: 135 val = 1 136 return fmt_sidx(val, use_roman=interface_data.use_roman_numerals_for_series_number) 137 138 def process_size(field, fm, name, val): 139 val = val or 0 140 mb = 1024 * 1024 141 142 def fmt(val, suffix): 143 ans = f'{val:.1f}' 144 if ans.endsWith('.0'): 145 ans = ans[:-2] 146 return ans + suffix 147 148 if val < mb: 149 return fmt(val / 1024, 'KB') 150 return fmt(val / mb, 'MB') 151 152 name = fm.name or field 153 datatype = fm.datatype 154 if field is 'comments' or datatype is 'comments': 155 return 156 func = None 157 if datatype is 'composite': 158 func = process_composite 159 elif field is 'formats': 160 func = process_formats 161 elif datatype is 'rating': 162 func = process_rating 163 elif field is 'identifiers': 164 func = process_identifiers 165 elif field is 'authors': 166 func = process_authors 167 elif field is 'publisher': 168 func = process_publisher 169 elif field is 'languages': 170 func = process_languages 171 elif datatype is 'datetime': 172 func = process_datetime 173 elif datatype is 'series': 174 func = process_series 175 elif field.endswith('_index'): 176 func = process_series_index 177 elif field is 'size': 178 func = process_size 179 ans = None 180 if func: 181 ans = func(field, fm, name, val) 182 else: 183 if datatype is 'text' or datatype is 'enumeration': 184 if val is not undefined and val is not None: 185 join = fm.is_multiple.list_to_ui if fm.is_multiple else None 186 ans = add_val(val, join=join) 187 elif datatype is 'bool': 188 ans = add_val(_('Yes') if val else _('No')) 189 elif datatype is 'int' or datatype is 'float': 190 if val is not undefined and val is not None: 191 fmt = (fm.display or {}).number_format 192 if fmt: 193 val = fmt.format(val) 194 else: 195 val += '' 196 ans = add_val(val) 197 return ans 198# }}} 199 200 201def render_part(part, template, book_id, metadata): 202 count = rendered_count = 0 203 ans = E.div() 204 ans.innerHTML = part 205 iterator = document.createNodeIterator(ans, NodeFilter.SHOW_TEXT) 206 replacements = v'[]' 207 while True: 208 n = iterator.nextNode() 209 if not n: 210 break 211 rendered = E.span() 212 for field in n.nodeValue.split(/({#?[_a-z0-9]+})/): 213 if field[0] is '{' and field[-1] is '}': 214 count += 1 215 val = render_field(field[1:-1], metadata, book_id) 216 if val: 217 rendered_count += 1 218 if jstype(val) is 'string': 219 val = document.createTextNode(val) 220 rendered.appendChild(val) 221 else: 222 rendered.appendChild(document.createTextNode(field)) 223 replacements.push(v'[rendered, n]') 224 225 for new_child, old_child in replacements: 226 old_child.parentNode.replaceChild(new_child, old_child) 227 228 if count and not rendered_count: 229 return 230 return ans 231 232 233def render_line(line, template, book_id, metadata): 234 parts = v'[]' 235 line = line or '\xa0' 236 for p in line.split(/\|\|\|/): 237 part = render_part(p, template, book_id, metadata) 238 if part: 239 parts.push(part) 240 if not parts.length: 241 return 242 ans = E.div(class_='custom-line') 243 for p in parts: 244 ans.appendChild(p) 245 if parts.length > 1: 246 set_css(ans, display='flex', justify_content='space-between') 247 return ans 248 249 250def render_template_text(template, book_id, metadata): 251 ans = E.div() 252 for line in template.lines: 253 ldiv = render_line(line, template, book_id, metadata) 254 if ldiv: 255 ans.appendChild(ldiv) 256 if template.comments_fields.length: 257 html = '' 258 for f in template.comments_fields: 259 val = metadata[f] 260 if val: 261 html += f'<div style="margin-bottom:1.5ex">{val}</div>' 262 263 if html: 264 comments = sandboxed_html(html, sandbox_css()) 265 ans.appendChild(comments) 266 return ans 267 268 269def init(container): 270 clear(container) 271 container.appendChild(E.div(class_=CUSTOM_LIST_CLASS)) 272 273 274def on_img_load(img, load_type): 275 div = img.parentNode 276 if not div: 277 return 278 if load_type is not 'load': 279 clear(div) 280 div.appendChild(E.div( 281 E.h2(img.dataset.title, style='text-align:center; font-size:larger; font-weight: bold'), 282 E.div(_('by'), style='text-align: center'), 283 E.h2(img.dataset.authors, style='text-align:center; font-size:larger; font-weight: bold') 284 )) 285 set_css(div, border='dashed 1px currentColor', border_radius=BORDER_RADIUS+'px') 286 287 288def create_item(book_id, metadata, create_image, show_book_details, href): 289 template = get_interface_data().custom_list_template or default_template() 290 text_data = render_template_text(template, book_id, metadata) 291 text_data.style.flexGrow = '10' 292 text_data.style.overflow = 'hidden' 293 if template.thumbnail: 294 height = f'{template.thumbnail_height}px' 295 else: 296 if template.height is 'auto': 297 extra = 5 if template.comments_fields.length else 1 298 height = (template.lines.length * 2.5 + extra) + 'ex' 299 else: 300 height = template.height 301 if jstype(height) is 'number': 302 height += 'px' 303 ans = E.a( 304 style=f'height:{height}; display: flex', 305 class_=ITEM_CLASS, href=href 306 ) 307 if template.thumbnail: 308 h = template.thumbnail_height 309 w = int(0.75 * h) 310 img = create_image(book_id, w, h, on_img_load) 311 authors = metadata.authors.join(' & ') if metadata.authors else _('Unknown') 312 img.setAttribute('alt', _('{} by {}').format(metadata.title, authors)) 313 img.dataset.title, img.dataset.authors = metadata.title, authors 314 img.style.maxWidth = w + 'px' 315 img.style.maxHeight = h + 'px' 316 img_div = E.div(img, class_='custom-list-left', style=f'min-width: {w}px') 317 ans.appendChild(img_div) 318 ans.appendChild(text_data) 319 ans.addEventListener('click', show_book_details, True) 320 return ans 321 322 323def append_item(container, item): 324 container.lastChild.appendChild(item) 325