diff --git a/src/calibre/srv/metadata.py b/src/calibre/srv/metadata.py index d1b1adf0b9..8f9370d8a0 100644 --- a/src/calibre/srv/metadata.py +++ b/src/calibre/srv/metadata.py @@ -157,7 +157,7 @@ def categories_settings(query, db): if partition_method not in {'first letter', 'disable', 'partition'}: partition_method = 'first letter' try: - collapse_at = max(0, int(query.get('collapse_at', 25))) + collapse_at = max(0, int(float(query.get('collapse_at', 25)))) except Exception: collapse_at = 25 sort_by = query.get('sort_tags_by', 'name') diff --git a/src/pyj/book_list/prefs.pyj b/src/pyj/book_list/prefs.pyj new file mode 100644 index 0000000000..d2f650f80c --- /dev/null +++ b/src/pyj/book_list/prefs.pyj @@ -0,0 +1,213 @@ +# vim:fileencoding=utf-8 +# License: GPL v3 Copyright: 2016, Kovid Goyal + +from dom import clear +from elementmaker import E +from book_list.globals import get_session_data + +# from book_list.theme import get_font_size, get_color + +pp_counter = 0 +widget_counter = 0 + +class ConfigItem: + + def __init__(self, item_data): + nonlocal widget_counter + widget_counter += 1 + self.widget_id = 'pref-widget-' + widget_counter + self.item_data = item_data + self.ignore_ui_value_changed = False + + def initialize(self): + self.ignore_ui_value_changed = True + try: + self.to_ui(self.from_storage()) + finally: + self.ignore_ui_value_changed = False + return self + + @property + def container(self): + return document.getElementById(self.widget_id) + + @property + def control(self): + return self.container.lastChild + + def from_storage(self): + val = get_session_data().get(self.item_data.name) + if self.item_data.from_storage: + val = self.item_data.from_storage(val) + return val + + def to_storage(self, val): + if self.item_data.to_storage: + val = self.item_data.to_storage(val) + get_session_data().set(self.item_data.name, val) + + def defval(self): + return get_session_data().defval(self.item_data.name) + + def ui_value_changed(self): + if self.ignore_ui_value_changed: + return + self.to_storage(self.from_ui()) + + def to_ui(self, val): + pass + + def from_ui(self): + pass + +class Choices(ConfigItem): + + def __init__(self, item_data, container, onfocus): + ConfigItem.__init__(self, item_data) + div = E.div( + id=self.widget_id, + E.span(item_data.text + ': ', style='white-space:pre'), + E.select(required='1') + ) + container.appendChild(div) + select = div.lastChild + for choice, text in item_data.choices: + select.appendChild(E.option(text, value=choice)) + select.addEventListener('change', self.ui_value_changed.bind(self)) + select.addEventListener('focus', onfocus) + + def to_ui(self, val): + self.control.value = val + + def from_ui(self): + return self.control.value + +class CheckBox(ConfigItem): + + def __init__(self, item_data, container, onfocus): + ConfigItem.__init__(self, item_data) + div = E.div( + id=self.widget_id, + E.input(type='checkbox'), + E.span(' ' + item_data.text, style='white-space:pre') + ) + container.appendChild(div) + control = div.firstChild + control.addEventListener('change', self.ui_value_changed.bind(self)) + control.addEventListener('focus', onfocus) + + @property + def control(self): + return self.container.firstChild + + def to_ui(self, val): + self.control.checked = bool(val) + + def from_ui(self): + return bool(self.control.checked) + +class SpinBox(ConfigItem): + + def __init__(self, item_data, container, onfocus): + ConfigItem.__init__(self, item_data) + div = E.div( + id=self.widget_id, + E.span(item_data.text + ': ', style='white-space:pre'), + E.input(type='number', step='any', min='1', max='100') + ) + container.appendChild(div) + control = div.lastChild + for attr in str.split('min max step'): + val = item_data[attr] + if val is not undefined and val is not None: + control.setAttribute(attr, '' + val) + control.addEventListener('change', self.ui_value_changed.bind(self)) + control.addEventListener('focus', onfocus) + + def to_ui(self, val): + self.control.value = val + + def from_ui(self): + return self.control.value + +class LineEdit(ConfigItem): + + def __init__(self, item_data, container, onfocus): + ConfigItem.__init__(self, item_data) + div = E.div( + id=self.widget_id, + E.span(item_data.text + ': ', style='white-space:pre'), + E.input(type='text') + ) + container.appendChild(div) + control = div.lastChild + control.addEventListener('change', self.ui_value_changed.bind(self)) + control.addEventListener('focus', onfocus) + + def to_ui(self, val): + self.control.value = val or '' + + def from_ui(self): + return self.control.value or '' + +class PrefsPanel: + + def __init__(self, interface_data, book_list_container): + nonlocal iv_counter + pp_counter += 1 + self.container_id = 'prefs-panel-' + pp_counter + style = '' + div = E.div( + id=self.container_id, style='display:none', + E.style(style, type='text/css') + ) + book_list_container.appendChild(div) + self.widgets = [] + + @property + def container(self): + return document.getElementById(self.container_id) + + @property + def is_visible(self): + self.container.style.display == 'block' + + @is_visible.setter + def is_visible(self, val): + self.container.style.display = 'block' if val else 'none' + + def init(self, data): + c = self.container + clear(c) + self.widgets = [] + + def onfocus(name): + return def(ev): + c = self.container + div = c.querySelector(str.format('div[data-name="{}"]', name)) + div.lastChild.style.display = 'block' + + for item in data: + div = E.div( + style='margin-bottom:1ex; padding: 1ex 1em; border-bottom: solid 1px currentColor', + title=item.tooltip, + data_name=item.name, + E.div(), + E.div( + item.tooltip or '', + style='font-size:0.8rem; font-style: italic; margin-top:1ex; display:none' + ) + ) + c.appendChild(div) + val = get_session_data().get(item.name) + if item.from_storage: + val = item.from_storage(val) + if item.choices: + cls = Choices + elif val is True or val is False: + cls = CheckBox + elif type(val) == 'number': + cls = SpinBox + else: + cls = LineEdit + self.widgets.append((new cls(item, div.firstChild, onfocus(item.name))).initialize()) diff --git a/src/pyj/book_list/search.pyj b/src/pyj/book_list/search.pyj index e60da5ead0..d55d344303 100644 --- a/src/pyj/book_list/search.pyj +++ b/src/pyj/book_list/search.pyj @@ -85,20 +85,22 @@ class SearchPanel: 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 refresh(self): if self.currently_loading is not None: - return - self.initial_load_started = True + self.currently_loading.abort() + self.currently_loading = None sd = get_session_data() query = {'library_id': self.interface_data.library_id} for k in str.split('sort_tags_by partition_method collapse_at dont_collapse hide_empty_categories'): query[k] = sd.get(k) + '' - self.currently_loading = ajax('interface-data/tag-browser', self.on_data_fetched.bind(self), query=query, bypass_cache=False) - self.currently_loading.send() + 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 @@ -383,6 +385,68 @@ class SearchPanel: 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'), + }, + + { + '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() == '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...')) + ) + self.refresh() + @property def container(self): return document.getElementById(self.container_id) diff --git a/src/pyj/book_list/ui.pyj b/src/pyj/book_list/ui.pyj index 3c0e7f4d67..659224e3f4 100644 --- a/src/pyj/book_list/ui.pyj +++ b/src/pyj/book_list/ui.pyj @@ -6,6 +6,7 @@ from book_list.search import SearchPanel from book_list.top_bar import TopBar from book_list.views import BooksView from book_list.item_list import ItemsView, create_item +from book_list.prefs import PrefsPanel from gettext import gettext as _ from utils import debounce @@ -20,9 +21,13 @@ class BarState: class ClosePanelBar(BarState): - def __init__(self, title, tooltip=''): + def __init__(self, title, tooltip='', close_callback=None): tooltip = tooltip or _('Close this panel') - BarState.__init__(self, title=title, tooltip=tooltip, action=close_panel, icon_name='times') + def action(): + close_panel() + if close_callback is not None: + close_callback() + BarState.__init__(self, title=title, tooltip=tooltip, action=action, icon_name='times') class UIState: @@ -69,8 +74,9 @@ class UI: self.top_bar = TopBar(book_list_container) self.books_view = BooksView(interface_data, book_list_container) self.items_view = ItemsView(interface_data, book_list_container) + self.prefs_panel = PrefsPanel(interface_data, book_list_container) self.search_panel = SearchPanel(interface_data, book_list_container) - self.panels = [self.books_view, self.items_view, self.search_panel] + self.panels = [self.books_view, self.items_view, self.search_panel, self.prefs_panel] self.panel_map = {self.ROOT_PANEL: UIState(create_book_view_top_bar_state(self.books_view), main_panel=self.books_view)} self.current_panel = self.ROOT_PANEL window.addEventListener('resize', debounce(self.on_resize.bind(self), 250)) @@ -84,8 +90,13 @@ class UI: self.panel_map['booklist-sort-menu'] = UIState(ClosePanelBar(_('Sort books')), panel_data=def(): return self.books_view.sort_panel_data(create_item) ) + self.panel_map['booklist-config-tb'] = UIState( + ClosePanelBar(_('Configure Tag Browser'), close_callback=self.search_panel.apply_prefs.bind(self.search_panel)), + main_panel=self.prefs_panel, panel_data=self.search_panel.get_prefs.bind(self.search_panel)) - self.panel_map['booklist-search'] = UIState(ClosePanelBar(_('Search for books')), main_panel=self.search_panel) + bss = ClosePanelBar(_('Search for books')) + bss.add_button(icon_name='cogs', tooltip=_('Configure Tag Browser'), action=show_panel_action('booklist-config-tb')) + self.panel_map['booklist-search'] = UIState(bss, main_panel=self.search_panel) def on_resize(self): pass