From 00ff61a76a9cfba2226ebd2b3ccea4615a25fdec Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Thu, 23 Jun 2011 19:11:40 +0100 Subject: [PATCH 1/4] As sent to kovid --- src/calibre/gui2/tag_view.py | 93 ++++++++++++++++++++---------------- 1 file changed, 53 insertions(+), 40 deletions(-) diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py index c3f17105dc..c24adfee95 100644 --- a/src/calibre/gui2/tag_view.py +++ b/src/calibre/gui2/tag_view.py @@ -149,7 +149,8 @@ class TagsView(QTreeView): # {{{ hidden_categories=self.hidden_categories, search_restriction=None, drag_drop_finished=self.drag_drop_finished, - collapse_model=self.collapse_model) + collapse_model=self.collapse_model, + state_map={}) self.pane_is_visible = True # because TagsModel.init did a recount self.sort_by = sort_by self.tag_match = tag_match @@ -173,6 +174,7 @@ class TagsView(QTreeView): # {{{ self.made_connections = True self.refresh_signal_processed = True db.add_listener(self.database_changed) + self.expanded.connect(self.item_expanded) def database_changed(self, event, ids): if self.refresh_signal_processed: @@ -541,6 +543,8 @@ class TagsView(QTreeView): # {{{ return self.isExpanded(idx) def recount(self, *args): + from calibre.utils.mem import memory + print 'start of recount:', memory() if self.disable_recounting or not self.pane_is_visible: return self.refresh_signal_processed = True @@ -548,18 +552,20 @@ class TagsView(QTreeView): # {{{ if not ci.isValid(): ci = self.indexAt(QPoint(10, 10)) path = self.model().path_for_index(ci) if self.is_visible(ci) else None - try: - if not self.model().refresh(): # categories changed! - self.set_new_model() - path = None - except: #Database connection could be closed if an integrity check is happening - pass + expanded_categories, state_map = self.model().get_state() + self.set_new_model(state_map=state_map) + for category in expanded_categories: + self.expand(self.model().index_for_category(category)) self._model.show_item_at_path(path) + print 'end of recount:', memory() + + def item_expanded(self, idx): + self.setCurrentIndex(idx) # If the number of user categories changed, if custom columns have come or # gone, or if columns have been hidden or restored, we must rebuild the # model. Reason: it is much easier than reconstructing the browser tree. - def set_new_model(self, filter_categories_by=None): + def set_new_model(self, filter_categories_by=None, state_map={}): try: old = getattr(self, '_model', None) if old is not None: @@ -569,7 +575,8 @@ class TagsView(QTreeView): # {{{ search_restriction=self.search_restriction, drag_drop_finished=self.drag_drop_finished, filter_categories_by=filter_categories_by, - collapse_model=self.collapse_model) + collapse_model=self.collapse_model, + state_map=state_map) self.setModel(self._model) except: # The DB must be gone. Set the model to None and hope that someone @@ -752,7 +759,8 @@ class TagsModel(QAbstractItemModel): # {{{ def __init__(self, db, parent, hidden_categories=None, search_restriction=None, drag_drop_finished=None, - filter_categories_by=None, collapse_model='disable'): + filter_categories_by=None, collapse_model='disable', + state_map={}): QAbstractItemModel.__init__(self, parent) # must do this here because 'QPixmap: Must construct a QApplication @@ -776,10 +784,11 @@ class TagsModel(QAbstractItemModel): # {{{ self.filter_categories_by = filter_categories_by self.collapse_model = collapse_model - # get_node_tree cannot return None here, because row_map is empty. Note - # that get_node_tree can indirectly change the user_categories dict. + # get_category_nodes cannot return None here, because row_map is empty. + # Note that get_category_nodes can indirectly change the user_categories + # dict. - data = self.get_node_tree(config['sort_tags_by']) + data = self.get_category_nodes(config['sort_tags_by']) gst = db.prefs.get('grouped_search_terms', {}) self.root_item = TagTreeItem(icon_map=self.icon_state_map) self.category_nodes = [] @@ -844,7 +853,7 @@ class TagsModel(QAbstractItemModel): # {{{ category_node_map[key] = node last_category_node = node self.category_nodes.append(node) - self.refresh(data=data) + self.create_node_tree(data, state_map) def break_cycles(self): self.root_item.break_cycles() @@ -1121,7 +1130,7 @@ class TagsModel(QAbstractItemModel): # {{{ def set_search_restriction(self, s): self.search_restriction = s - def get_node_tree(self, sort): + def get_category_nodes(self, sort): old_row_map_len = len(self.row_map) self.row_map = [] self.categories = {} @@ -1184,11 +1193,17 @@ class TagsModel(QAbstractItemModel): # {{{ return data def refresh(self, data=None): + print 'refresh called' + traceback.print_stack() + return False + + def create_node_tree(self, data, state_map): sort_by = config['sort_tags_by'] + if data is None: - data = self.get_node_tree(sort_by) # get category data - if data is None: - return False + print 'NO DATA!!!!' + traceback.print_stack() + return collapse = gprefs['tags_browser_collapse_at'] collapse_model = self.collapse_model @@ -1355,29 +1370,27 @@ class TagsModel(QAbstractItemModel): # {{{ for category in self.category_nodes: if len(category.children) > 0: - child_map = category.children - states = [c.tag.state for c in category.child_tags()] - names = [(c.tag.name, c.tag.category) for c in category.child_tags()] - state_map = dict(izip(names, states)) - # temporary sub-categories (the partitioning ones) must follow - # the permanent sub-categories. This will happen naturally if - # the temp ones are added by process_node - ctags = [c for c in child_map if - c.type == TagTreeItem.CATEGORY and not c.temporary] - start = len(ctags) - self.beginRemoveRows(self.createIndex(category.row(), 0, category), - start, len(child_map)-1) - category.children = ctags - for i in range(start, len(child_map)): - child_map[i].break_cycles() - child_map = None - self.endRemoveRows() - else: - state_map = {} - - process_one_node(category, state_map) + print 'HAS CHILDREN!!!' + continue + process_one_node(category, state_map.get(category.py_name, {})) return True + def get_state(self): + state_map = {} + expanded_categories = [] + for row, category in enumerate(self.category_nodes): + if self.tags_view.isExpanded(self.index(row, 0, QModelIndex())): + expanded_categories.append(category.py_name) + states = [c.tag.state for c in category.child_tags()] + names = [(c.tag.name, c.tag.category) for c in category.child_tags()] + state_map[category.py_name] = dict(izip(names, states)) + return expanded_categories, state_map + + def index_for_category(self, name): + for row, category in enumerate(self.category_nodes): + if category.py_name == name: + return self.index(row, 0, QModelIndex()) + def columnCount(self, parent): return 1 @@ -1476,7 +1489,7 @@ class TagsModel(QAbstractItemModel): # {{{ self.tags_view.tag_item_renamed.emit() item.tag.name = val self.rename_item_in_all_user_categories(name, key, val) - self.refresh() # Should work, because no categories can have disappeared + self.refresh_required.emit() self.show_item_at_path(path) return True From 4afed97a4218d9b1e593b1504fb02f0856be72d9 Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Thu, 23 Jun 2011 19:57:31 +0100 Subject: [PATCH 2/4] As sent to Kovid bis --- src/calibre/gui2/tag_view.py | 65 ++++++++++++++++++++---------------- 1 file changed, 36 insertions(+), 29 deletions(-) diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py index c24adfee95..730c91d37f 100644 --- a/src/calibre/gui2/tag_view.py +++ b/src/calibre/gui2/tag_view.py @@ -543,8 +543,10 @@ class TagsView(QTreeView): # {{{ return self.isExpanded(idx) def recount(self, *args): - from calibre.utils.mem import memory - print 'start of recount:', memory() + ''' + Rebuild the category tree, expand any categories that were expanded, + reset the search states, and reselect the current node. + ''' if self.disable_recounting or not self.pane_is_visible: return self.refresh_signal_processed = True @@ -557,15 +559,18 @@ class TagsView(QTreeView): # {{{ for category in expanded_categories: self.expand(self.model().index_for_category(category)) self._model.show_item_at_path(path) - print 'end of recount:', memory() def item_expanded(self, idx): + ''' + Called by the expanded signal + ''' self.setCurrentIndex(idx) - # If the number of user categories changed, if custom columns have come or - # gone, or if columns have been hidden or restored, we must rebuild the - # model. Reason: it is much easier than reconstructing the browser tree. def set_new_model(self, filter_categories_by=None, state_map={}): + ''' + There are cases where we need to rebuild the category tree without + attempting to reposition the current node. + ''' try: old = getattr(self, '_model', None) if old is not None: @@ -784,11 +789,10 @@ class TagsModel(QAbstractItemModel): # {{{ self.filter_categories_by = filter_categories_by self.collapse_model = collapse_model - # get_category_nodes cannot return None here, because row_map is empty. - # Note that get_category_nodes can indirectly change the user_categories - # dict. + # Note that _get_category_nodes can indirectly change the + # user_categories dict. - data = self.get_category_nodes(config['sort_tags_by']) + data = self._get_category_nodes(config['sort_tags_by']) gst = db.prefs.get('grouped_search_terms', {}) self.root_item = TagTreeItem(icon_map=self.icon_state_map) self.category_nodes = [] @@ -853,7 +857,7 @@ class TagsModel(QAbstractItemModel): # {{{ category_node_map[key] = node last_category_node = node self.category_nodes.append(node) - self.create_node_tree(data, state_map) + self._create_node_tree(data, state_map) def break_cycles(self): self.root_item.break_cycles() @@ -1130,8 +1134,10 @@ class TagsModel(QAbstractItemModel): # {{{ def set_search_restriction(self, s): self.search_restriction = s - def get_category_nodes(self, sort): - old_row_map_len = len(self.row_map) + def _get_category_nodes(self, sort): + ''' + Called by __init__. Do not directly call this method. + ''' self.row_map = [] self.categories = {} @@ -1185,23 +1191,25 @@ class TagsModel(QAbstractItemModel): # {{{ if category in data: # The search category can come and go self.row_map.append(category) self.categories[category] = tb_categories[category]['name'] - - if old_row_map_len != 0 and old_row_map_len != len(self.row_map): - # A category has been added or removed. We must force a rebuild of - # the model - return None return data def refresh(self, data=None): - print 'refresh called' + ''' + Here to trap usages of refresh in the old architecture. Can eventually + be removed. + ''' + print 'TagsModel: refresh called!' traceback.print_stack() return False - def create_node_tree(self, data, state_map): + def _create_node_tree(self, data, state_map): + ''' + Called by __init__. Do not directly call this method. + ''' sort_by = config['sort_tags_by'] if data is None: - print 'NO DATA!!!!' + print '_create_node_tree: no data!' traceback.print_stack() return @@ -1369,11 +1377,7 @@ class TagsModel(QAbstractItemModel): # {{{ # }}} for category in self.category_nodes: - if len(category.children) > 0: - print 'HAS CHILDREN!!!' - continue process_one_node(category, state_map.get(category.py_name, {})) - return True def get_state(self): state_map = {} @@ -1802,19 +1806,22 @@ class TagsModel(QAbstractItemModel): # {{{ return v return None - def show_item_at_path(self, path, box=False): + def show_item_at_path(self, path, box=False, + position=QTreeView.PositionAtCenter): ''' Scroll the browser and open categories to show the item referenced by path. If possible, the item is placed in the center. If box=True, a box is drawn around the item. ''' if path: - self.show_item_at_index(self.index_for_path(path), box) + self.show_item_at_index(self.index_for_path(path), box=box, + position=position) - def show_item_at_index(self, idx, box=False): + def show_item_at_index(self, idx, box=False, + position=QTreeView.PositionAtCenter): if idx.isValid(): self.tags_view.setCurrentIndex(idx) - self.tags_view.scrollTo(idx, QTreeView.PositionAtCenter) + self.tags_view.scrollTo(idx, position) if box: tag_item = idx.internalPointer() tag_item.boxed = True From 545698798bb6da1ce4d307124a7752e09efce2b3 Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Fri, 24 Jun 2011 09:01:04 +0100 Subject: [PATCH 3/4] Improvements to Quickview: - add ability to open edit metadata - make current book tracking work better. --- src/calibre/gui2/dialogs/quickview.py | 59 +++++++++++++++++---------- 1 file changed, 38 insertions(+), 21 deletions(-) diff --git a/src/calibre/gui2/dialogs/quickview.py b/src/calibre/gui2/dialogs/quickview.py index aff37ea152..3a69368730 100644 --- a/src/calibre/gui2/dialogs/quickview.py +++ b/src/calibre/gui2/dialogs/quickview.py @@ -5,11 +5,13 @@ __docformat__ = 'restructuredtext en' from PyQt4.Qt import (Qt, QDialog, QAbstractItemView, QTableWidgetItem, - QListWidgetItem, QByteArray, QModelIndex, QCoreApplication) + QListWidgetItem, QByteArray, QCoreApplication, + QApplication) +from calibre.customize.ui import find_plugin +from calibre.gui2 import gprefs from calibre.gui2.dialogs.quickview_ui import Ui_Quickview from calibre.utils.icu import sort_key -from calibre.gui2 import gprefs class TableItem(QTableWidgetItem): ''' @@ -55,8 +57,9 @@ class Quickview(QDialog, Ui_Quickview): self.is_closed = False self.current_book_id = None self.current_key = None - self.use_current_key_for_next_refresh = False self.last_search = None + self.current_column = None + self.current_item = None self.items.setSelectionMode(QAbstractItemView.SingleSelection) self.items.currentTextChanged.connect(self.item_selected) @@ -87,16 +90,24 @@ class Quickview(QDialog, Ui_Quickview): # Add the data self.refresh(row) - self.view.selectionModel().currentChanged[QModelIndex,QModelIndex].connect(self.slave) + self.view.clicked.connect(self.slave) QCoreApplication.instance().aboutToQuit.connect(self.save_state) self.search_button.clicked.connect(self.do_search) + view.model().new_bookdisplay_data.connect(self.book_was_changed) # search button def do_search(self): if self.last_search is not None: - self.use_current_key_for_next_refresh = True self.gui.search.set_search_string(self.last_search) + # Called when book information is changed in the library view. Make that + # book current. This means that prev and next in edit metadata will move + # the current book. + def book_was_changed(self, mi): + if self.is_closed or self.current_column is None: + return + self.refresh(self.view.model().index(self.db.row(mi.id), self.current_column)) + # clicks on the items listWidget def item_selected(self, txt): self.fill_in_books_box(unicode(txt)) @@ -104,22 +115,15 @@ class Quickview(QDialog, Ui_Quickview): # Given a cell in the library view, display the information def refresh(self, idx): bv_row = idx.row() - key = self.view.model().column_map[idx.column()] - + self.current_column = idx.column() + key = self.view.model().column_map[self.current_column] book_id = self.view.model().id(bv_row) - # Double-clicking on a book to show it in the library view will result - # in a signal emitted for column 1 of the book row. Use the original - # column for this signal. - if self.use_current_key_for_next_refresh: + # Only show items for categories + if not self.db.field_metadata[key]['is_category']: + if self.current_key is None: + return key = self.current_key - self.use_current_key_for_next_refresh = False - else: - # Only show items for categories - if not self.db.field_metadata[key]['is_category']: - if self.current_key is None: - return - key = self.current_key self.items_label.setText('{0} ({1})'.format( self.db.field_metadata[key]['name'], key)) @@ -147,6 +151,7 @@ class Quickview(QDialog, Ui_Quickview): self.items.blockSignals(False) def fill_in_books_box(self, selected_item): + self.current_item = selected_item # Do a bit of fix-up on the items so that the search works. if selected_item.startswith('.'): sv = '.' + selected_item @@ -162,19 +167,26 @@ class Quickview(QDialog, Ui_Quickview): select_item = None self.books_table.setSortingEnabled(False) + tt = ('

