From ca8fed1bd4cf60f40e1d45e4121f2515183dc9e5 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Wed, 15 Feb 2017 10:27:33 +0530 Subject: [PATCH] Start work on porting the Tag Browser panel --- src/pyj/book_list/search.pyj | 806 +++++++++++++++++------------------ src/pyj/book_list/views.pyj | 7 +- src/pyj/session.pyj | 2 + src/pyj/widgets.pyj | 8 +- 4 files changed, 411 insertions(+), 412 deletions(-) diff --git a/src/pyj/book_list/search.pyj b/src/pyj/book_list/search.pyj index 4c3ccd6053..fff67aad0e 100644 --- a/src/pyj/book_list/search.pyj +++ b/src/pyj/book_list/search.pyj @@ -4,15 +4,27 @@ from __python__ import hash_literals from ajax import ajax from complete import create_search_bar -from dom import clear, set_css, build_rule, svgicon, add_extra_css +from dom import clear, set_css, build_rule, svgicon, add_extra_css, ensure_id from elementmaker import E from gettext import gettext as _ from widgets import create_button, create_spinner, Breadcrumbs from modals import show_modal from utils import rating_to_stars -from book_list.globals import get_boss, get_session_data +from session import get_interface_data + +from book_list.library_data import library_data, current_library_id +from book_list.ui import show_panel +from book_list.router import back +from book_list.top_bar import create_top_bar +from book_list.globals import get_session_data from book_list.theme import get_color, get_font_size +apply_search = None + +def set_apply_search(func): + nonlocal apply_search + apply_search = func + sp_counter = 0 CLASS_NAME = 'book-search-panel' add_extra_css(def(): @@ -36,447 +48,431 @@ add_extra_css(def(): return style ) -class SearchPanel: +state = {} - def __init__(self, interface_data, book_list_container): - nonlocal sp_counter - sp_counter += 1 - self.container_id = 'search-panel-' + sp_counter - self.interface_data = interface_data - self.tag_path = [] +def component(container, name): + return container.querySelector(f'[data-component="{name}"]') - div = E.div( - id=self.container_id, style='display:none', class_=CLASS_NAME, - E.div(style="text-align:center; padding:1ex 1em; border-bottom: solid 1px currentColor; margin-bottom: 0.5ex"), # search input container - E.div( - E.div(), - E.ol(style="display:none"), # breadcrumbs container - E.div(style="display:none") # tag browser container - ) - ) - book_list_container.appendChild(div) - # Build search input - search_container = div.firstChild - search_button = create_button(_('Search'), icon='search', tooltip=_('Do the search')) - search_bar = create_search_bar(self.execute_search.bind(self), 'search-books', tooltip=_('Search for books'), placeholder=_('Enter the search query'), button=search_button) - set_css(search_bar, flex_grow='10', margin_right='0.5em') - search_container.appendChild(E.div(style="display: flex; width: 100%;", search_bar, search_button)) - search_container.appendChild(E.ul(class_='search-items')) +def icon_for_node(node): + interface_data = get_interface_data() + ans = interface_data.icon_map[node.data.category] or 'column.png' + return interface_data.icon_path + '/' + ans - # Build loading panel - loading_panel = div.lastChild.firstChild - loading_panel.appendChild(E.div( - create_spinner(), '\xa0' + _('Fetching data for the tag browser, please wait') + '…', - style='margin-left:auto; margin-right:auto; font-size: 1.5rem; font-weight; bold; text-align:center; margin-top:30vh') - ) - self.breadcrumbs = Breadcrumbs(self.breadcrumbs_container) - self.initial_load_started = False - self.currently_loading = None - self.tag_browser_data = None - self.node_id_map = {} - self.active_nodes = {} - def init(self): - tb = self.search_control - # We dont focus the search box because on mobile that will cause the - # keyboard to popup and obscure the rest of the page - # tb.focus() - tb.value = '' - self.tag_path = [] - self.active_nodes = {} - if not self.initial_load_started: - self.initial_load_started = True - self.refresh() - else: - self.render_tag_browser() +def node_for_path(path): + path = path or state.tag_path + ans = state.tag_browser_data + for child_index in path: + ans = ans.children[child_index] + return ans - def refresh(self): - if self.currently_loading is not None: - self.currently_loading.abort() - self.currently_loading = None - sd = get_session_data() - query = {'library_id': self.interface_data.library_id} - for k in 'sort_tags_by partition_method collapse_at dont_collapse hide_empty_categories'.split(' '): - query[k] = sd.get(k) + '' - xhr = ajax('interface-data/tag-browser', self.on_data_fetched.bind(self), query=query, bypass_cache=False) - xhr.send() - self.currently_loading = xhr - def on_data_fetched(self, end_type, xhr, ev): - self.currently_loading = None - if end_type is 'abort': - return +def execute_search(text): + container = document.getElementById(state.container_id) + search_control = container.querySelector('input[name="search-books"]') + text = text or search_control.value or '' + apply_search(text) - parent = self.container.lastChild - if parent.lastChild.style.display is 'none': - parent.firstChild.style.display = 'none' - parent.lastChild.style.display = 'block' - container = self.tb_container - clear(container) - def show_error(error_html): - ediv = E.div() - container.appendChild(ediv) - ediv.innerHTML = '

' + _('Failed to load tag browser data') + '

' + error_html +def search_expression_for_item(node, node_state): + item = node.data + if item.is_searchable is False or not node_state or node_state is 'clear': + return '' - def process_node(node): - self.node_id_map[node.id] = node - node.data = item_map[node.id] + search_state = {'plus':'true', 'plusplus':'.true', 'minus':'false', 'minusminus':'.false'}[node_state] + stars = rating_to_stars(3, True) + + if item.is_category: + category = item.category + + if item.is_first_letter: + letters_seen = {} for child in node.children: - child.parent = node - process_node(child) - - if end_type is 'load': - try: - tag_browser_data = JSON.parse(xhr.responseText) - except Exception as err: - show_error(err + '') - return - item_map = tag_browser_data.item_map - self.tag_browser_data = tag_browser_data.root - self.node_id_map = {} - self.active_nodes = {} - process_node(self.tag_browser_data) - self.render_tag_browser(container) - else: - show_error(xhr.error_html) - - def node_for_path(self, path): - path = path or self.tag_path - ans = self.tag_browser_data - for child_index in path: - ans = ans.children[child_index] - return ans - - def render_tag_browser(self, container=None): - container = container or self.tb_container - clear(container) - set_css(container, padding='1ex 1em', display='flex', flex_wrap='wrap', margin_left='-0.5rem') - self.render_children(container, self.node_for_path().children) - self.render_breadcrumbs() - - def icon_for_node(self, node): - ans = self.interface_data.icon_map[node.data.category] or 'column.png' - return self.interface_data.icon_path + '/' + ans - - def render_children(self, container, children): - - def click_handler(func, i): - return def(): - func.call(self, i) - - for i, node in enumerate(children): - data = node.data - tooltip = '' - if data.count is not undefined: - tooltip += '\n' + _('Number of books in this category: {}').format(data.count) - if data.avg_rating is not undefined: - tooltip += '\n' + _('Average rating for books in this category: {:.1f}').format(data.avg_rating) - div = E.div( - title=tooltip.lstrip(), - style="display:flex; align-items: stretch", - E.div(class_='tag-name', - style='border-right:solid 1px currentColor; padding: 1ex; display:flex; align-items: center', - E.img(src=self.icon_for_node(node), style='display:inline-block; max-height:2.5ex'), - '\xa0' + data.name - ), - E.div(class_='tag-menu', - style='padding: 1ex; display:flex; align-items:center', - E.div(svgicon('angle-down')) - ) - ) - set_css(div, max_width='45vw', border='solid 1px currentColor', border_radius='20px', margin='0.5rem', cursor='pointer', overflow='hidden', user_select='none') - div.firstChild.addEventListener('click', click_handler(self.node_clicked, i)) - div.lastChild.addEventListener('click', click_handler(self.menu_clicked, i)) - container.appendChild(div) - - def render_breadcrumbs(self): - container = self.breadcrumbs_container - if not self.tag_path.length: - container.style.display = 'none' - return - container.style.display = 'inline-block' - self.breadcrumbs.reset() - - def onclick(i): - return def(ev): - self.tag_path = self.tag_path[:i+1] - self.render_tag_browser() - ev.preventDefault() - return True - - def create_breadcrumb(index=-1, item=None): - li = self.breadcrumbs.add_crumb(onclick(index)) - if item: - li.appendChild(E.span(item.name)) - else: - li.appendChild(svgicon('home', '2.2ex', '2.2ex')) - - create_breadcrumb() - parent = self.tag_browser_data - for i, index in enumerate(self.tag_path): - parent = parent.children[index] - create_breadcrumb(i, parent.data) - - def search_expression_for_item(self, node, state): - item = node.data - if item.is_searchable is False or not state or state is 'clear': - return '' - - search_state = {'plus':'true', 'plusplus':'.true', 'minus':'false', 'minusminus':'.false'}[state] - stars = rating_to_stars(3, True) - - if item.is_category: - category = item.category - - if item.is_first_letter: - letters_seen = {} - for child in node.children: - if child.data.sort: - letters_seen[child.data.sort[0]] = True - letters_seen = Object.keys(letters_seen) - if letters_seen.length: - charclass = letters_seen.join('') - if category is 'authors': - expr = r'author_sort:"~(^[{0}])|(&\s*[{0}])"'.format(charclass) - elif category is 'series': - expr = r'series_sort:"~^[{0}]"'.format(charclass) - else: - expr = r'{0}:"~^[{1}]"'.format(category, charclass) + if child.data.sort: + letters_seen[child.data.sort[0]] = True + letters_seen = Object.keys(letters_seen) + if letters_seen.length: + charclass = letters_seen.join('') + if category is 'authors': + expr = r'author_sort:"~(^[{0}])|(&\s*[{0}])"'.format(charclass) + elif category is 'series': + expr = r'series_sort:"~^[{0}]"'.format(charclass) else: - expr = '{}:false'.format(category) - - elif category is 'news': - expr = 'tags:"={}"'.format(item.name) - + expr = r'{0}:"~^[{1}]"'.format(category, charclass) else: - return '{}:{}'.format(category, search_state) + expr = '{}:false'.format(category) - if 'false' in search_state: - expr = '(not ' + expr + ')' - return expr + elif category is 'news': + expr = 'tags:"={}"'.format(item.name) - category = 'tags' if item.category is 'news' else item.category - if item.name and item.name[0] in stars: - # Assume ratings - rnum = item.name.length - if item.name.endswith(stars[-1]): - rnum = '{}.5'.format(rnum - 1) - expr = '{}:{}'.format(category, rnum) else: - fm = self.interface_data.field_metadata[item.category] - suffix = ':' if fm and fm.is_csp else '' - name = item.original_name or item.name or item.sort - if not name: - return '' - name = str.replace(name, '"', r'\"') - if name[0] is '.': - name = '.' + name - if search_state is 'plusplus' or search_state is 'minusminus': - name = '.' + name - expr = '{}:"={}{}"'.format(category, name, suffix) + return '{}:{}'.format(category, search_state) if 'false' in search_state: expr = '(not ' + expr + ')' return expr - def node_clicked(self, i): - node = self.node_for_path().children[i] - if node.children and node.children.length: - self.tag_path.append(i) - self.render_tag_browser() - else: - expr = self.search_expression_for_item(node, 'plus') - self.execute_search(expr) + category = 'tags' if item.category is 'news' else item.category + if item.name and item.name[0] in stars: + # Assume ratings + rnum = item.name.length + if item.name.endswith(stars[-1]): + rnum = '{}.5'.format(rnum - 1) + expr = '{}:{}'.format(category, rnum) + else: + interface_data = get_interface_data() + fm = interface_data.field_metadata[item.category] + suffix = ':' if fm and fm.is_csp else '' + name = item.original_name or item.name or item.sort + if not name: + return '' + name = str.replace(name, '"', r'\"') + if name[0] is '.': + name = '.' + name + if search_state is 'plusplus' or search_state is 'minusminus': + name = '.' + name + expr = '{}:"={}{}"'.format(category, name, suffix) - def menu_clicked(self, i): + if 'false' in search_state: + expr = '(not ' + expr + ')' + return expr - def add_to_search(node, search_type): - return def(): - self.add_to_search(node, search_type) +def node_clicked(i): + node = node_for_path().children[i] + if node.children and node.children.length: + state.tag_path.append(i) + render_tag_browser() + else: + expr = search_expression_for_item(node, 'plus') + execute_search(expr) - def create_details(container, hide_modal): - node = self.node_for_path().children[i] - data = node.data - name = data.original_name or data.name or data.sort - items = [] - if data.count is not undefined: - items.append(_('Count: ') + data.count) - if data.avg_rating is not undefined: - items.append(_('Rating: {:.1f}').format(data.avg_rating)) - suffix = '' - if items.length: - suffix = ' [' + items.join(' ') + ']' - title = E.h2( - style='display:flex; align-items: center; border-bottom: solid 1px currentColor; font-weight:bold; font-size:' + get_font_size('title'), - E.img(src=self.icon_for_node(node), style='height:2ex'), - E.span('\xa0' + name + suffix) - ) - container.appendChild(title) - container.appendChild(E.div( - style='margin-top:1ex; margin-bottom: 1ex', - _('Search for books based on this category (a search term will be added to the search box)') - )) +def add_to_search(node, search_type, anded): + if anded is undefined or anded is None: + anded = get_session_data().get('and_search_terms') + state.active_nodes[node.id] = [search_type, anded] + render_search_expression() - ul = E.ul(style='list-style:none; overflow:hidden', class_='tb-action-list') - container.appendChild(ul) - items = [ - (_('Books matching this category'), 'plus'), - (_('Books that do not match this category'), 'minus'), - ] - if node.data.is_hierarchical is 5: - items.extend([ - (_('Books that match this category and all sub-categories'), 'plusplus'), - (_('Books that do not match this category or any of its sub-categories'), 'minusminus'), - ]) - for text, search_type in items: - li = E.li( - style='display:flex; align-items: center; margin-bottom:0.5ex; padding: 0.5ex; cursor:pointer', - E.img(src='{}/{}.png'.format(self.interface_data.icon_path, search_type), style='max-height: 2.5ex'), - E.span('\xa0' + text) - ) - li.addEventListener('click', add_to_search(node, search_type)) - li.addEventListener('click', hide_modal) - ul.appendChild(li) - f = E.form( - style='text-align:left; border-top: solid 1px currentColor; padding-top:1ex; margin-top:0.5ex; display:flex; align-items:center', - E.span(_('Add to the search expression with:')), - E.input(type='radio', name='expr_join', value='OR', checked=''), - E.span('\xa0OR\xa0'), - E.input(type='radio', name='expr_join', value='AND'), - E.span('\xa0AND') - ) - and_control = f.lastChild.previousSibling - and_control.checked = get_session_data().get('and_search_terms') - container.appendChild(f) - and_control.addEventListener('change', def(ev): - get_session_data().set('and_search_terms', bool(ev.target.checked)) - ) - f.firstChild.nextSibling.addEventListener('change', def(ev): - get_session_data().set('and_search_terms', not ev.target.checked) - ) - show_modal(create_details) - def add_to_search(self, node, search_type, anded): - if anded is undefined or anded is None: - anded = get_session_data().get('and_search_terms') - self.active_nodes[node.id] = [search_type, anded] - self.render_search_expression() +def render_search_expression(): + def remove_expression(node_id): + return def(): + v'delete state.active_nodes[node_id]' + render_search_expression() + parts = [] + container = document.getElementById(state.container_id) + sic = component(container, 'tag_browser') + clear(sic) + for node_id in Object.keys(state.active_nodes): + search_type, anded = state.active_nodes[node_id] + node = state.node_id_map[node_id] + expr = search_expression_for_item(node, search_type) + name = node.data.original_name or node.data.name or node.data.sort or '' + if expr: + c = E.li(svgicon('remove'), '\xa0' + name) + sic.appendChild(c) + c.addEventListener('click', remove_expression(node_id)) + if parts.length: + expr = ('and' if anded else 'or') + ' ' + expr + parts.push(expr) + search_control = container.querySelector('input[name="search-books"]') + search_control.value = parts.join(' ') - def render_search_expression(self): - def remove_expression(node_id): - return def(): - v'delete self.active_nodes[node_id]' - self.render_search_expression() - parts = [] - container = self.search_items_container - clear(container) - for node_id in Object.keys(self.active_nodes): - search_type, anded = self.active_nodes[node_id] - node = self.node_id_map[node_id] - expr = self.search_expression_for_item(node, search_type) - name = node.data.original_name or node.data.name or node.data.sort or '' - if expr: - c = E.li(svgicon('remove'), '\xa0' + name) - container.appendChild(c) - c.addEventListener('click', remove_expression(node_id)) - if parts.length: - expr = ('and' if anded else 'or') + ' ' + expr - parts.push(expr) - self.search_control.value = parts.join(' ') - def get_prefs(self): - return [ - { - 'name': 'sort_tags_by', - 'text': _('Sort tags by'), - 'choices': [('name', _('Name')), ('popularity', _('Popularity (number of books)')), ('rating', _('Average rating'))], - 'tooltip': _('Change how the tags/authors/etc. are sorted in the Tag Browser'), - }, +def menu_clicked(i): - { - 'name':'partition_method', - 'text':_('Tags browser category partitioning method'), - 'choices':[('first letter', _('First Letter')), ('disable', _('Disable')), ('partition', _('Partition'))], - 'tooltip':_('Choose how tag browser subcategories are displayed when' - ' there are more items than the limit. Select by first' - ' letter to see an A, B, C list. Choose partitioned to' - ' have a list of fixed-sized groups. Set to disabled' - ' if you never want subcategories.'), - }, + def add_to_search(node, search_type): + return def(): + add_to_search(node, search_type) - { - 'name':'collapse_at', - 'text':_('Collapse when more items than'), - 'min': 5, 'max':10000, 'step':5, - 'from_storage':int, 'to_storage':int, - 'tooltip': _('If a Tag Browser category has more than this number of items, it is divided' - ' up into subcategories. If the partition method is set to disable, this value is ignored.'), - }, + def create_details(container, hide_modal): + node = node_for_path().children[i] + data = node.data + name = data.original_name or data.name or data.sort + items = [] + if data.count is not undefined: + items.append(_('Count: ') + data.count) + if data.avg_rating is not undefined: + items.append(_('Rating: {:.1f}').format(data.avg_rating)) + suffix = '' + if items.length: + suffix = ' [' + items.join(' ') + ']' - { - 'name': 'dont_collapse', - 'text': _('Categories not to partition'), - 'tooltip': _('A comma-separated list of categories in which items containing' - ' periods are displayed in the tag browser trees. For example, if' - " this box contains 'tags' then tags of the form 'Mystery.English'" - " and 'Mystery.Thriller' will be displayed with English and Thriller" - " both under 'Mystery'. If 'tags' is not in this box," - ' then the tags will be displayed each on their own line.'), - }, - - { - 'name': 'hide_empty_categories', - 'text': _('Hide empty categories (columns)'), - 'from_storage': def(x): return x.toLowerCase() is 'yes';, - 'to_storage': def(x): return 'yes' if x else 'no';, - 'tooltip':_('When checked, calibre will automatically hide any category' - ' (a column, custom or standard) that has no items to show. For example, some' - ' categories might not have values when using virtual libraries. Checking this' - ' box will cause these empty categories to be hidden.'), - }, - - ] - - def apply_prefs(self): - container = self.tb_container - clear(container) - container.appendChild(E.div( - style='margin: 1ex 1em', - _('Reloading tag browser with updated settings, please wait...')) + title = E.h2( + style='display:flex; align-items: center; border-bottom: solid 1px currentColor; font-weight:bold; font-size:' + get_font_size('title'), + E.img(src=icon_for_node(node), style='height:2ex'), + E.span('\xa0' + name + suffix) ) - self.refresh() + container.appendChild(title) + container.appendChild(E.div( + style='margin-top:1ex; margin-bottom: 1ex', + _('Search for books based on this category (a search term will be added to the search box)') + )) - @property - def container(self): - return document.getElementById(self.container_id) + ul = E.ul(style='list-style:none; overflow:hidden', class_='tb-action-list') + container.appendChild(ul) + items = [ + (_('Books matching this category'), 'plus'), + (_('Books that do not match this category'), 'minus'), + ] + if node.data.is_hierarchical is 5: + items.extend([ + (_('Books that match this category and all sub-categories'), 'plusplus'), + (_('Books that do not match this category or any of its sub-categories'), 'minusminus'), + ]) + interface_data = get_interface_data() + for text, search_type in items: + li = E.li( + style='display:flex; align-items: center; margin-bottom:0.5ex; padding: 0.5ex; cursor:pointer', + E.img(src='{}/{}.png'.format(interface_data.icon_path, search_type), style='max-height: 2.5ex'), + E.span('\xa0' + text) + ) + li.addEventListener('click', add_to_search(node, search_type)) + li.addEventListener('click', hide_modal) + ul.appendChild(li) + f = E.form( + style='text-align:left; border-top: solid 1px currentColor; padding-top:1ex; margin-top:0.5ex; display:flex; align-items:center', + E.span(_('Add to the search expression with:')), + E.input(type='radio', name='expr_join', value='OR', checked=''), + E.span('\xa0OR\xa0'), + E.input(type='radio', name='expr_join', value='AND'), + E.span('\xa0AND') + ) + and_control = f.lastChild.previousSibling + and_control.checked = get_session_data().get('and_search_terms') + container.appendChild(f) + and_control.addEventListener('change', def(ev): + get_session_data().set('and_search_terms', bool(ev.target.checked)) + ) + f.firstChild.nextSibling.addEventListener('change', def(ev): + get_session_data().set('and_search_terms', not ev.target.checked) + ) + show_modal(create_details) - @property - def breadcrumbs_container(self): - return self.tb_container.previousSibling - @property - def tb_container(self): - return self.container.lastChild.lastChild +def render_children(container, children): + for i, node in enumerate(children): + data = node.data + tooltip = '' + if data.count is not undefined: + tooltip += '\n' + _('Number of books in this category: {}').format(data.count) + if data.avg_rating is not undefined: + tooltip += '\n' + _('Average rating for books in this category: {:.1f}').format(data.avg_rating) + div = E.div( + title=tooltip.lstrip(), + style="display:flex; align-items: stretch", + E.div(class_='tag-name', + style='border-right:solid 1px currentColor; padding: 1ex; display:flex; align-items: center', + E.img(src=icon_for_node(node), style='display:inline-block; max-height:2.5ex'), + '\xa0' + data.name + ), + E.div(class_='tag-menu', + style='padding: 1ex; display:flex; align-items:center', + E.div(svgicon('angle-down')) + ) + ) + set_css(div, max_width='45vw', border='solid 1px currentColor', border_radius='20px', margin='0.5rem', cursor='pointer', overflow='hidden', user_select='none') + div.firstChild.addEventListener('click', node_clicked.bind(i)) + div.lastChild.addEventListener('click', menu_clicked.bind(i)) + container.appendChild(div) - @property - def search_control(self): - return self.container.querySelector('input[name="search-books"]') +def render_breadcrumbs(): + container = state.breadcrumbs.container + if not state.tag_path.length: + container.style.display = 'none' + return + container.style.display = 'inline-block' + state.breadcrumbs.reset() - @property - def search_items_container(self): - return self.container.firstChild.lastChild + def onclick(i): + return def(ev): + state.tag_path = state.tag_path[:i+1] + render_tag_browser() + ev.preventDefault() + return True - @property - def is_visible(self): - self.container.style.display is 'block' + def create_breadcrumb(index=-1, item=None): + li = state.breadcrumbs.add_crumb(onclick(index)) + if item: + li.appendChild(E.span(item.name)) + else: + li.appendChild(svgicon('home', '2.2ex', '2.2ex')) - @is_visible.setter - def is_visible(self, val): - self.container.style.display = 'block' if val else 'none' + create_breadcrumb() + parent = state.tag_browser_data + for i, index in enumerate(state.tag_path): + parent = parent.children[index] + create_breadcrumb(i, parent.data) - def execute_search(self, text=''): - text = text or self.search_control.value or '' - get_boss().ui.books_view.change_search(text) +def render_tag_browser(): + container = document.getElementById(state.container_id).lastChild.lastChild + clear(container) + set_css(container, padding='1ex 1em', display='flex', flex_wrap='wrap', margin_left='-0.5rem') + render_children(container, node_for_path().children) + render_breadcrumbs() + + +def on_data_fetched(end_type, xhr, ev): + state.currently_loading = None + if end_type is 'abort': + return + container = document.getElementById(state.container_id) + if not container: + return + + loading_panel = component(container, 'loading') + loading_panel.style.display = 'none' + container = component(container, 'tag_browser') + container.style.display = 'block' + clear(container) + + def show_error(error_html): + ediv = E.div() + container.appendChild(ediv) + ediv.innerHTML = '

' + _('Failed to load tag browser data') + '

' + error_html + + def process_node(node): + state.node_id_map[node.id] = node + node.data = item_map[node.id] + for child in node.children: + child.parent = node + process_node(child) + + if end_type is 'load': + try: + tag_browser_data = JSON.parse(xhr.responseText) + except Exception as err: + show_error(err + '') + return + item_map = tag_browser_data.item_map + state.tag_browser_data = tag_browser_data.root + state.node_id_map = {} + state.active_nodes = {} + process_node(state.tag_browser_data) + render_tag_browser() + else: + show_error(xhr.error_html) + + +def refresh(): + if state.currently_loading is not None: + state.currently_loading.abort() + state.currently_loading = None + sd = get_session_data() + query = {'library_id': current_library_id()} + for k in 'sort_tags_by partition_method collapse_at dont_collapse hide_empty_categories'.split(' '): + query[k] = sd.get(k) + '' + xhr = ajax('interface-data/tag-browser', on_data_fetched, query=query, bypass_cache=False) + xhr.send() + state.currently_loading = xhr + + +def create_search_panel(container): + nonlocal state + state = {} + container.classList.add(CLASS_NAME) + # search input container + container.appendChild(E.div( + data_component='search', + style="text-align:center; padding:1ex 1em; border-bottom: solid 1px currentColor; margin-bottom: 0.5ex" + )) + container.appendChild(E.div( + E.div(data_component='loading'), + E.ol(style="display:none", data_component='breadcrumbs'), + E.div(style="display:none", data_component='tag_browser') + )) + + # Build search input + # We dont focus the search box because on mobile that will cause the + # keyboard to popup and obscure the rest of the page + search_container = component(container, 'search') + search_button = create_button(_('Search'), icon='search', tooltip=_('Do the search')) + search_bar = create_search_bar(execute_search, 'search-books', tooltip=_('Search for books'), placeholder=_('Enter the search query'), button=search_button) + set_css(search_bar, flex_grow='10', margin_right='0.5em') + search_container.appendChild(E.div(style="display: flex; width: 100%;", search_bar, search_button)) + search_container.appendChild(E.ul(class_='search-items')) + + # Build loading panel + loading_panel = component(container, 'loading') + loading_panel.appendChild(E.div( + create_spinner(), '\xa0' + _('Fetching data for the tag browser, please wait') + '…', + style='margin-left:auto; margin-right:auto; font-size: 1.5rem; font-weight; bold; text-align:center; margin-top:30vh') + ) + + # Build breadcrumbs + state.breadcrumbs = Breadcrumbs(component(container, 'breadcrumbs')) + + # Init state + state.currently_loading = None + state.tag_browser_data = None + state.node_id_map = {} + state.active_nodes = {} + state.tag_path = [] + state.container_id = ensure_id(container) + refresh() + + + +def init(container_id): + if not library_data.sortable_fields: + show_panel('book_list', replace=True) + return + container = document.getElementById(container_id) + create_top_bar(container, title=_('Search for books'), action=back, icon='close') + container.appendChild(E.div(class_=CLASS_NAME)) + create_search_panel(container.lastChild) + + +def get_prefs(): + return [ + { + 'name': 'sort_tags_by', + 'text': _('Sort tags by'), + 'choices': [('name', _('Name')), ('popularity', _('Popularity (number of books)')), ('rating', _('Average rating'))], + 'tooltip': _('Change how the tags/authors/etc. are sorted in the Tag Browser'), + }, + + { + 'name':'partition_method', + 'text':_('Tags browser category partitioning method'), + 'choices':[('first letter', _('First Letter')), ('disable', _('Disable')), ('partition', _('Partition'))], + 'tooltip':_('Choose how tag browser subcategories are displayed when' + ' there are more items than the limit. Select by first' + ' letter to see an A, B, C list. Choose partitioned to' + ' have a list of fixed-sized groups. Set to disabled' + ' if you never want subcategories.'), + }, + + { + 'name':'collapse_at', + 'text':_('Collapse when more items than'), + 'min': 5, 'max':10000, 'step':5, + 'from_storage':int, 'to_storage':int, + 'tooltip': _('If a Tag Browser category has more than this number of items, it is divided' + ' up into subcategories. If the partition method is set to disable, this value is ignored.'), + }, + + { + 'name': 'dont_collapse', + 'text': _('Categories not to partition'), + 'tooltip': _('A comma-separated list of categories in which items containing' + ' periods are displayed in the tag browser trees. For example, if' + " this box contains 'tags' then tags of the form 'Mystery.English'" + " and 'Mystery.Thriller' will be displayed with English and Thriller" + " both under 'Mystery'. If 'tags' is not in this box," + ' then the tags will be displayed each on their own line.'), + }, + + { + 'name': 'hide_empty_categories', + 'text': _('Hide empty categories (columns)'), + 'from_storage': def(x): return x.toLowerCase() is 'yes';, + 'to_storage': def(x): return 'yes' if x else 'no';, + 'tooltip':_('When checked, calibre will automatically hide any category' + ' (a column, custom or standard) that has no items to show. For example, some' + ' categories might not have values when using virtual libraries. Checking this' + ' box will cause these empty categories to be hidden.'), + }, + + ] diff --git a/src/pyj/book_list/views.pyj b/src/pyj/book_list/views.pyj index d38008720f..7506a921f3 100644 --- a/src/pyj/book_list/views.pyj +++ b/src/pyj/book_list/views.pyj @@ -20,6 +20,7 @@ from book_list.router import back from book_list.ui import set_panel_handler, show_panel from book_list.library_data import load_status, ensure_current_library_data, library_data, current_sorted_field, loaded_books_query, url_books_query, current_library_id from book_list.item_list import create_item_list, create_item +from book_list.search import init as init_search_panel, set_apply_search ALLOWED_MODES = {'cover_grid'} DEFAULT_MODE = 'cover_grid' @@ -157,7 +158,8 @@ def create_books_list(container): container.appendChild(E.div()), container.appendChild(E.div()) apply_view_mode(get_session_data().get('view_mode')) create_more_button(container.lastChild) - add_button(container.parentNode, icon='sort-amount-desc', action=show_panel.bind(None, 'book_list^sort'), tooltip=_('Sort Books')) + add_button(container.parentNode, icon='sort-amount-desc', action=show_panel.bind(None, 'book_list^sort'), tooltip=_('Sort books')) + add_button(container.parentNode, icon='search', action=show_panel.bind(None, 'book_list^search'), tooltip=_('Search for books')) add_button(container.parentNode, icon='ellipsis-v', action=show_panel.bind(None, 'book_list^more_actions'), tooltip=_('More actions')) @@ -241,6 +243,8 @@ def create_sort_panel(container_id): def search(query, replace=False): pass +set_apply_search(def(query): search(query, True);) + # }}} # More actions {{{ @@ -262,4 +266,5 @@ def create_more_actions_panel(container_id): set_panel_handler('book_list', init) set_panel_handler('book_list^sort', create_sort_panel) +set_panel_handler('book_list^search', init_search_panel) set_panel_handler('book_list^more_actions', create_more_actions_panel) diff --git a/src/pyj/session.pyj b/src/pyj/session.pyj index f6fa95d72a..215a15e795 100644 --- a/src/pyj/session.pyj +++ b/src/pyj/session.pyj @@ -145,6 +145,8 @@ default_interface_data = { 'allow_console_print':False, 'default_library_id': None, 'library_map': None, + 'icon_map': {}, + 'icon_path': '', } def get_interface_data(): diff --git a/src/pyj/widgets.pyj b/src/pyj/widgets.pyj index c9ac0102fe..ff0b74fd90 100644 --- a/src/pyj/widgets.pyj +++ b/src/pyj/widgets.pyj @@ -2,7 +2,7 @@ # License: GPL v3 Copyright: 2015, Kovid Goyal from __python__ import hash_literals -from dom import build_rule, clear, svgicon, create_keyframes, set_css, change_icon_image, add_extra_css +from dom import build_rule, clear, svgicon, create_keyframes, set_css, change_icon_image, add_extra_css, ensure_id from elementmaker import E from book_list.theme import get_color @@ -44,8 +44,6 @@ create_spinner.style += create_keyframes('spin', 'from { transform: rotate(0deg) # Breadcrumbs {{{ -id_counter = 0 - class Breadcrumbs: STYLE_RULES = build_rule( @@ -81,11 +79,9 @@ class Breadcrumbs: def __init__(self, container): - nonlocal id_counter id_counter += 1 - container.setAttribute('id', container.getAttribute('id') or ('calibre-breadcrumbs-' + id_counter)) + self.container_id = ensure_id(container, 'calibre-breadcrumbs-') container.classList.add('calibre-breadcrumbs') clear(container) - self.container_id = container.getAttribute('id') @property def container(self):