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