' + + _('Double-click on a book to change the selection in the library view. ' + 'Shift- or control-double-click to edit the metadata of a book') + + '

') for row, b in enumerate(books): mi = self.db.get_metadata(b, index_is_id=True, get_user_categories=False) a = TableItem(mi.title, mi.title_sort) a.setData(Qt.UserRole, b) + a.setToolTip(tt) self.books_table.setItem(row, 0, a) if b == self.current_book_id: select_item = a a = TableItem(' & '.join(mi.authors), mi.author_sort) + a.setToolTip(tt) self.books_table.setItem(row, 1, a) series = mi.format_field('series')[1] if series is None: series = '' a = TableItem(series, series) + a.setToolTip(tt) self.books_table.setItem(row, 2, a) self.books_table.setRowHeight(row, self.books_table_row_height) @@ -201,11 +213,16 @@ class Quickview(QDialog, Ui_Quickview): self.save_state() def book_doubleclicked(self, row, column): - self.use_current_key_for_next_refresh = True - self.view.select_rows([self.books_table.item(row, 0).data(Qt.UserRole).toInt()[0]]) + book_id = self.books_table.item(row, 0).data(Qt.UserRole).toInt()[0] + self.view.select_rows([book_id]) + modifiers = int(QApplication.keyboardModifiers()) + if modifiers in (Qt.CTRL, Qt.SHIFT): + em = find_plugin('Edit Metadata') + if em is not None: + em.actual_plugin_.edit_metadata(None) # called when a book is clicked on the library view - def slave(self, current, previous): + def slave(self, current): if self.is_closed: return self.refresh(current) From 03d6c30c738e8ef97d7e11e8196d46745d0d7a43 Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Fri, 24 Jun 2011 16:48:54 +0100 Subject: [PATCH 4/4] Add 2 formatter date functions: today() and days_between() --- src/calibre/utils/formatter_functions.py | 39 ++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/src/calibre/utils/formatter_functions.py b/src/calibre/utils/formatter_functions.py index 4c1cec6462..63264e6379 100644 --- a/src/calibre/utils/formatter_functions.py +++ b/src/calibre/utils/formatter_functions.py @@ -12,7 +12,7 @@ import inspect, re, traceback from calibre.utils.titlecase import titlecase from calibre.utils.icu import capitalize, strcmp, sort_key -from calibre.utils.date import parse_date, format_date +from calibre.utils.date import parse_date, format_date, now, UNDEFINED_DATE class FormatterFunctions(object): @@ -579,7 +579,7 @@ class BuiltinSubitems(BuiltinFormatterFunction): class BuiltinFormatDate(BuiltinFormatterFunction): name = 'format_date' arg_count = 2 - category = 'Get values from metadata' + category = 'Date functions' __doc__ = doc = _('format_date(val, format_string) -- format the value, ' 'which must be a date, using the format_string, returning a string. ' 'The formatting codes are: ' @@ -754,6 +754,39 @@ class BuiltinMergeLists(BuiltinFormatterFunction): res.append(i) return ', '.join(sorted(res, key=sort_key)) +class BuiltinToday(BuiltinFormatterFunction): + name = 'today' + arg_count = 0 + category = 'Date functions' + __doc__ = doc = _('today() -- ' + 'return a date string for today. This value is designed for use in ' + 'format_date or days_between, but can be manipulated like any ' + 'other string. The date is in ISO format.') + def evaluate(self, formatter, kwargs, mi, locals): + return format_date(now(), 'iso') + +class BuiltinDaysBetween(BuiltinFormatterFunction): + name = 'days_between' + arg_count = 2 + category = 'Date functions' + __doc__ = doc = _('days_between(date1, date2) -- ' + 'return the number of days between date1 and date2. The number is ' + 'positive if date1 is greater than date2, otherwise negative. If ' + 'either date1 or date2 are not dates, the function returns the ' + 'empty string.') + def evaluate(self, formatter, kwargs, mi, locals, date1, date2): + try: + d1 = parse_date(date1) + if d1 == UNDEFINED_DATE: + return '' + d2 = parse_date(date2) + if d2 == UNDEFINED_DATE: + return '' + except: + return '' + i = d1 - d2 + return str(i.days) + builtin_add = BuiltinAdd() builtin_and = BuiltinAnd() @@ -763,6 +796,7 @@ builtin_capitalize = BuiltinCapitalize() builtin_cmp = BuiltinCmp() builtin_contains = BuiltinContains() builtin_count = BuiltinCount() +builtin_days_between= BuiltinDaysBetween() builtin_divide = BuiltinDivide() builtin_eval = BuiltinEval() builtin_first_non_empty = BuiltinFirstNonEmpty() @@ -795,6 +829,7 @@ builtin_switch = BuiltinSwitch() builtin_template = BuiltinTemplate() builtin_test = BuiltinTest() builtin_titlecase = BuiltinTitlecase() +builtin_today = BuiltinToday() builtin_uppercase = BuiltinUppercase() class FormatterUserFunction(FormatterFunction):