From e61e40bdf0d8efd4374f659365ee2ec9503ae874 Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Sun, 18 Apr 2010 17:08:27 +0100 Subject: [PATCH 01/10] Custom column changes, etc. Also removed a print statement --- resources/default_tweaks.py | 17 + resources/images/blank.svg | 63 +++ resources/images/column.svg | 61 +++ src/calibre/gui2/__init__.py | 2 + src/calibre/gui2/dialogs/comments_dialog.py | 17 + src/calibre/gui2/dialogs/comments_dialog.ui | 83 ++++ src/calibre/gui2/dialogs/config/__init__.py | 98 +++- src/calibre/gui2/dialogs/config/config.ui | 81 ++++ .../dialogs/config/create_custom_column.py | 123 +++++ .../dialogs/config/create_custom_column.ui | 142 ++++++ src/calibre/gui2/dialogs/tag_categories.py | 172 +++++++ src/calibre/gui2/dialogs/tag_categories.ui | 427 ++++++++++++++++ src/calibre/gui2/library.py | 458 +++++++++++++----- src/calibre/gui2/main.ui | 66 ++- src/calibre/gui2/search_box.py | 5 + src/calibre/gui2/tag_view.py | 156 +++++- src/calibre/gui2/ui.py | 107 +++- src/calibre/library/caches.py | 186 +++++-- src/calibre/library/custom_columns.py | 2 +- src/calibre/library/database2.py | 108 ++++- src/calibre/utils/search_query_parser.py | 20 +- 21 files changed, 2129 insertions(+), 265 deletions(-) create mode 100644 resources/images/blank.svg create mode 100644 resources/images/column.svg create mode 100644 src/calibre/gui2/dialogs/comments_dialog.py create mode 100644 src/calibre/gui2/dialogs/comments_dialog.ui create mode 100644 src/calibre/gui2/dialogs/config/create_custom_column.py create mode 100644 src/calibre/gui2/dialogs/config/create_custom_column.ui create mode 100644 src/calibre/gui2/dialogs/tag_categories.py create mode 100644 src/calibre/gui2/dialogs/tag_categories.ui diff --git a/resources/default_tweaks.py b/resources/default_tweaks.py index 77cfaaedf5..b18789565d 100644 --- a/resources/default_tweaks.py +++ b/resources/default_tweaks.py @@ -25,3 +25,20 @@ series_index_auto_increment = 'next' # copy : copy author to author_sort without modification # comma : use 'copy' if there is a ',' in the name, otherwise use 'invert' author_sort_copy_method = 'invert' + + +# Set whether boolean custom columns are two- or three-valued. +# Two-values for true booleans +# three-values for yes/no/unknown +# Set to 'yes' for three-values, 'no' for two-values +bool_custom_columns_are_tristate = 'yes' + + +# Provide a set of columns to be sorted on when calibre starts +# The argument is None of saved sort history is to be used +# otherwise it is a list of column,order pairs. Column is the +# lookup/search name, found using the tooltip for the column +# Order is 0 for ascending, 1 for descending +# For example, set it to [('authors',0),('title',0)] to sort by +# title within authors. +sort_columns_at_startup = None \ No newline at end of file diff --git a/resources/images/blank.svg b/resources/images/blank.svg new file mode 100644 index 0000000000..c19057d80f --- /dev/null +++ b/resources/images/blank.svg @@ -0,0 +1,63 @@ + + + + + + + + + + + + image/svg+xml + + + + + + + diff --git a/resources/images/column.svg b/resources/images/column.svg new file mode 100644 index 0000000000..4d6f4b809e --- /dev/null +++ b/resources/images/column.svg @@ -0,0 +1,61 @@ + +Capitello modanatura modanature moulure mouluresbuildingArchitetto Francesco RollandinArchitetto Francesco RollandinArchitetto Francesco Rollandinimage/svg+xmlen + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/calibre/gui2/__init__.py b/src/calibre/gui2/__init__.py index f467d5cc80..12987caeb9 100644 --- a/src/calibre/gui2/__init__.py +++ b/src/calibre/gui2/__init__.py @@ -13,6 +13,7 @@ from PyQt4.QtGui import QFileDialog, QMessageBox, QPixmap, QFileIconProvider, \ ORG_NAME = 'KovidsBrain' APP_UID = 'libprs500' from calibre import islinux, iswindows, isosx, isfreebsd +from calibre.constants import preferred_encoding from calibre.utils.config import Config, ConfigProxy, dynamic, JSONConfig from calibre.utils.localization import set_qt_translator from calibre.ebooks.metadata.meta import get_metadata, metadata_from_formats @@ -95,6 +96,7 @@ def _config(): help=_('Overwrite author and title with new metadata')) c.add_opt('enforce_cpu_limit', default=True, help=_('Limit max simultaneous jobs to number of CPUs')) + c.add_opt('tag_categories', default={}, help=_('User-created tag categories')) return ConfigProxy(c) diff --git a/src/calibre/gui2/dialogs/comments_dialog.py b/src/calibre/gui2/dialogs/comments_dialog.py new file mode 100644 index 0000000000..e3b256f7f9 --- /dev/null +++ b/src/calibre/gui2/dialogs/comments_dialog.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python +__license__ = 'GPL v3' +__copyright__ = '2008, Kovid Goyal kovid@kovidgoyal.net' +__docformat__ = 'restructuredtext en' + +from PyQt4.Qt import QDialog +from calibre.gui2 import ResizableDialog +from calibre.gui2.dialogs.comments_dialog_ui import Ui_CommentsDialog + +class CommentsDialog(QDialog, Ui_CommentsDialog): + def __init__(self, parent, text): + QDialog.__init__(self, parent) + Ui_CommentsDialog.__init__(self) + self.setupUi(self) + if text is not None: + self.textbox.setPlainText(text) + self.textbox.setTabChangesFocus(True) \ No newline at end of file diff --git a/src/calibre/gui2/dialogs/comments_dialog.ui b/src/calibre/gui2/dialogs/comments_dialog.ui new file mode 100644 index 0000000000..b05069f1f6 --- /dev/null +++ b/src/calibre/gui2/dialogs/comments_dialog.ui @@ -0,0 +1,83 @@ + + + CommentsDialog + + + + 0 + 0 + 336 + 235 + + + + + 0 + 0 + + + + Edit Comments + + + + + 10 + 10 + 311 + 211 + + + + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + + + buttonBox + accepted() + CommentsDialog + accept() + + + 229 + 211 + + + 157 + 234 + + + + + buttonBox + rejected() + CommentsDialog + reject() + + + 297 + 217 + + + 286 + 234 + + + + + diff --git a/src/calibre/gui2/dialogs/config/__init__.py b/src/calibre/gui2/dialogs/config/__init__.py index 88697e55bb..26d99b4ff2 100644 --- a/src/calibre/gui2/dialogs/config/__init__.py +++ b/src/calibre/gui2/dialogs/config/__init__.py @@ -1,6 +1,6 @@ __license__ = 'GPL v3' __copyright__ = '2008, Kovid Goyal ' -import os, re, time, textwrap +import os, re, time, textwrap, sys, copy from PyQt4.Qt import QDialog, QListWidgetItem, QIcon, \ QDesktopServices, QVBoxLayout, QLabel, QPlainTextEdit, \ @@ -8,10 +8,11 @@ from PyQt4.Qt import QDialog, QListWidgetItem, QIcon, \ SIGNAL, QThread, Qt, QSize, QVariant, QUrl, \ QModelIndex, QAbstractTableModel, \ QDialogButtonBox, QTabWidget, QBrush, QLineEdit, \ - QProgressDialog + QProgressDialog, QMessageBox -from calibre.constants import iswindows, isosx +from calibre.constants import iswindows, isosx, preferred_encoding from calibre.gui2.dialogs.config.config_ui import Ui_Dialog +from calibre.gui2.dialogs.config.create_custom_column import CreateCustomColumn from calibre.gui2 import qstring_to_unicode, choose_dir, error_dialog, config, \ ALL_COLUMNS, NONE, info_dialog, choose_files, \ warning_dialog, ResizableDialog @@ -90,7 +91,6 @@ class ConfigTabs(QTabWidget): widget.commit(save_defaults=True) return True - class PluginModel(QAbstractItemModel): def __init__(self, *args): @@ -328,14 +328,16 @@ class ConfigDialog(ResizableDialog, Ui_Dialog): def category_current_changed(self, n, p): self.stackedWidget.setCurrentIndex(n.row()) - def __init__(self, window, db, server=None): - ResizableDialog.__init__(self, window) + def __init__(self, parent, model, server=None): + ResizableDialog.__init__(self, parent) self.ICON_SIZES = {0:QSize(48, 48), 1:QSize(32,32), 2:QSize(24,24)} self._category_model = CategoryModel() self.category_view.currentChanged = self.category_current_changed self.category_view.setModel(self._category_model) - self.db = db + self.parent = parent + self.model = model + self.db = model.db self.server = server path = prefs['library_path'] self.location.setText(path if path else '') @@ -359,15 +361,27 @@ class ConfigDialog(ResizableDialog, Ui_Dialog): self.roman_numerals.setChecked(rn) self.new_version_notification.setChecked(config['new_version_notification']) - column_map = config['column_map'] - for col in column_map + [i for i in ALL_COLUMNS if i not in column_map]: - item = QListWidgetItem(BooksModel.headers[col], self.columns) + # Set up columns + # Make copies of maps so that internal changes aren't put into the real maps + self.colmap = config['column_map'][:] + self.custcols = copy.deepcopy(self.db.custom_column_label_map) + cm = [c.decode(preferred_encoding, 'replace') for c in self.colmap] + ac = [c.decode(preferred_encoding, 'replace') for c in ALL_COLUMNS] + for col in cm + \ + [i for i in ac if i not in cm] + \ + [i for i in self.custcols if i not in cm]: + if col in ALL_COLUMNS: + item = QListWidgetItem(model.headers[col], self.columns) + else: + item = QListWidgetItem(self.custcols[col]['name'], self.columns) item.setData(Qt.UserRole, QVariant(col)) item.setFlags(Qt.ItemIsEnabled|Qt.ItemIsUserCheckable|Qt.ItemIsSelectable) - item.setCheckState(Qt.Checked if col in column_map else Qt.Unchecked) - + item.setCheckState(Qt.Checked if col in self.colmap else Qt.Unchecked) self.connect(self.column_up, SIGNAL('clicked()'), self.up_column) self.connect(self.column_down, SIGNAL('clicked()'), self.down_column) + self.connect(self.del_custcol_button, SIGNAL('clicked()'), self.del_custcol) + self.connect(self.add_custcol_button, SIGNAL('clicked()'), self.add_custcol) + self.connect(self.edit_custcol_button, SIGNAL('clicked()'), self.edit_custcol) icons = config['toolbar_icon_size'] self.toolbar_button_size.setCurrentIndex(0 if icons == self.ICON_SIZES[0] else 1 if icons == self.ICON_SIZES[1] else 2) @@ -398,7 +412,6 @@ class ConfigDialog(ResizableDialog, Ui_Dialog): for item in items: self.language.addItem(item[1], QVariant(item[0])) - exts = set([]) for ext in BOOK_EXTENSIONS: ext = ext.lower() @@ -633,6 +646,31 @@ class ConfigDialog(ResizableDialog, Ui_Dialog): self.columns.insertItem(idx+1, self.columns.takeItem(idx)) self.columns.setCurrentRow(idx+1) + def del_custcol(self): + idx = self.columns.currentRow() + if idx < 0: + self.messagebox(_('You must select a column to delete it')) + return + col = qstring_to_unicode(self.columns.item(idx).data(Qt.UserRole).toString()) + if col not in self.custcols: + self.messagebox(_('The selected column is not a custom column')) + return + ret = self.messagebox(_('Do you really want to delete column %s and all its data')%self.custcols[col]['name'], + buttons=QMessageBox.Ok|QMessageBox.Cancel, + defaultButton=QMessageBox.Cancel) + if ret != QMessageBox.Ok: + return + self.columns.item(idx).setCheckState(False) + self.columns.takeItem(idx) + self.custcols[col]['*deleteme'] = True + return + + def add_custcol(self): + d = CreateCustomColumn(self, False, self.model.orig_headers, ALL_COLUMNS) + + def edit_custcol(self): + d = CreateCustomColumn(self, True, self.model.orig_headers, ALL_COLUMNS) + def view_server_logs(self): from calibre.library.server import log_access_file, log_error_file d = QDialog(self) @@ -702,7 +740,6 @@ class ConfigDialog(ResizableDialog, Ui_Dialog): if dir: self.location.setText(dir) - def accept(self): mcs = unicode(self.max_cover_size.text()).strip() if not re.match(r'\d+x\d+', mcs): @@ -720,17 +757,38 @@ class ConfigDialog(ResizableDialog, Ui_Dialog): wl += 1 config['worker_limit'] = wl - config['use_roman_numerals_for_series_number'] = bool(self.roman_numerals.isChecked()) config['new_version_notification'] = bool(self.new_version_notification.isChecked()) prefs['network_timeout'] = int(self.timeout.value()) path = qstring_to_unicode(self.location.text()) input_cols = [unicode(self.input_order.item(i).data(Qt.UserRole).toString()) for i in range(self.input_order.count())] prefs['input_format_order'] = input_cols - cols = [unicode(self.columns.item(i).data(Qt.UserRole).toString()) for i in range(self.columns.count()) if self.columns.item(i).checkState()==Qt.Checked] + + ####### Now deal with changes to columns + cols = [qstring_to_unicode(self.columns.item(i).data(Qt.UserRole).toString())\ + for i in range(self.columns.count()) \ + if self.columns.item(i).checkState()==Qt.Checked] if not cols: cols = ['title'] config['column_map'] = cols + must_restart = False + for c in self.custcols: + if self.custcols[c]['num'] is None: + self.db.create_custom_column( + label=c, + name=self.custcols[c]['name'], + datatype=self.custcols[c]['datatype'], + is_multiple=self.custcols[c]['is_multiple']) + must_restart = True + elif '*deleteme' in self.custcols[c]: + self.db.delete_custom_column(label=c) + must_restart = True + elif '*edited' in self.custcols[c]: + cc = self.custcols[c] + self.db.set_custom_column_metadata(cc['num'], name=cc['name'], label=cc['label']) + if '*must_restart' in self.custcols[c]: + must_restart = True + config['toolbar_icon_size'] = self.ICON_SIZES[self.toolbar_button_size.currentIndex()] config['show_text_in_toolbar'] = bool(self.show_toolbar_text.isChecked()) config['separate_cover_flow'] = bool(self.separate_cover_flow.isChecked()) @@ -771,8 +829,16 @@ class ConfigDialog(ResizableDialog, Ui_Dialog): d.exec_() else: self.database_location = os.path.abspath(path) + if must_restart: + self.messagebox(_('The changes you made require that Calibre be restarted. Please restart as soon as practical.')) + self.parent.must_restart_before_config = True QDialog.accept(self) + # might want to substitute the standard calibre box. However, the copy_to_clipboard + # functionality has no purpose, so ??? + def messagebox(self, m, buttons=QMessageBox.Ok, defaultButton=QMessageBox.Ok): + return QMessageBox.critical(None,'Calibre configuration', m, buttons, defaultButton) + class VacThread(QThread): def __init__(self, parent, db): diff --git a/src/calibre/gui2/dialogs/config/config.ui b/src/calibre/gui2/dialogs/config/config.ui index aff157bb08..22e1b30683 100644 --- a/src/calibre/gui2/dialogs/config/config.ui +++ b/src/calibre/gui2/dialogs/config/config.ui @@ -498,6 +498,87 @@ + + + + Remove a user-defined column + + + ... + + + + :/images/minus.svg:/images/minus.svg + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + ... + + + Add a user-defined column + + + + :/images/plus.svg:/images/plus.svg + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + Edit settings of a user-defined column + + + ... + + + + :/images/edit_input.svg:/images/edit_input.svg + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + diff --git a/src/calibre/gui2/dialogs/config/create_custom_column.py b/src/calibre/gui2/dialogs/config/create_custom_column.py new file mode 100644 index 0000000000..0b6d15a2b5 --- /dev/null +++ b/src/calibre/gui2/dialogs/config/create_custom_column.py @@ -0,0 +1,123 @@ +__license__ = 'GPL v3' +__copyright__ = '2010, Kovid Goyal ' + +'''Dialog to create a new custom column''' + +from PyQt4.QtCore import SIGNAL, QObject +from PyQt4.Qt import QDialog, Qt, QMessageBox, QListWidgetItem, QVariant +from calibre.gui2.dialogs.config.create_custom_column_ui import Ui_QCreateCustomColumn +from calibre.gui2 import ALL_COLUMNS, qstring_to_unicode + +class CreateCustomColumn(QDialog, Ui_QCreateCustomColumn): + column_types = { + 0:{'datatype':'text', 'text':_('Text, column shown in tags browser'), 'is_multiple':False}, + 1:{'datatype':'*text', 'text':_('Comma separated text, shown in tags browser'), 'is_multiple':True}, + 2:{'datatype':'comments', 'text':_('Text, column not shown in tags browser'), 'is_multiple':False}, + 3:{'datatype':'datetime', 'text':_('Date'), 'is_multiple':False}, + 4:{'datatype':'float', 'text':_('Float'), 'is_multiple':False}, + 5:{'datatype':'int', 'text':_('Integer'), 'is_multiple':False}, + 6:{'datatype':'rating', 'text':_('Rating (stars)'), 'is_multiple':False}, + 7:{'datatype':'bool', 'text':_('Yes/No'), 'is_multiple':False}, + } + def __init__(self, parent, editing, standard_colheads, standard_colnames): + QDialog.__init__(self, parent) + Ui_QCreateCustomColumn.__init__(self) + self.setupUi(self) + self.connect(self.button_box, SIGNAL("accepted()"), self.accept) + self.connect(self.button_box, SIGNAL("rejected()"), self.reject) + self.parent = parent + self.editing_col = editing + self.standard_colheads = standard_colheads + self.standard_colnames = standard_colnames + if not self.editing_col: + for t in self.column_types: + self.column_type_box.addItem(self.column_types[t]['text']) + self.exec_() + return + idx = parent.columns.currentRow() + if idx < 0: + self.parent.messagebox(_('No column has been selected')) + return + col = qstring_to_unicode(parent.columns.item(idx).data(Qt.UserRole).toString()) + if col not in parent.custcols: + self.parent.messagebox(_('Selected column is not a user-defined column')) + return + + c = parent.custcols[col] + self.column_name_box.setText(c['label']) + self.column_heading_box.setText(c['name']) + ct = c['datatype'] if not c['is_multiple'] else '*text' + self.orig_column_number = c['num'] + self.orig_column_name = col + column_numbers = dict(map(lambda x:(self.column_types[x]['datatype'], x), self.column_types)) + self.column_type_box.addItem(self.column_types[column_numbers[ct]]['text']) + self.exec_() + + def accept(self): + col = qstring_to_unicode(self.column_name_box.text()) + col_heading = qstring_to_unicode(self.column_heading_box.text()) + col_type = self.column_types[self.column_type_box.currentIndex()]['datatype'] + if col_type == '*text': + col_type='text' + is_multiple = True + else: + is_multiple = False + if not col: + self.parent.messagebox(_('No lookup name was provided')) + return + if not col_heading: + self.parent.messagebox(_('No column heading was provided')) + return + bad_col = False + if col in self.parent.custcols: + if not self.editing_col or self.parent.custcols[col]['num'] != self.orig_column_number: + bad_col = True + if col in self.standard_colnames: + bad_col = True + if bad_col: + self.parent.messagebox(_('The lookup name is already used')) + return + bad_head = False + for t in self.parent.custcols: + if self.parent.custcols[t]['name'] == col_heading: + if not self.editing_col or self.parent.custcols[t]['num'] != self.orig_column_number: + bad_head = True + for t in self.standard_colheads: + if self.standard_colheads[t] == col_heading: + bad_head = True + if bad_head: + self.parent.messagebox(_('The heading %s is already used')%col_heading) + return + if col.find(':') >= 0 or col.find(' ') >= 0 and \ + (not is_alpha(col) or is_lower(col)): + self.parent.messagebox(_('The lookup name must be lower case and cannot contain ":"s or spaces')) + return + + if not self.editing_col: + self.parent.custcols[col] = { + 'label':col, + 'name':col_heading, + 'datatype':col_type, + 'editable':True, + 'display':None, + 'normalized':None, + 'num':None, + 'is_multiple':is_multiple, + } + item = QListWidgetItem(col_heading, self.parent.columns) + item.setData(Qt.UserRole, QVariant(col)) + item.setFlags(Qt.ItemIsEnabled|Qt.ItemIsUserCheckable|Qt.ItemIsSelectable) + item.setCheckState(Qt.Checked) + else: + idx = self.parent.columns.currentRow() + item = self.parent.columns.item(idx) + item.setData(Qt.UserRole, QVariant(col)) + item.setText(col_heading) + self.parent.custcols[self.orig_column_name]['label'] = col + self.parent.custcols[self.orig_column_name]['name'] = col_heading + self.parent.custcols[self.orig_column_name]['*edited'] = True + self.parent.custcols[self.orig_column_name]['*must_restart'] = True + QDialog.accept(self) + + def reject(self): + QDialog.reject(self) \ No newline at end of file diff --git a/src/calibre/gui2/dialogs/config/create_custom_column.ui b/src/calibre/gui2/dialogs/config/create_custom_column.ui new file mode 100644 index 0000000000..17291c020d --- /dev/null +++ b/src/calibre/gui2/dialogs/config/create_custom_column.ui @@ -0,0 +1,142 @@ + + + QCreateCustomColumn + + + Qt::ApplicationModal + + + + 0 + 0 + 391 + 157 + + + + + 0 + 0 + + + + Create Tag-based Column + + + + + 10 + 0 + 371 + 141 + + + + + QLayout::SetDefaultConstraint + + + 5 + + + + + + + Lookup name + + + + + + + Column heading + + + + + + + + 20 + 0 + + + + Used for searching the column. Must be lower case and not contain spaces or colons. + + + + + + + Column heading in the library view and category name in tags browser + + + + + + + Column type + + + + + + + + 0 + 0 + + + + + 70 + 0 + + + + What kind of information will be kept in the column. + + + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + true + + + + + + + + 75 + true + + + + Create and edit custom columns + + + + + + + + column_name_box + column_heading_box + column_type_box + button_box + + + + diff --git a/src/calibre/gui2/dialogs/tag_categories.py b/src/calibre/gui2/dialogs/tag_categories.py new file mode 100644 index 0000000000..d090c3e424 --- /dev/null +++ b/src/calibre/gui2/dialogs/tag_categories.py @@ -0,0 +1,172 @@ +__license__ = 'GPL v3' + +__copyright__ = '2008, Kovid Goyal ' +from PyQt4.QtCore import SIGNAL, Qt +from PyQt4.QtGui import QDialog, QDialogButtonBox, QLineEdit, QComboBox +from PyQt4.Qt import QString + +from calibre.gui2.dialogs.tag_categories_ui import Ui_TagCategories +from calibre.gui2 import qstring_to_unicode, config +from calibre.gui2 import question_dialog, error_dialog +from calibre.gui2.dialogs.confirm_delete import confirm +from calibre.constants import islinux + +class TagCategories(QDialog, Ui_TagCategories): + category_names = [_('Authors'), _('Series'), _('Publishers'), _('Tags')] + category_labels = ['author', 'series', 'publisher', 'tag'] + + + def __init__(self, window, db, index=None): + QDialog.__init__(self, window) + Ui_TagCategories.__init__(self) + self.setupUi(self) + + self.db = db + self.index = index + self.tags = [] + + self.all_items = {} + self.all_items['tag'] = sorted(self.db.all_tags(), cmp=lambda x,y: cmp(x.lower(), y.lower())) + self.all_items['author'] = sorted([i[1].replace('|', ',') for i in self.db.all_authors()], + cmp=lambda x,y: cmp(x.lower(), y.lower())) + self.all_items['publisher'] = sorted([i[1] for i in self.db.all_publishers()], + cmp=lambda x,y: cmp(x.lower(), y.lower())) + self.all_items['series'] = sorted([i[1] for i in self.db.all_series()], + cmp=lambda x,y: cmp(x.lower(), y.lower())) + self.current_cat_name = None + self.current_cat_label= None + self.category_label_to_name = {} + self.category_name_to_label = {} + for i in range(len(self.category_labels)): + self.category_label_to_name[self.category_labels[i]] = self.category_names[i] + self.category_name_to_label[self.category_names[i]] = self.category_labels[i] + + self.connect(self.apply_button, SIGNAL('clicked()'), self.apply_tags) + self.connect(self.unapply_button, SIGNAL('clicked()'), self.unapply_tags) + self.connect(self.add_category_button, SIGNAL('clicked()'), self.add_category) + self.connect(self.category_box, SIGNAL('currentIndexChanged(int)'), self.select_category) + self.connect(self.delete_category_button, SIGNAL('clicked()'), self.del_category) + if islinux: + self.available_tags.itemDoubleClicked.connect(self.apply_tags) + else: + self.connect(self.available_tags, SIGNAL('itemActivated(QListWidgetItem*)'), self.apply_tags) + self.connect(self.applied_tags, SIGNAL('itemActivated(QListWidgetItem*)'), self.unapply_tags) + + self.categories = dict.copy(config['tag_categories']) + if self.categories is None: + self.categories = {} + self.populate_category_list() + self.category_kind_box.clear() + for i in range(len(self.category_names)): + self.category_kind_box.addItem(self.category_names[i]) + self.select_category(0) + + def apply_tags(self, item=None): + if self.current_cat_name[0] is None: + return + items = self.available_tags.selectedItems() if item is None else [item] + for item in items: + tag = qstring_to_unicode(item.text()) + if tag not in self.tags: + self.tags.append(tag) + self.available_tags.takeItem(self.available_tags.row(item)) + self.tags.sort() + self.applied_tags.clear() + for tag in self.tags: + self.applied_tags.addItem(tag) + def unapply_tags(self, item=None): + items = self.applied_tags.selectedItems() if item is None else [item] + for item in items: + tag = qstring_to_unicode(item.text()) + self.tags.remove(tag) + self.available_tags.addItem(tag) + self.tags.sort() + self.applied_tags.clear() + for tag in self.tags: + self.applied_tags.addItem(tag) + self.available_tags.sortItems() + + def add_category(self): + self.save_category() + cat_name = qstring_to_unicode(self.input_box.text()).strip() + if cat_name == '': + return + cat_kind = unicode(self.category_kind_box.currentText()) + r_cat_kind = self.category_name_to_label[cat_kind] + if r_cat_kind not in self.categories: + self.categories[r_cat_kind] = {} + if cat_name not in self.categories[r_cat_kind]: + self.category_box.clear() + self.category_kind_label.setText(cat_kind) + self.current_cat_name = cat_name + self.current_cat_label = r_cat_kind + self.categories[r_cat_kind][cat_name] = [] + if len(self.tags): + self.clear_boxes(item_label=self.current_cat_label) + self.populate_category_list() + self.category_box.setCurrentIndex(self.category_box.findText(cat_name)) + else: + self.select_category(self.category_box.findText(cat_name)) + return True + + def del_category(self): + if not confirm('

'+_('The current tag category will be ' + 'permanently deleted. Are you sure?') + +'

', 'tag_category_delete', self): + return + print 'here', self.current_category + if self.current_cat_name is not None: + if self.current_cat_name == unicode(self.category_box.currentText()): + del self.categories[self.current_cat_label][self.current_cat_name] + self.current_category = [None, None] ## order here is important. RemoveItem will put it back + self.category_box.removeItem(self.category_box.currentIndex()) + + def select_category(self, idx): + self.save_category() + s = self.category_box.itemText(idx) + if s: + self.current_cat_name = unicode(s) + self.current_cat_label = str(self.category_box.itemData(idx).toString()) + else: + self.current_cat_name = None + self.current_cat_label = None + self.clear_boxes(item_label=False) + if self.current_cat_label: + self.category_kind_label.setText(self.category_label_to_name[self.current_cat_label]) + self.tags = self.categories[self.current_cat_label].get(self.current_cat_name, []) + # Must do two loops because obsolete values can be saved + # We need to show these to the user so they can be deleted if desired + for t in self.tags: + self.applied_tags.addItem(t) + for t in self.all_items[self.current_cat_label]: + if t not in self.tags: + self.available_tags.addItem(t) + else: + self.category_kind_label.setText('') + + + def clear_boxes(self, item_label = None): + self.tags = [] + self.applied_tags.clear() + self.available_tags.clear() + if item_label: + for item in self.all_items[item_label]: + self.available_tags.addItem(item) + + def accept(self): + self.save_category() + config['tag_categories'] = self.categories + QDialog.accept(self) + + def save_category(self): + if self.current_cat_name is not None: + self.categories[self.current_cat_label][self.current_cat_name] = self.tags + + def populate_category_list(self): + cat_list = {} + for c in self.categories: + for n in self.categories[c]: + if n.strip(): + cat_list[n] = c + for n in sorted(cat_list.keys(), cmp=lambda x,y: cmp(x.lower(), y.lower())): + self.category_box.addItem(n, cat_list[n]) \ No newline at end of file diff --git a/src/calibre/gui2/dialogs/tag_categories.ui b/src/calibre/gui2/dialogs/tag_categories.ui new file mode 100644 index 0000000000..9b3b58bc87 --- /dev/null +++ b/src/calibre/gui2/dialogs/tag_categories.ui @@ -0,0 +1,427 @@ + + + TagCategories + + + + 0 + 0 + 588 + 482 + + + + Tag Editor + + + + :/images/chapters.svg:/images/chapters.svg + + + + + + + + + + A&vailable values + + + available_tags + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + true + + + QAbstractItemView::MultiSelection + + + QAbstractItemView::SelectRows + + + + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + Apply tags to current tag category + + + ... + + + + :/images/forward.svg:/images/forward.svg + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + + + + + A&pplied values + + + applied_tags + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + true + + + QAbstractItemView::MultiSelection + + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + Unapply (remove) tag from current tag category + + + ... + + + + :/images/list_remove.svg:/images/list_remove.svg + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + true + + + + + + + + + + 0 + 0 + + + + + 100 + 0 + + + + Category name: + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter + + + category_box + + + + + + + + 160 + 0 + + + + + 145 + 0 + + + + Select a category to edit + + + false + + + + + + + Delete this selected tag category + + + ... + + + + :/images/minus.svg:/images/minus.svg + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + 60 + 0 + + + + Enter a new category name. Select the kind before adding it. + + + + + + + Add the new category + + + ... + + + + :/images/plus.svg:/images/plus.svg + + + + + + + Select the content kind of the new category + + + + Author + + + + + Series + + + + + Formats + + + + + Publishers + + + + + Tags + + + + + + + + Category kind: + + + category_kind_box + + + + + + + Qt::Horizontal + + + + 20 + 20 + + + + + + + + TextLabel + + + + + + + Category kind: + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter + + + + + + + + + + + + + buttonBox + accepted() + TagCategories + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + TagCategories + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/src/calibre/gui2/library.py b/src/calibre/gui2/library.py index 746d97ca32..2261e29479 100644 --- a/src/calibre/gui2/library.py +++ b/src/calibre/gui2/library.py @@ -1,34 +1,36 @@ __license__ = 'GPL v3' __copyright__ = '2008, Kovid Goyal ' -import os, textwrap, traceback, re, shutil +import os, textwrap, traceback, re, shutil, functools + from operator import attrgetter from math import cos, sin, pi from contextlib import closing +from datetime import date from PyQt4.QtGui import QTableView, QAbstractItemView, QColor, \ QItemDelegate, QPainterPath, QLinearGradient, QBrush, \ - QPen, QStyle, QPainter, \ + QPen, QStyle, QPainter, QIcon,\ QImage, QApplication, QMenu, \ - QStyledItemDelegate, QCompleter + QStyledItemDelegate, QCompleter, QIntValidator, \ + QPlainTextEdit, QDoubleValidator, QCheckBox, QMessageBox from PyQt4.QtCore import QAbstractTableModel, QVariant, Qt, pyqtSignal, \ - SIGNAL, QObject, QSize, QModelIndex, QDate + SIGNAL, QObject, QSize, QModelIndex, QDate, QRect from calibre import strftime -from calibre.ptempfile import PersistentTemporaryFile -from calibre.utils.pyparsing import ParseException -from calibre.library.caches import _match, CONTAINS_MATCH, EQUALS_MATCH, REGEXP_MATCH -from calibre.gui2 import NONE, TableView, qstring_to_unicode, config, \ - error_dialog -from calibre.gui2.widgets import EnLineEdit, TagsLineEdit -from calibre.utils.search_query_parser import SearchQueryParser +from calibre.ebooks.metadata import string_to_authors, fmt_sidx, authors_to_string from calibre.ebooks.metadata.meta import set_metadata as _set_metadata -from calibre.ebooks.metadata import string_to_authors, fmt_sidx, \ - authors_to_string -from calibre.utils.config import tweaks +from calibre.gui2 import NONE, TableView, qstring_to_unicode, config, error_dialog +from calibre.gui2.dialogs.comments_dialog import CommentsDialog +from calibre.gui2.widgets import EnLineEdit, TagsLineEdit +from calibre.library.caches import _match, CONTAINS_MATCH, EQUALS_MATCH, REGEXP_MATCH +from calibre.ptempfile import PersistentTemporaryFile +from calibre.utils.config import tweaks, prefs from calibre.utils.date import dt_factory, qt_to_dt, isoformat +from calibre.utils.pyparsing import ParseException +from calibre.utils.search_query_parser import SearchQueryParser -class LibraryDelegate(QItemDelegate): +class RatingDelegate(QItemDelegate): COLOR = QColor("blue") SIZE = 16 PEN = QPen(COLOR, 1, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin) @@ -54,7 +56,10 @@ class LibraryDelegate(QItemDelegate): return QSize(5*(self.SIZE), self.SIZE+4) def paint(self, painter, option, index): - num = index.model().data(index, Qt.DisplayRole).toInt()[0] + if index.model().data(index, Qt.DisplayRole) is None: + num = 0 + else: + num = index.model().data(index, Qt.DisplayRole).toInt()[0] def draw_star(): painter.save() painter.scale(self.factor, self.factor) @@ -95,7 +100,6 @@ class LibraryDelegate(QItemDelegate): return sb class DateDelegate(QStyledItemDelegate): - def displayText(self, val, locale): d = val.toDate() return d.toString('dd MMM yyyy') @@ -111,7 +115,6 @@ class DateDelegate(QStyledItemDelegate): return qde class PubDateDelegate(QStyledItemDelegate): - def displayText(self, val, locale): return val.toDate().toString('MMM yyyy') @@ -123,7 +126,6 @@ class PubDateDelegate(QStyledItemDelegate): return qde class TextDelegate(QStyledItemDelegate): - def __init__(self, parent): ''' Delegate for text data. If auto_complete_function needs to return a list @@ -147,7 +149,6 @@ class TextDelegate(QStyledItemDelegate): return editor class TagsDelegate(QStyledItemDelegate): - def __init__(self, parent): QStyledItemDelegate.__init__(self, parent) self.db = None @@ -157,17 +158,101 @@ class TagsDelegate(QStyledItemDelegate): def createEditor(self, parent, option, index): if self.db: - editor = TagsLineEdit(parent, self.db.all_tags()) + col = index.model().column_map[index.column()] + if not index.model().is_custom_column(col): + editor = TagsLineEdit(parent, self.db.all_tags()) + else: + editor = TagsLineEdit(parent, sorted(list(self.db.all_custom(label=col)))) + return editor; else: editor = EnLineEdit(parent) return editor +class CcTextDelegate(QStyledItemDelegate): + def __init__(self, parent): + ''' + Delegate for text/int/float data. + ''' + QStyledItemDelegate.__init__(self, parent) + def createEditor(self, parent, option, index): + m = index.model() + col = m.column_map[index.column()] + typ = m.custom_columns[col]['datatype'] + editor = EnLineEdit(parent) + if typ == 'int': + editor.setValidator(QIntValidator(parent)) + elif typ == 'float': + editor.setValidator(QDoubleValidator(parent)) + else: + complete_items = sorted(list(m.db.all_custom(label=col))) + completer = QCompleter(complete_items, self) + completer.setCaseSensitivity(Qt.CaseInsensitive) + completer.setCompletionMode(QCompleter.PopupCompletion) + editor.setCompleter(completer) + return editor + +class CcCommentsDelegate(QStyledItemDelegate): + def __init__(self, parent): + ''' + Delegate for comments data. + ''' + QStyledItemDelegate.__init__(self, parent) + self.parent = parent + + def createEditor(self, parent, option, index): + m = index.model() + col = m.column_map[index.column()] + # db col is not named for the field, but for the table number. To get it, + # gui column -> column label -> table number -> db column + text = m.db.data[index.row()][m.db.FIELD_MAP[m.custom_columns[col]['num']]] + editor = CommentsDialog(parent, text) + d = editor.exec_() + if d: + m.setData(index, QVariant(editor.textbox.toPlainText()), Qt.EditRole) + return None + + def setModelData(self, editor, model, index): + model.setData(index, QVariant(editor.textbox.text()), Qt.EditRole) + +class CcBoolDelegate(QStyledItemDelegate): + def __init__(self, parent): + ''' + Delegate for custom_column bool data. + ''' + QStyledItemDelegate.__init__(self, parent) + + def createEditor(self, parent, option, index): + m = index.model() + col = m.column_map[index.column()] + editor = QCheckBox(parent) + val = m.db.data[index.row()][m.db.FIELD_MAP[m.custom_columns[col]['num']]] + if tweaks['bool_custom_columns_are_tristate'] == 'no': + pass + else: + if tweaks['bool_custom_columns_are_tristate'] == 'yes': + editor.setTristate(True) + return editor + + def setModelData(self, editor, model, index): + model.setData(index, QVariant(editor.checkState()), Qt.EditRole) + + def setEditorData(self, editor, index): + m = index.model() + # db col is not named for the field, but for the table number. To get it, + # gui column -> column label -> table number -> db column + val = m.db.data[index.row()][m.db.FIELD_MAP[m.custom_columns[m.column_map[index.column()]]['num']]] + if tweaks['bool_custom_columns_are_tristate'] == 'no': + val = Qt.Unchecked if val is None or not val else Qt.Checked + else: + val = Qt.PartiallyChecked if val is None else Qt.Unchecked if not val else Qt.Checked + editor.setCheckState(val) + class BooksModel(QAbstractTableModel): about_to_be_sorted = pyqtSignal(object, name='aboutToBeSorted') sorting_done = pyqtSignal(object, name='sortingDone') - headers = { + orig_headers = { 'title' : _("Title"), 'authors' : _("Author(s)"), 'size' : _("Size (MB)"), @@ -182,15 +267,22 @@ class BooksModel(QAbstractTableModel): def __init__(self, parent=None, buffer=40): QAbstractTableModel.__init__(self, parent) self.db = None - self.column_map = config['column_map'] self.editable_cols = ['title', 'authors', 'rating', 'publisher', 'tags', 'series', 'timestamp', 'pubdate'] self.default_image = QImage(I('book.svg')) self.sorted_on = ('timestamp', Qt.AscendingOrder) + self.sort_history = [self.sorted_on] self.last_search = '' # The last search performed on this model - self.read_config() + self.column_map = [] + self.headers = {} self.buffer_size = buffer self.cover_cache = None + self.bool_yes_icon = QIcon(I('ok.svg')) + self.bool_no_icon = QIcon(I('list_remove.svg')) + self.bool_blank_icon = QIcon(I('blank.svg')) + + def is_custom_column(self, cc_label): + return cc_label in self.custom_columns def clear_caches(self): if self.cover_cache: @@ -198,15 +290,24 @@ class BooksModel(QAbstractTableModel): def read_config(self): self.use_roman_numbers = config['use_roman_numerals_for_series_number'] - cols = config['column_map'] - if cols != self.column_map: - self.column_map = cols - self.reset() - self.emit(SIGNAL('columns_sorted()')) + self.column_map = config['column_map'][:] # force a copy + self.headers = {} + for i in self.column_map: # take out any columns no longer in the db + if not i in self.orig_headers and not i in self.custom_columns: + self.column_map.remove(i) + for i in self.column_map: + if i in self.orig_headers: + self.headers[i] = self.orig_headers[i] + elif i in self.custom_columns: + self.headers[i] = self.custom_columns[i]['name'] + self.reset() + self.emit(SIGNAL('columns_sorted()')) def set_database(self, db): self.db = db + self.custom_columns = self.db.custom_column_label_map self.build_data_convertors() + self.read_config() def refresh_ids(self, ids, current_row=-1): rows = self.db.refresh_ids(ids) @@ -313,6 +414,8 @@ class BooksModel(QAbstractTableModel): self.clear_caches() self.reset() self.sorted_on = (self.column_map[col], order) + self.sort_history.insert(0, self.sorted_on) + del self.sort_history[3:] # clean up older searches self.sorting_done.emit(self.db.index) def refresh(self, reset=True): @@ -320,10 +423,8 @@ class BooksModel(QAbstractTableModel): col = self.column_map.index(self.sorted_on[0]) except: col = 0 - self.db.refresh(field=self.column_map[col], - ascending=self.sorted_on[1]==Qt.AscendingOrder) - if reset: - self.reset() + self.db.refresh(field=None) + self.sort(col, self.sorted_on[1], reset=reset) def resort(self, reset=True): try: @@ -427,6 +528,7 @@ class BooksModel(QAbstractTableModel): return ans def get_metadata(self, rows, rows_are_ids=False, full_metadata=False): + # Should this add the custom columns? It doesn't at the moment metadata, _full_metadata = [], [] if not rows_are_ids: rows = [self.db.id(row.row()) for row in rows] @@ -559,75 +661,108 @@ class BooksModel(QAbstractTableModel): return img def build_data_convertors(self): - - tidx = self.db.FIELD_MAP['title'] - aidx = self.db.FIELD_MAP['authors'] - sidx = self.db.FIELD_MAP['size'] - ridx = self.db.FIELD_MAP['rating'] - pidx = self.db.FIELD_MAP['publisher'] - tmdx = self.db.FIELD_MAP['timestamp'] - pddx = self.db.FIELD_MAP['pubdate'] - srdx = self.db.FIELD_MAP['series'] - tgdx = self.db.FIELD_MAP['tags'] - siix = self.db.FIELD_MAP['series_index'] - - def authors(r): - au = self.db.data[r][aidx] + def authors(r, idx=-1): + au = self.db.data[r][idx] if au: au = [a.strip().replace('|', ',') for a in au.split(',')] - return ' & '.join(au) + return QVariant(' & '.join(au)) + else: + return None - def timestamp(r): - dt = self.db.data[r][tmdx] - if dt: - return QDate(dt.year, dt.month, dt.day) - - def pubdate(r): - dt = self.db.data[r][pddx] - if dt: - return QDate(dt.year, dt.month, dt.day) - - def rating(r): - r = self.db.data[r][ridx] - r = r/2 if r else 0 - return r - - def publisher(r): - pub = self.db.data[r][pidx] - if pub: - return pub - - def tags(r): - tags = self.db.data[r][tgdx] + def tags(r, idx=-1): + tags = self.db.data[r][idx] if tags: - return ', '.join(sorted(tags.split(','))) + return QVariant(', '.join(sorted(tags.split(',')))) + return None - def series(r): - series = self.db.data[r][srdx] + def series(r, idx=-1, siix=-1): + series = self.db.data[r][idx] if series: idx = fmt_sidx(self.db.data[r][siix]) - return series + ' [%s]'%idx - def size(r): - size = self.db.data[r][sidx] + return QVariant(series + ' [%s]'%idx) + return None + + def size(r, idx=-1): + size = self.db.data[r][idx] if size: - return '%.1f'%(float(size)/(1024*1024)) + return QVariant('%.1f'%(float(size)/(1024*1024))) + return None + + def rating_type(r, idx=-1): + r = self.db.data[r][idx] + r = r/2 if r else 0 + return QVariant(r) + + def datetime_type(r, idx=-1): + val = self.db.data[r][idx] + if val is not None: + return QVariant(QDate(val)) + else: + return QVariant(QDate()) + + def bool_type(r, idx=-1): + return None # displayed using a decorator + + def bool_type_decorator(r, idx=-1): + val = self.db.data[r][idx] + if tweaks['bool_custom_columns_are_tristate'] == 'no': + if val is None or not val: + return self.bool_no_icon + if val: + return self.bool_yes_icon + if val is None: + return self.bool_blank_icon + return self.bool_no_icon + + def text_type(r, mult=False, idx=-1): + text = self.db.data[r][idx] + if text and mult: + return QVariant(', '.join(sorted(text.split('|')))) + return QVariant(text) + + def number_type(r, idx=-1): + return QVariant(self.db.data[r][idx]) self.dc = { - 'title' : lambda r : self.db.data[r][tidx], - 'authors' : authors, - 'size' : size, - 'timestamp': timestamp, - 'pubdate' : pubdate, - 'rating' : rating, - 'publisher': publisher, - 'tags' : tags, - 'series' : series, + 'title' : functools.partial(text_type, idx=self.db.FIELD_MAP['title'], mult=False), + 'authors' : functools.partial(authors, idx=self.db.FIELD_MAP['authors']), + 'size' : functools.partial(size, idx=self.db.FIELD_MAP['size']), + 'timestamp': functools.partial(datetime_type, idx=self.db.FIELD_MAP['timestamp']), + 'pubdate' : functools.partial(datetime_type, idx=self.db.FIELD_MAP['pubdate']), + 'rating' : functools.partial(rating_type, idx=self.db.FIELD_MAP['rating']), + 'publisher': functools.partial(text_type, idx=self.db.FIELD_MAP['title'], mult=False), + 'tags' : functools.partial(tags, idx=self.db.FIELD_MAP['tags']), + 'series' : functools.partial(series, idx=self.db.FIELD_MAP['series'], siix=self.db.FIELD_MAP['series_index']), } + self.dc_decorator = {} + + # Add the custom columns to the data converters + for col in self.custom_columns: + idx = self.db.FIELD_MAP[self.custom_columns[col]['num']] + datatype = self.custom_columns[col]['datatype'] + if datatype in ('text', 'comments'): + self.dc[col] = functools.partial(text_type, idx=idx, mult=self.custom_columns[col]['is_multiple']) + elif datatype in ('int', 'float'): + self.dc[col] = functools.partial(number_type, idx=idx) + elif datatype == 'datetime': + self.dc[col] = functools.partial(datetime_type, idx=idx) + elif datatype == 'bool': + self.dc[col] = functools.partial(bool_type, idx=idx) + self.dc_decorator[col] = functools.partial(bool_type_decorator, idx=idx) + elif datatype == 'rating': + self.dc[col] = functools.partial(rating_type, idx=idx) + else: + print 'What type is this?', col, datatype def data(self, index, role): if role in (Qt.DisplayRole, Qt.EditRole): - ans = self.dc[self.column_map[index.column()]](index.row()) - return NONE if ans is None else QVariant(ans) + return self.dc[self.column_map[index.column()]](index.row()) + elif role == Qt.DecorationRole: + if self.column_map[index.column()] in self.dc_decorator: + return self.dc_decorator[self.column_map[index.column()]](index.row()) + return None + #elif role == Qt.SizeHintRole: + # return QVariant(Qt.SizeHint(1, 23)) #elif role == Qt.TextAlignmentRole and self.column_map[index.column()] in ('size', 'timestamp'): # return QVariant(Qt.AlignVCenter | Qt.AlignCenter) #elif role == Qt.ToolTipRole and index.isValid(): @@ -636,6 +771,8 @@ class BooksModel(QAbstractTableModel): return NONE def headerData(self, section, orientation, role): + if role == Qt.ToolTipRole: + return QVariant(_('The lookup/search name is "{0}"').format(self.column_map[section])) if role != Qt.DisplayRole: return NONE if orientation == Qt.Horizontal: @@ -646,55 +783,89 @@ class BooksModel(QAbstractTableModel): def flags(self, index): flags = QAbstractTableModel.flags(self, index) if index.isValid(): - if self.column_map[index.column()] in self.editable_cols: + colhead = self.column_map[index.column()] + if colhead in self.editable_cols: flags |= Qt.ItemIsEditable + elif self.is_custom_column(colhead): + if self.custom_columns[colhead]['editable']: + flags |= Qt.ItemIsEditable return flags + def set_custom_column_data(self, row, colhead, value): + typ = self.custom_columns[colhead]['datatype'] + if typ in ('text', 'comments'): + val = qstring_to_unicode(value.toString()).strip() + val = val if val else None + if typ == 'bool': + val = value.toInt()[0] # tristate checkboxes put unknown in the middle + val = None if val == 1 else False if val == 0 else True + elif typ == 'rating': + val = value.toInt()[0] + val = 0 if val < 0 else 5 if val > 5 else val + val *= 2 + elif typ in ('int', 'float'): + val = qstring_to_unicode(value.toString()).strip() + if val is None or not val: + val = None + elif typ == 'datetime': + val = value.toDate() + if val.isNull() or not val.isValid(): + return False + val = qt_to_dt(val, as_utc=False) + self.db.set_custom(self.db.id(row), val, label=colhead, num=None, append=False, notify=True) + return True + def setData(self, index, value, role): if role == Qt.EditRole: row, col = index.row(), index.column() column = self.column_map[col] - if column not in self.editable_cols: - return False - val = int(value.toInt()[0]) if column == 'rating' else \ - value.toDate() if column in ('timestamp', 'pubdate') else \ - unicode(value.toString()) - id = self.db.id(row) - if column == 'rating': - val = 0 if val < 0 else 5 if val > 5 else val - val *= 2 - self.db.set_rating(id, val) - elif column == 'series': - val = val.strip() - pat = re.compile(r'\[([.0-9]+)\]') - match = pat.search(val) - if match is not None: - self.db.set_series_index(id, float(match.group(1))) - val = pat.sub('', val).strip() - elif val: - if tweaks['series_index_auto_increment'] == 'next': - ni = self.db.get_next_series_num_for(val) - if ni != 1: - self.db.set_series_index(id, ni) - if val: - self.db.set_series(id, val) - elif column == 'timestamp': - if val.isNull() or not val.isValid(): + if self.is_custom_column(column): + if not self.set_custom_column_data(row, column, value): return False - self.db.set_timestamp(id, qt_to_dt(val, as_utc=False)) - elif column == 'pubdate': - if val.isNull() or not val.isValid(): - return False - self.db.set_pubdate(id, qt_to_dt(val, as_utc=False)) else: - self.db.set(row, column, val) + if column not in self.editable_cols: + return False + val = int(value.toInt()[0]) if column == 'rating' else \ + value.toDate() if column in ('timestamp', 'pubdate') else \ + unicode(value.toString()) + id = self.db.id(row) + if column == 'rating': + val = 0 if val < 0 else 5 if val > 5 else val + val *= 2 + self.db.set_rating(id, val) + elif column == 'series': + val = val.strip() + pat = re.compile(r'\[([.0-9]+)\]') + match = pat.search(val) + if match is not None: + self.db.set_series_index(id, float(match.group(1))) + val = pat.sub('', val).strip() + elif val: + if tweaks['series_index_auto_increment'] == 'next': + ni = self.db.get_next_series_num_for(val) + if ni != 1: + self.db.set_series_index(id, ni) + if val: + self.db.set_series(id, val) + elif column == 'timestamp': + if val.isNull() or not val.isValid(): + return False + self.db.set_timestamp(id, qt_to_dt(val, as_utc=False)) + elif column == 'pubdate': + if val.isNull() or not val.isValid(): + return False + self.db.set_pubdate(id, qt_to_dt(val, as_utc=False)) + else: + self.db.set(row, column, val) self.emit(SIGNAL("dataChanged(QModelIndex, QModelIndex)"), \ index, index) if column == self.sorted_on[0]: self.resort() - return True + def set_search_restriction(self, s): + self.db.data.set_search_restriction(s) + class BooksView(TableView): TIME_FMT = '%d %b %Y' wrapper = textwrap.TextWrapper(width=20) @@ -711,13 +882,16 @@ class BooksView(TableView): def __init__(self, parent, modelcls=BooksModel): TableView.__init__(self, parent) - self.rating_delegate = LibraryDelegate(self) + self.rating_delegate = RatingDelegate(self) self.timestamp_delegate = DateDelegate(self) self.pubdate_delegate = PubDateDelegate(self) self.tags_delegate = TagsDelegate(self) self.authors_delegate = TextDelegate(self) self.series_delegate = TextDelegate(self) self.publisher_delegate = TextDelegate(self) + self.cc_text_delegate = CcTextDelegate(self) + self.cc_bool_delegate = CcBoolDelegate(self) + self.cc_comments_delegate = CcCommentsDelegate(self) self.display_parent = parent self._model = modelcls(self) self.setModel(self._model) @@ -772,6 +946,25 @@ class BooksView(TableView): self.setItemDelegateForColumn(cm.index('publisher'), self.publisher_delegate) if 'series' in cm: self.setItemDelegateForColumn(cm.index('series'), self.series_delegate) + for colhead in cm: + if not self._model.is_custom_column(colhead): + continue + cc = self._model.custom_columns[colhead] + if cc['datatype'] == 'datetime': + self.setItemDelegateForColumn(cm.index(colhead), self.timestamp_delegate) + elif cc['datatype'] == 'comments': + self.setItemDelegateForColumn(cm.index(colhead), self.cc_comments_delegate) + elif cc['datatype'] == 'text': + if cc['is_multiple']: + self.setItemDelegateForColumn(cm.index(colhead), self.tags_delegate) + else: + self.setItemDelegateForColumn(cm.index(colhead), self.cc_text_delegate) + elif cc['datatype'] in ('int', 'float'): + self.setItemDelegateForColumn(cm.index(colhead), self.cc_text_delegate) + elif cc['datatype'] == 'bool': + self.setItemDelegateForColumn(cm.index(colhead), self.cc_bool_delegate) + elif cc['datatype'] == 'rating': + self.setItemDelegateForColumn(cm.index(colhead), self.rating_delegate) def set_context_menu(self, edit_metadata, send_to_device, convert, view, save, open_folder, book_details, delete, similar_menu=None): @@ -798,6 +991,16 @@ class BooksView(TableView): self.context_menu.popup(event.globalPos()) event.accept() + def restore_sort_at_startup(self, saved_history): + if tweaks['sort_columns_at_startup'] is not None: + saved_history = tweaks['sort_columns_at_startup'] + + if saved_history is None: + return + for col,order in reversed(saved_history): + self.sortByColumn(col, order) + self.model().sort_history = saved_history + def sortByColumn(self, colname, order): try: idx = self._model.column_map.index(colname) @@ -833,7 +1036,6 @@ class BooksView(TableView): event.accept() self.emit(SIGNAL('files_dropped(PyQt_PyObject)'), paths) - def set_database(self, db): self._model.set_database(db) self.tags_delegate.set_database(db) @@ -854,6 +1056,10 @@ class BooksView(TableView): self.connect(self._model, SIGNAL('searched(PyQt_PyObject)'), self.search_done) + def connect_to_restriction_set(self, tv): + QObject.connect(tv, SIGNAL('restriction_set(PyQt_PyObject)'), + self._model.set_search_restriction) + def connect_to_book_display(self, bd): QObject.connect(self._model, SIGNAL('new_bookdisplay_data(PyQt_PyObject)'), bd) @@ -949,7 +1155,6 @@ class OnDeviceSearch(SearchQueryParser): matches.add(index) break except ValueError: # Unicode errors - import traceback traceback.print_exc() return matches @@ -1187,5 +1392,6 @@ class DeviceBooksModel(BooksModel): def set_editable(self, editable): self.editable = editable - + def set_search_restriction(self, s): + pass diff --git a/src/calibre/gui2/main.ui b/src/calibre/gui2/main.ui index 6913ab0cfe..b3ed89af93 100644 --- a/src/calibre/gui2/main.ui +++ b/src/calibre/gui2/main.ui @@ -306,6 +306,12 @@ + + + 256 + 16777215 + + true @@ -328,21 +334,59 @@ - - - 0 - + - - Match any - + + + 0 + + + + Match any + + + + + Match all + + + - - Match all - + + + Manage tag categories + + + Create, edit, and delete tag categories + + - + +
+ + + + + + Restrict display to: + + + + + + + + 50 + 0 + + + + Books display will be restricted to those matching the selected saved search + + + + diff --git a/src/calibre/gui2/search_box.py b/src/calibre/gui2/search_box.py index 033b43954a..b69fde5b93 100644 --- a/src/calibre/gui2/search_box.py +++ b/src/calibre/gui2/search_box.py @@ -10,6 +10,7 @@ from PyQt4.Qt import QComboBox, SIGNAL, Qt, QLineEdit, QStringList, pyqtSlot from PyQt4.QtGui import QCompleter from calibre.gui2 import config +from calibre.gui2.dialogs.confirm_delete import confirm class SearchLineEdit(QLineEdit): @@ -278,6 +279,10 @@ class SavedSearchBox(QComboBox): # SIGNALed from the main UI def delete_search_button_clicked(self): #print 'in delete_search_button_clicked' + if not confirm('

'+_('The selected search will be ' + 'permanently deleted. Are you sure?') + +'

', 'saved_search_delete', self): + return idx = self.currentIndex if idx < 0: return diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py index d9225b2503..7b24d9d2e0 100644 --- a/src/calibre/gui2/tag_view.py +++ b/src/calibre/gui2/tag_view.py @@ -8,11 +8,13 @@ Browsing book collection by tags. ''' from itertools import izip +from copy import copy from PyQt4.Qt import Qt, QTreeView, QApplication, pyqtSignal, \ QFont, SIGNAL, QSize, QIcon, QPoint, \ QAbstractItemModel, QVariant, QModelIndex from calibre.gui2 import config, NONE +from calibre.utils.config import prefs from calibre.utils.search_query_parser import saved_searches from calibre.library.database2 import Tag @@ -27,16 +29,24 @@ class TagsView(QTreeView): self.setIconSize(QSize(30, 30)) self.tag_match = None - def set_database(self, db, tag_match, popularity): + def set_database(self, db, tag_match, popularity, restriction): self._model = TagsModel(db, parent=self) self.popularity = popularity + self.restriction = restriction self.tag_match = tag_match + self.db = db self.setModel(self._model) self.connect(self, SIGNAL('clicked(QModelIndex)'), self.toggle) self.popularity.setChecked(config['sort_by_popularity']) self.connect(self.popularity, SIGNAL('stateChanged(int)'), self.sort_changed) + self.connect(self.restriction, SIGNAL('activated(const QString&)'), self.search_restriction_set) self.need_refresh.connect(self.recount, type=Qt.QueuedConnection) db.add_listener(self.database_changed) + self.saved_searches_changed(recount=False) + + def create_tag_category(self, name, tag_list): + self._model.create_tag_category(name, tag_list) + self.recount() def database_changed(self, event, ids): self.need_refresh.emit() @@ -48,6 +58,19 @@ class TagsView(QTreeView): def sort_changed(self, state): config.set('sort_by_popularity', state == Qt.Checked) self.model().refresh() + # self.search_restriction_set() + + def search_restriction_set(self, s): + self.clear() + if len(s) == 0: + self.search_restriction = '' + else: + self.search_restriction = unicode(s) + self.model().set_search_restriction(self.search_restriction) + self.recount() + self.emit(SIGNAL('restriction_set(PyQt_PyObject)'), self.search_restriction) + self.emit(SIGNAL('tags_marked(PyQt_PyObject, PyQt_PyObject)'), + self._model.tokens(), self.match_all) def toggle(self, index): modifiers = int(QApplication.keyboardModifiers()) @@ -59,6 +82,20 @@ class TagsView(QTreeView): def clear(self): self.model().clear_state() + def saved_searches_changed(self, recount=True): + p = prefs['saved_searches'].keys() + p.sort() + t = self.restriction.currentText() + self.restriction.clear() # rebuild the restrictions combobox using current saved searches + self.restriction.addItem('') + for s in p: + self.restriction.addItem(s) + if t in p: # redo the current restriction, if there was one + self.restriction.setCurrentIndex(self.restriction.findText(t)) + self.search_restriction_set(t) + if recount: + self.recount() + def recount(self, *args): ci = self.currentIndex() if not ci.isValid(): @@ -74,6 +111,15 @@ class TagsView(QTreeView): self.setCurrentIndex(idx) self.scrollTo(idx, QTreeView.PositionAtCenter) + ''' + If the number of user categories changed, or if custom columns have come or gone, + we must rebuild the model. Reason: it is much easier to do that than to reconstruct + the browser tree. + ''' + def set_new_model(self): + self._model = TagsModel(self.db, parent=self) + self.setModel(self._model) + class TagTreeItem(object): CATEGORY = 0 @@ -148,28 +194,90 @@ class TagTreeItem(object): class TagsModel(QAbstractItemModel): - categories = [_('Authors'), _('Series'), _('Formats'), _('Publishers'), _('News'), _('Tags'), _('Searches')] - row_map = ['author', 'series', 'format', 'publisher', 'news', 'tag', 'search'] + categories_orig = [_('Authors'), _('Series'), _('Formats'), _('Publishers'), _('News'), _('All tags')] + row_map_orig = ['author', 'series', 'format', 'publisher', 'news', 'tag'] + fixed_categories= 5 + search_keys=['search', _('Searches')] def __init__(self, db, parent=None): QAbstractItemModel.__init__(self, parent) - self.cmap = tuple(map(QIcon, [I('user_profile.svg'), + self.cmap_orig = list(map(QIcon, [I('user_profile.svg'), I('series.svg'), I('book.svg'), I('publisher.png'), - I('news.svg'), I('tags.svg'), I('search.svg')])) + I('news.svg')])) self.icon_map = [QIcon(), QIcon(I('plus.svg')), QIcon(I('minus.svg'))] self.db = db + self.search_restriction = '' + self.user_categories = {} self.ignore_next_search = 0 + data = self.get_node_tree(config['sort_by_popularity']) self.root_item = TagTreeItem() - data = self.db.get_categories(config['sort_by_popularity']) - data['search'] = self.get_search_nodes() - for i, r in enumerate(self.row_map): c = TagTreeItem(parent=self.root_item, data=self.categories[i], category_icon=self.cmap[i]) for tag in data[r]: TagTreeItem(parent=c, data=tag, icon_map=self.icon_map) + def set_search_restriction(self, s): + self.search_restriction = s + + def get_node_tree(self, sort): + self.row_map = [] + self.categories = [] + self.cmap = self.cmap_orig[:] + self.user_categories = dict.copy(config['tag_categories']) + column_map = config['column_map'] + + for i in range(0, self.fixed_categories): # First the standard categories + self.row_map.append(self.row_map_orig[i]) + self.categories.append(self.categories_orig[i]) + if len(self.search_restriction): + data = self.db.get_categories(sort_on_count=sort, + ids=self.db.search(self.search_restriction, return_matches=True)) + else: + data = self.db.get_categories(sort_on_count=sort) + + for i in data: # now the custom columns + if i not in self.row_map_orig and i in column_map: + self.row_map.append(i) + self.categories.append(self.db.custom_column_label_map[i]['name']) + self.cmap.append(QIcon(I('column.svg'))) + + for i in self.row_map_orig: + if i not in self.user_categories: + self.user_categories[i] = {} + config['tag_categories'] = self.user_categories + + taglist = {} # Now the user-defined categories + for i in data: + taglist[i] = dict(map(lambda t:(t.name if i != 'author' else t.name.replace('|', ','), t), data[i])) + for k in self.row_map_orig: + if k not in self.user_categories: + continue + for i in sorted(self.user_categories[k].keys()): # now the tag categories + l = [] + for t in self.user_categories[k][i]: + if t in taglist[k]: # use same tag node as the complete category + l.append(taglist[k][t]) + # else: eliminate nodes that have zero counts + data[i+'*'] = l + self.row_map.append(i+'*') + self.categories.append(i) + if k == 'tag': # choose the icon + self.cmap.append(QIcon(I('tags.svg'))) + else: + self.cmap.append(QIcon(self.cmap[self.row_map_orig.index(k)])) + + # Now the rest of the normal tag categories + for i in range(self.fixed_categories, len(self.row_map_orig)): + self.row_map.append(self.row_map_orig[i]) + self.categories.append(self.categories_orig[i]) + self.cmap.append(QIcon(I('tags.svg'))) + data['search'] = self.get_search_nodes() # Add the search category + self.row_map.append(self.search_keys[0]) + self.categories.append(self.search_keys[1]) + self.cmap.append(QIcon(I('search.svg'))) + return data def get_search_nodes(self): l = [] @@ -178,8 +286,7 @@ class TagsModel(QAbstractItemModel): return l def refresh(self): - data = self.db.get_categories(config['sort_by_popularity']) - data['search'] = self.get_search_nodes() + data = self.get_node_tree(config['sort_by_popularity']) # get category data for i, r in enumerate(self.row_map): category = self.root_item.children[i] names = [t.tag.name for t in category.children] @@ -194,8 +301,6 @@ class TagsModel(QAbstractItemModel): if len(data[r]) > 0: self.beginInsertRows(category_index, 0, len(data[r])-1) for tag in data[r]: - if r == 'author': - tag.name = tag.name.replace('|', ',') tag.state = state_map.get(tag.name, 0) t = TagTreeItem(parent=category, data=tag, icon_map=self.icon_map) self.endInsertRows() @@ -273,16 +378,20 @@ class TagsModel(QAbstractItemModel): return len(parent_item.children) def reset_all_states(self, except_=None): + update_list = [] for i in xrange(self.rowCount(QModelIndex())): category_index = self.index(i, 0, QModelIndex()) for j in xrange(self.rowCount(category_index)): tag_index = self.index(j, 0, category_index) tag_item = tag_index.internalPointer() - if tag_item is except_: - continue tag = tag_item.tag - if tag.state != 0: + if tag is except_: + self.emit(SIGNAL('dataChanged(QModelIndex,QModelIndex)'), + tag_index, tag_index) + continue + if tag.state != 0 or tag in update_list: tag.state = 0 + update_list.append(tag) self.emit(SIGNAL('dataChanged(QModelIndex,QModelIndex)'), tag_index, tag_index) @@ -299,9 +408,9 @@ class TagsModel(QAbstractItemModel): if not index.isValid(): return False item = index.internalPointer() if item.type == TagTreeItem.TAG: - if exclusive: - self.reset_all_states(except_=item) item.toggle() + if exclusive: + self.reset_all_states(except_=item.tag) self.ignore_next_search = 2 self.emit(SIGNAL('dataChanged(QModelIndex,QModelIndex)'), index, index) return True @@ -309,14 +418,19 @@ class TagsModel(QAbstractItemModel): def tokens(self): ans = [] + tags_seen = [] for i, key in enumerate(self.row_map): category_item = self.root_item.children[i] for tag_item in category_item.children: tag = tag_item.tag - category = key if key != 'news' else 'tag' if tag.state > 0: prefix = ' not ' if tag.state == 2 else '' + category = key if not key.endswith('*') and \ + key not in ['news', 'specialtags', 'normaltags'] \ + else 'tag' + if category == 'tag': + if tag.name in tags_seen: + continue + tags_seen.append(tag.name) ans.append('%s%s:"=%s"'%(prefix, category, tag.name)) - return ans - - + return ans \ No newline at end of file diff --git a/src/calibre/gui2/ui.py b/src/calibre/gui2/ui.py index 54a7a26d5e..d0ffad610c 100644 --- a/src/calibre/gui2/ui.py +++ b/src/calibre/gui2/ui.py @@ -60,6 +60,9 @@ from calibre.ebooks.BeautifulSoup import BeautifulSoup, Tag, NavigableString from calibre.library.database2 import LibraryDatabase2 from calibre.library.caches import CoverCache from calibre.gui2.dialogs.confirm_delete import confirm +from calibre.gui2.dialogs.tag_categories import TagCategories + +from datetime import datetime class SaveMenu(QMenu): @@ -126,8 +129,10 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI): pixmap_to_data(pixmap)) def __init__(self, listener, opts, actions, parent=None): + self.last_time = datetime.now() self.preferences_action, self.quit_action = actions self.spare_servers = [] + self.must_restart_before_config = False MainWindow.__init__(self, opts, parent) # Initialize fontconfig in a separate thread as this can be a lengthy # process if run for the first time on this machine @@ -143,6 +148,9 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI): self.setupUi(self) self.setWindowTitle(__appname__) + self.restriction_count_of_books_in_view = 0 + self.restriction_count_of_books_in_library = 0 + self.restriction_in_effect = False self.search.initialize('main_search_history', colorize=True, help_text=_('Search (For Advanced Search click the button to the left)')) self.connect(self.clear_button, SIGNAL('clicked()'), self.search_clear) @@ -320,7 +328,6 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI): QObject.connect(md.actions()[7], SIGNAL('triggered(bool)'), self.__em6__) - self.save_menu = QMenu() self.save_menu.addAction(_('Save to disk')) self.save_menu.addAction(_('Save to disk in a single directory')) @@ -475,6 +482,8 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI): self.search_done)), ('connect_to_book_display', (self.status_bar.book_info.show_data,)), + ('connect_to_restriction_set', + (self.tags_view,)), ]: for view in (self.library_view, self.memory_view, self.card_a_view, self.card_b_view): getattr(view, func)(*args) @@ -514,8 +523,7 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI): db = LibraryDatabase2(self.library_path) self.library_view.set_database(db) prefs['library_path'] = self.library_path - self.library_view.sortByColumn(*dynamic.get('sort_column', - ('timestamp', Qt.DescendingOrder))) + self.library_view.restore_sort_at_startup(dynamic.get('sort_history', [('timestamp', Qt.DescendingOrder)])) if not self.library_view.restore_column_widths(): self.library_view.resizeColumnsToContents() self.search.setFocus(Qt.OtherFocusReason) @@ -525,10 +533,20 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI): self.tags_view.setVisible(False) self.tag_match.setVisible(False) self.popularity.setVisible(False) - self.tags_view.set_database(db, self.tag_match, self.popularity) + self.restriction_label.setVisible(False) + self.edit_categories.setVisible(False) + self.search_restriction.setVisible(False) + self.connect(self.edit_categories, SIGNAL('clicked()'), self.do_edit_categories) + self.tags_view.set_database(db, self.tag_match, self.popularity, self.search_restriction) self.connect(self.tags_view, SIGNAL('tags_marked(PyQt_PyObject, PyQt_PyObject)'), self.search.search_from_tags) + self.connect(self.tags_view, + SIGNAL('restriction_set(PyQt_PyObject)'), + self.saved_search.clear_to_help) + self.connect(self.tags_view, + SIGNAL('restriction_set(PyQt_PyObject)'), + self.mark_restriction_set) self.connect(self.tags_view, SIGNAL('tags_marked(PyQt_PyObject, PyQt_PyObject)'), self.saved_search.clear_to_help) @@ -541,8 +559,10 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI): SIGNAL('count_changed(int)'), self.location_view.count_changed) self.connect(self.library_view.model(), SIGNAL('count_changed(int)'), self.tags_view.recount, Qt.QueuedConnection) - self.connect(self.search, SIGNAL('cleared()'), self.tags_view_clear) - self.connect(self.saved_search, SIGNAL('changed()'), self.tags_view.recount, Qt.QueuedConnection) + self.connect(self.library_view.model(), SIGNAL('count_changed(int)'), + self.restriction_count_changed, Qt.QueuedConnection) + self.connect(self.search, SIGNAL('cleared()'), self.search_box_cleared) + self.connect(self.saved_search, SIGNAL('changed()'), self.tags_view.saved_searches_changed, Qt.QueuedConnection) if not gprefs.get('quick_start_guide_added', False): from calibre.ebooks.metadata import MetaInformation mi = MetaInformation(_('Calibre Quick Start Guide'), ['John Schember']) @@ -592,7 +612,6 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI): self.resize(self.width(), self._calculated_available_height) self.search.setMaximumWidth(self.width()-150) - if config['autolaunch_server']: from calibre.library.server import start_threaded_server from calibre.library import server_config @@ -632,6 +651,12 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI): height = v.rowHeight(0) self.library_view.verticalHeader().setDefaultSectionSize(height) + def do_edit_categories(self): + d = TagCategories(self, self.library_view.model().db) + d.exec_() + if d.result() == d.Accepted: + self.tags_view.set_new_model() + self.tags_view.recount() def resizeEvent(self, ev): MainWindow.resizeEvent(self, ev) @@ -783,23 +808,68 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI): self.tags_view.setVisible(True) self.tag_match.setVisible(True) self.popularity.setVisible(True) + self.restriction_label.setVisible(True) + self.edit_categories.setVisible(True) + self.search_restriction.setVisible(True) self.tags_view.setFocus(Qt.OtherFocusReason) else: self.tags_view.setVisible(False) self.tag_match.setVisible(False) self.popularity.setVisible(False) + self.restriction_label.setVisible(False) + self.edit_categories.setVisible(False) + self.search_restriction.setVisible(False) - def tags_view_clear(self): - self.search_count.setText(_("(all books)")) + ''' + Handling of the count of books in a restricted view requires that + we capture the count after the initial restriction search. To so this, + we require that the restriction_set signal be issued before the search signal, + so that when the search_done happens and the count is displayed, + we can grab the count. This works because the search box is cleared + when a restriction is set, so that first search will find all books. + + Adding and deleting books creates another complexity. When added, they are + displayed regardless of whether they match the restriction. However, if they + do not, they are removed at the next search. The counts must take this + behavior into effect. + ''' + + def restriction_count_changed(self, c): + self.restriction_count_of_books_in_view += c - self.restriction_count_of_books_in_library + self.restriction_count_of_books_in_library = c + if self.restriction_in_effect: + self.set_number_of_books_shown(all='not used', compute_count=False) + + def mark_restriction_set(self, r): + self.restriction_in_effect = False if r is None or not r else True + + def set_number_of_books_shown(self, all, compute_count): + if self.restriction_in_effect: + if compute_count: + self.restriction_count_of_books_in_view = self.current_view().row_count() + t = _("({0} of {1})").format(self.current_view().row_count(), + self.restriction_count_of_books_in_view) + self.search_count.setStyleSheet('QLabel { background-color: yellow; }') + else: # No restriction + if all == 'yes': + t = _("(all books)") + else: + t = _("({0} of all)").format(self.current_view().row_count()) + self.search_count.setStyleSheet('QLabel { background-color: white; }') + self.search_count.setText(t) + + def search_box_cleared(self): + self.set_number_of_books_shown(all='yes', compute_count=True) self.tags_view.clear() + self.saved_search.clear_to_help() def search_clear(self): - self.search_count.setText(_("(all books)")) + self.set_number_of_books_shown(all='yes', compute_count=True) self.search.clear() def search_done(self, view, ok): if view is self.current_view(): - self.search_count.setText(_("(%d found)") % self.current_view().row_count()) + self.set_number_of_books_shown(all='no', compute_count=False) self.search.search_done(ok) def sync_cf_to_listview(self, current, previous): @@ -2028,7 +2098,12 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI): _('Cannot configure while there are running jobs.')) d.exec_() return - d = ConfigDialog(self, self.library_view.model().db, + if self.must_restart_before_config: + d = error_dialog(self, _('Cannot configure'), + _('Cannot configure before calibre is restarted.')) + d.exec_() + return + d = ConfigDialog(self, self.library_view.model(), server=self.content_server) d.exec_() self.content_server = d.server @@ -2043,15 +2118,17 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI): _('Save only %s format to disk')% prefs['output_format'].upper()) self.library_view.model().read_config() + self.library_view.model().refresh() + self.library_view.model().research() + self.tags_view.set_new_model() # in case columns changed + self.tags_view.recount() self.create_device_menu() - if not patheq(self.library_path, d.database_location): newloc = d.database_location move_library(self.library_path, newloc, self, self.library_moved) - def library_moved(self, newloc): if newloc is None: return db = LibraryDatabase2(newloc) @@ -2226,7 +2303,7 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI): def write_settings(self): config.set('main_window_geometry', self.saveGeometry()) - dynamic.set('sort_column', self.library_view.model().sorted_on) + dynamic.set('sort_history', self.library_view.model().sort_history) dynamic.set('tag_view_visible', self.tags_view.isVisible()) dynamic.set('cover_flow_visible', self.cover_flow.isVisible()) self.library_view.write_settings() diff --git a/src/calibre/library/caches.py b/src/calibre/library/caches.py index b18ada991e..1ce0843185 100644 --- a/src/calibre/library/caches.py +++ b/src/calibre/library/caches.py @@ -12,8 +12,9 @@ from itertools import repeat from PyQt4.QtCore import QThread, QReadWriteLock from PyQt4.QtGui import QImage -from calibre.utils.search_query_parser import SearchQueryParser +from calibre.utils.config import tweaks, prefs from calibre.utils.date import parse_date +from calibre.utils.search_query_parser import SearchQueryParser class CoverCache(QThread): @@ -146,6 +147,14 @@ class ResultCache(SearchQueryParser): ''' Stores sorted and filtered metadata in memory. ''' + def __init__(self, FIELD_MAP, cc_label_map): + self.FIELD_MAP = FIELD_MAP + self.custom_column_label_map = cc_label_map + self._map = self._map_filtered = self._data = [] + self.first_sort = True + self.search_restriction = '' + SearchQueryParser.__init__(self, [c for c in cc_label_map]) + self.build_relop_dict() def build_relop_dict(self): ''' @@ -194,13 +203,6 @@ class ResultCache(SearchQueryParser): self.search_relops = {'=':[1, relop_eq], '>':[1, relop_gt], '<':[1, relop_lt], \ '!=':[2, relop_ne], '>=':[2, relop_ge], '<=':[2, relop_le]} - def __init__(self, FIELD_MAP): - self.FIELD_MAP = FIELD_MAP - self._map = self._map_filtered = self._data = [] - self.first_sort = True - SearchQueryParser.__init__(self) - self.build_relop_dict() - def __getitem__(self, row): return self._data[self._map_filtered[row]] @@ -214,30 +216,45 @@ class ResultCache(SearchQueryParser): def universal_set(self): return set([i[0] for i in self._data if i is not None]) + def get_matches_dates(self, location, query): + matches = set([]) + if len(query) < 2: + return matches + relop = None + for k in self.search_relops.keys(): + if query.startswith(k): + (p, relop) = self.search_relops[k] + query = query[p:] + if relop is None: + (p, relop) = self.search_relops['='] + if location in self.custom_column_label_map: + loc = self.FIELD_MAP[self.custom_column_label_map[location]['num']] + else: + loc = self.FIELD_MAP[{'date':'timestamp', 'pubdate':'pubdate'}[location]] + try: + qd = parse_date(query) + except: + raise ParseException(query, len(query), 'Date conversion error', self) + if '-' in query: + field_count = query.count('-') + 1 + else: + field_count = query.count('/') + 1 + for item in self._data: + if item is None or item[loc] is None: continue + if relop(item[loc], qd, field_count): + matches.add(item[0]) + return matches + def get_matches(self, location, query): matches = set([]) if query and query.strip(): location = location.lower().strip() ### take care of dates special case - if location in ('pubdate', 'date'): - if len(query) < 2: - return matches - relop = None - for k in self.search_relops.keys(): - if query.startswith(k): - (p, relop) = self.search_relops[k] - query = query[p:] - if relop is None: - return matches - loc = self.FIELD_MAP[{'date':'timestamp', 'pubdate':'pubdate'}[location]] - qd = parse_date(query) - field_count = query.count('-') + 1 - for item in self._data: - if item is None: continue - if relop(item[loc], qd, field_count): - matches.add(item[0]) - return matches + if (location in ('pubdate', 'date')) or \ + ((location in self.custom_column_label_map) and \ + self.custom_column_label_map[location]['datatype'] == 'datetime'): + return self.get_matches_dates(location, query) ### everything else matchkind = CONTAINS_MATCH @@ -257,19 +274,38 @@ class ResultCache(SearchQueryParser): query = query.decode('utf-8') if location in ('tag', 'author', 'format', 'comment'): location += 's' + all = ('title', 'authors', 'publisher', 'tags', 'comments', 'series', 'formats', 'isbn', 'rating', 'cover') MAP = {} - for x in all: + + for x in all: # get the db columns for the standard searchables MAP[x] = self.FIELD_MAP[x] + IS_CUSTOM = [] + for x in range(len(self.FIELD_MAP)): # build a list containing '' the size of FIELD_MAP + IS_CUSTOM.append('') + for x in self.custom_column_label_map: # add custom columns to MAP. Put the column's type into IS_CUSTOM + if self.custom_column_label_map[x]['datatype'] != "datetime": + MAP[x] = self.FIELD_MAP[self.custom_column_label_map[x]['num']] + IS_CUSTOM[MAP[x]] = self.custom_column_label_map[x]['datatype'] + EXCLUDE_FIELDS = [MAP['rating'], MAP['cover']] SPLITABLE_FIELDS = [MAP['authors'], MAP['tags'], MAP['formats']] + for x in self.custom_column_label_map: + if self.custom_column_label_map[x]['is_multiple']: + SPLITABLE_FIELDS.append(MAP[x]) + location = [location] if location != 'all' else list(MAP.keys()) for i, loc in enumerate(location): location[i] = MAP[loc] + try: rating_query = int(query) * 2 except: rating_query = None + + # get the tweak here so that the string lookup and compare aren't in the loop + bools_are_tristate = tweaks['bool_custom_columns_are_tristate'] == 'yes' + for loc in location: if loc == MAP['authors']: q = query.replace(',', '|'); ### DB stores authors with commas changed to bars, so change query @@ -278,14 +314,34 @@ class ResultCache(SearchQueryParser): for item in self._data: if item is None: continue + + if IS_CUSTOM[loc] == 'bool': # complexity caused by the two-/three-value tweak + v = item[loc] + if not bools_are_tristate: + if v is None or not v: # item is None or set to false + if q in [_('no'), _('unchecked'), 'false']: + matches.add(item[0]) + else: # item is explicitly set to true + if q in [_('yes'), _('checked'), 'true']: + matches.add(item[0]) + else: + if v is None: + if q in [_('empty'), _('blank'), 'false']: + matches.add(item[0]) + elif not v: # is not None and false + if q in [_('no'), _('unchecked'), 'true']: + matches.add(item[0]) + else: # item is not None and true + if q in [_('yes'), _('checked'), 'true']: + matches.add(item[0]) + continue + if not item[loc]: - if query == 'false': - if isinstance(item[loc], basestring): - if item[loc].strip() != '': - continue + if q == 'false': matches.add(item[0]) - continue - continue ### item is empty. No possible matches below + continue # item is empty. No possible matches below + if q == 'false': # Field has something in it, so a false query does not match + continue if q == 'true': if isinstance(item[loc], basestring): @@ -293,12 +349,35 @@ class ResultCache(SearchQueryParser): continue matches.add(item[0]) continue - if rating_query and loc == MAP['rating'] and rating_query == int(item[loc]): - matches.add(item[0]) + + if rating_query: + if (loc == MAP['rating'] and rating_query == int(item[loc])): + matches.add(item[0]) continue + + if IS_CUSTOM[loc] == 'rating': + if rating_query and rating_query == int(item[loc]): + matches.add(item[0]) + continue + + try: # a conversion below might fail + if IS_CUSTOM[loc] == 'float': + if float(query) == item[loc]: # relationals not supported + matches.add(item[0]) + continue + if IS_CUSTOM[loc] == 'int': + if int(query) == item[loc]: + matches.add(item[0]) + continue + except: + continue ## A conversion threw an exception. Because of the type, no further match possible + if loc not in EXCLUDE_FIELDS: if loc in SPLITABLE_FIELDS: - vals = item[loc].split(',') ### check individual tags/authors/formats, not the long string + if IS_CUSTOM[loc]: + vals = item[loc].split('|') + else: + vals = item[loc].split(',') else: vals = [item[loc]] ### make into list to make _match happy if _match(q, vals, matchkind): @@ -342,8 +421,7 @@ class ResultCache(SearchQueryParser): ''' for id in ids: try: - self._data[id] = db.conn.get('SELECT * from meta2 WHERE id=?', - (id,))[0] + self._data[id] = db.conn.get('SELECT * from meta2 WHERE id=?', (id,))[0] self._data[id].append(db.has_cover(id, index_is_id=True)) except IndexError: return None @@ -399,6 +477,12 @@ class ResultCache(SearchQueryParser): asstr else cmp(self._data[x][loc], self._data[y][loc]) except AttributeError: # Some entries may be None ans = cmp(self._data[x][loc], self._data[y][loc]) + except TypeError: ## raised when a datetime is None + if self._data[x][loc] is None: + if self._data[y][loc] is None: + return 0 # Both None. Return eq + return 1 # x is None, y not. Return gt + return -1 # x is not None and (therefore) y is. return lt if subsort and ans == 0: return cmp(self._data[x][11].lower(), self._data[y][11].lower()) return ans @@ -410,21 +494,35 @@ class ResultCache(SearchQueryParser): if field == 'date': field = 'timestamp' elif field == 'title': field = 'sort' elif field == 'authors': field = 'author_sort' + as_string = field not in ('size', 'rating', 'timestamp') + if field in self.custom_column_label_map: + as_string = self.custom_column_label_map[field]['datatype'] in ('comments', 'text') + field = self.custom_column_label_map[field]['num'] + if self.first_sort: subsort = True self.first_sort = False fcmp = self.seriescmp if field == 'series' else \ functools.partial(self.cmp, self.FIELD_MAP[field], subsort=subsort, - asstr=field not in ('size', 'rating', 'timestamp')) - + asstr=as_string) self._map.sort(cmp=fcmp, reverse=not ascending) self._map_filtered = [id for id in self._map if id in self._map_filtered] - def search(self, query): + def search(self, query, return_matches = False): if not query or not query.strip(): + q = self.search_restriction + else: + q = '%s (%s)' % (self.search_restriction, query) + if not q: + if return_matches: + return list(self.map) # when return_matches, do not update the maps! self._map_filtered = list(self._map) - return - matches = sorted(self.parse(query)) + return [] + matches = sorted(self.parse(q)) + if return_matches: + return [id for id in self._map if id in matches] self._map_filtered = [id for id in self._map if id in matches] + return [] - + def set_search_restriction(self, s): + self.search_restriction = '' if not s else 'search:"%s"' % (s.strip()) \ No newline at end of file diff --git a/src/calibre/library/custom_columns.py b/src/calibre/library/custom_columns.py index 9258d5222a..6442db4a73 100644 --- a/src/calibre/library/custom_columns.py +++ b/src/calibre/library/custom_columns.py @@ -190,7 +190,7 @@ class CustomColumns(object): (label, num)) changed = True if is_editable is not None: - self.conn.execute('UPDATE custom_columns SET is_editable=? WHERE id=?', + self.conn.execute('UPDATE custom_columns SET editable=? WHERE id=?', (bool(is_editable), num)) self.custom_column_num_map[num]['is_editable'] = bool(is_editable) changed = True diff --git a/src/calibre/library/database2.py b/src/calibre/library/database2.py index 9ff1c14576..f704eb68a6 100644 --- a/src/calibre/library/database2.py +++ b/src/calibre/library/database2.py @@ -56,8 +56,6 @@ def delete_tree(path, permanent=False): copyfile = os.link if hasattr(os, 'link') else shutil.copyfile - - class Tag(object): def __init__(self, name, id=None, count=0, state=0, tooltip=None): @@ -186,7 +184,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): self.conn.executescript(script) self.conn.commit() - self.data = ResultCache(self.FIELD_MAP) + self.data = ResultCache(self.FIELD_MAP, self.custom_column_label_map) self.search = self.data.search self.refresh = functools.partial(self.data.refresh, self) self.sort = self.data.sort @@ -576,35 +574,98 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): def get_recipe(self, id): return self.conn.get('SELECT script FROM feeds WHERE id=?', (id,), all=False) - def get_categories(self, sort_on_count=False): - self.conn.executescript(u''' - CREATE TEMP VIEW IF NOT EXISTS tag_browser_news AS SELECT DISTINCT - id, - name, - (SELECT COUNT(id) FROM books_tags_link WHERE tag=x.id) count - FROM tags as x WHERE name!="{0}" AND id IN - (SELECT DISTINCT tag FROM books_tags_link WHERE book IN - (SELECT DISTINCT book FROM books_tags_link WHERE tag IN - (SELECT id FROM tags WHERE name="{0}"))); - '''.format(_('News'))) - self.conn.commit() + def get_categories(self, sort_on_count=False, ids=None): + + orig_category_columns = {'tags': ['tag', 'name'], + 'series': ['series', 'name'], + 'publishers': ['publisher', 'name'], + 'authors': ['author', 'name']} # 'news' is added below + cat_cols = {} + + def create_filtered_views(self, ids): + def create_tag_browser_view(table_name, column_name, view_column_name): + script = (''' + CREATE TEMP VIEW IF NOT EXISTS tag_browser_filtered_{tn} AS SELECT + id, + {vcn}, + (SELECT COUNT(books_{tn}_link.id) FROM books_{tn}_link WHERE {cn}={tn}.id and books_list_filter(book)) count + FROM {tn}; + '''.format(tn=table_name, cn=column_name, vcn=view_column_name)) + self.conn.executescript(script) + + self.cat_cols = {} + for tn,cn in orig_category_columns.iteritems(): + create_tag_browser_view(tn, cn[0], cn[1]) + cat_cols[tn] = cn + for i,v in self.custom_column_num_map.iteritems(): + if v['datatype'] == 'text': + tn = 'custom_column_{0}'.format(i) + create_tag_browser_view(tn, 'value', 'value') + cat_cols[tn] = [v['label'], 'value'] + cat_cols['news'] = ['news', 'name'] + + self.conn.executescript(u''' + CREATE TEMP VIEW IF NOT EXISTS tag_browser_news AS SELECT DISTINCT + id, + name, + (SELECT COUNT(books_tags_link.id) FROM books_tags_link WHERE tag=x.id) count + FROM tags as x WHERE name!="{0}" AND id IN + (SELECT DISTINCT tag FROM books_tags_link WHERE book IN + (SELECT DISTINCT book FROM books_tags_link WHERE tag IN + (SELECT id FROM tags WHERE name="{0}"))); + '''.format(_('News'))) + self.conn.commit() + + self.conn.executescript(u''' + CREATE TEMP VIEW IF NOT EXISTS tag_browser_filtered_news AS SELECT DISTINCT + id, + name, + (SELECT COUNT(books_tags_link.id) FROM books_tags_link WHERE tag=x.id and books_list_filter(book)) count + FROM tags as x WHERE name!="{0}" AND id IN + (SELECT DISTINCT tag FROM books_tags_link WHERE book IN + (SELECT DISTINCT book FROM books_tags_link WHERE tag IN + (SELECT id FROM tags WHERE name="{0}"))); + '''.format(_('News'))) + self.conn.commit() + + if ids is not None: + s_ids = set(ids) + else: + s_ids = None + self.conn.create_function('books_list_filter', 1, lambda(id): 1 if id in s_ids else 0) + create_filtered_views(self, ids) categories = {} - for x in ('tags', 'series', 'news', 'publishers', 'authors'): - query = 'SELECT id,name,count FROM tag_browser_'+x + for tn,cn in cat_cols.iteritems(): + if ids is None: + query = 'SELECT id, {0}, count FROM tag_browser_{1}'.format(cn[1], tn) + else: + query = 'SELECT id, {0}, count FROM tag_browser_filtered_{1}'.format(cn[1], tn) if sort_on_count: query += ' ORDER BY count DESC' else: - query += ' ORDER BY name ASC' + query += ' ORDER BY {0} ASC'.format(cn[1]) data = self.conn.get(query) - category = x if x in ('series', 'news') else x[:-1] - categories[category] = [Tag(r[1], count=r[2], id=r[0]) for r in data] - + category = cn[0] + if ids is None: # no filtering + categories[category] = [Tag(r[1], count=r[2], id=r[0]) + for r in data] + else: # filter out zero-count tags + categories[category] = [Tag(r[1], count=r[2], id=r[0]) + for r in data if r[2] > 0] categories['format'] = [] for fmt in self.conn.get('SELECT DISTINCT format FROM data'): fmt = fmt[0] - count = self.conn.get('SELECT COUNT(id) FROM data WHERE format="%s"'%fmt, - all=False) + if ids is not None: + count = self.conn.get('''SELECT COUNT(id) + FROM data + WHERE format="%s" and books_list_filter(id)'''%fmt, + all=False) + else: + count = self.conn.get('''SELECT COUNT(id) + FROM data + WHERE format="%s"'''%fmt, + all=False) categories['format'].append(Tag(fmt, count=count)) if sort_on_count: @@ -612,7 +673,6 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): reverse=True) else: categories['format'].sort(cmp=lambda x,y:cmp(x.name, y.name)) - return categories def tags_older_than(self, tag, delta): diff --git a/src/calibre/utils/search_query_parser.py b/src/calibre/utils/search_query_parser.py index 2acb4708e9..4ee12609f7 100644 --- a/src/calibre/utils/search_query_parser.py +++ b/src/calibre/utils/search_query_parser.py @@ -116,13 +116,12 @@ class SearchQueryParser(object): failed.append(test[0]) return failed - def __init__(self, test=False): + def __init__(self, custcols=[], test=False): self._tests_failed = False # Define a token - locations = map(lambda x : CaselessLiteral(x)+Suppress(':'), - self.LOCATIONS) + standard_locations = map(lambda x : CaselessLiteral(x)+Suppress(':'), self.LOCATIONS+custcols) location = NoMatch() - for l in locations: + for l in standard_locations: location |= l location = Optional(location, default='all') word_query = CharsNotIn(string.whitespace + '()') @@ -176,14 +175,20 @@ class SearchQueryParser(object): def parse(self, query): # empty the list of searches used for recursion testing + self.recurse_level = 0 self.searches_seen = set([]) return self._parse(query) # this parse is used internally because it doesn't clear the - # recursive search test list + # recursive search test list. However, we permit seeing the + # same search a few times because the search might appear within + # another search. def _parse(self, query): + self.recurse_level += 1 res = self._parser.parseString(query)[0] - return self.evaluate(res) + t = self.evaluate(res) + self.recurse_level -= 1 + return t def method(self, group_name): return getattr(self, 'evaluate_'+group_name) @@ -213,7 +218,8 @@ class SearchQueryParser(object): try: if query in self.searches_seen: raise ParseException(query, len(query), 'undefined saved search', self) - self.searches_seen.add(query) + if self.recurse_level > 5: + self.searches_seen.add(query) return self._parse(saved_searches.lookup(query)) except: # convert all exceptions (e.g., missing key) to a parse error raise ParseException(query, len(query), 'undefined saved search', self) From 7f965d850606d7e4886614e484d7dec99ff15f5f Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Sun, 18 Apr 2010 17:16:56 +0100 Subject: [PATCH 02/10] Removed live and commented print statements --- src/calibre/gui2/dialogs/tag_categories.py | 1 - src/calibre/gui2/search_box.py | 13 +------------ src/calibre/utils/search_query_parser.py | 1 - 3 files changed, 1 insertion(+), 14 deletions(-) diff --git a/src/calibre/gui2/dialogs/tag_categories.py b/src/calibre/gui2/dialogs/tag_categories.py index d090c3e424..5711f4794f 100644 --- a/src/calibre/gui2/dialogs/tag_categories.py +++ b/src/calibre/gui2/dialogs/tag_categories.py @@ -114,7 +114,6 @@ class TagCategories(QDialog, Ui_TagCategories): 'permanently deleted. Are you sure?') +'

', 'tag_category_delete', self): return - print 'here', self.current_category if self.current_cat_name is not None: if self.current_cat_name == unicode(self.category_box.currentText()): del self.categories[self.current_cat_label][self.current_cat_name] diff --git a/src/calibre/gui2/search_box.py b/src/calibre/gui2/search_box.py index b69fde5b93..5a4cf25966 100644 --- a/src/calibre/gui2/search_box.py +++ b/src/calibre/gui2/search_box.py @@ -227,7 +227,6 @@ class SavedSearchBox(QComboBox): self.clear_to_help() def normalize_state(self): - #print 'in normalize_state' self.setEditText('') self.line_edit.setStyleSheet( 'QLineEdit { color: black; background-color: %s; }' % @@ -235,7 +234,6 @@ class SavedSearchBox(QComboBox): self.help_state = False def clear_to_help(self): - #print 'in clear_to_help' self.setToolTip(self.tool_tip_text) self.initialize_saved_search_names() self.setEditText(self.help_text) @@ -246,12 +244,10 @@ class SavedSearchBox(QComboBox): self.normal_background) def focus_out(self, event): - #print 'in focus_out' if self.currentText() == '': self.clear_to_help() def key_pressed(self, event): - #print 'in key_pressed' if self.help_state: self.normalize_state() @@ -260,7 +256,6 @@ class SavedSearchBox(QComboBox): self.normalize_state() def saved_search_selected (self, qname): - #print 'in saved_search_selected' qname = unicode(qname) if qname is None or not qname.strip(): return @@ -270,7 +265,6 @@ class SavedSearchBox(QComboBox): self.setToolTip(self.saved_searches.lookup(qname)) def initialize_saved_search_names(self): - #print 'in initialize_saved_search_names' self.clear() qnames = self.saved_searches.names() self.addItems(qnames) @@ -278,7 +272,6 @@ class SavedSearchBox(QComboBox): # SIGNALed from the main UI def delete_search_button_clicked(self): - #print 'in delete_search_button_clicked' if not confirm('

'+_('The selected search will be ' 'permanently deleted. Are you sure?') +'

', 'saved_search_delete', self): @@ -293,7 +286,6 @@ class SavedSearchBox(QComboBox): # SIGNALed from the main UI def save_search_button_clicked(self): - #print 'in save_search_button_clicked' name = unicode(self.currentText()) if self.help_state or not name.strip(): name = unicode(self.search_box.text()).replace('"', '') @@ -310,10 +302,7 @@ class SavedSearchBox(QComboBox): # SIGNALed from the main UI def copy_search_button_clicked (self): - #print 'in copy_search_button_clicked' idx = self.currentIndex(); if idx < 0: return - self.search_box.set_search_string(self.saved_searches.lookup(unicode(self.currentText()))) - - + self.search_box.set_search_string(self.saved_searches.lookup(unicode(self.currentText()))) \ No newline at end of file diff --git a/src/calibre/utils/search_query_parser.py b/src/calibre/utils/search_query_parser.py index 4ee12609f7..6768b66063 100644 --- a/src/calibre/utils/search_query_parser.py +++ b/src/calibre/utils/search_query_parser.py @@ -212,7 +212,6 @@ class SearchQueryParser(object): location = argument[0] query = argument[1] if location.lower() == 'search': - # print "looking for named search " + query if query.startswith('='): query = query[1:] try: From 27eca8fe7269ad5311f38065dd650d5174b8fac9 Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Mon, 19 Apr 2010 17:22:47 +0100 Subject: [PATCH 03/10] Commit before more extensive testing --- resources/images/drawer.svg | 2679 ++++++++++++++++++++ src/calibre/gui2/__init__.py | 3 +- src/calibre/gui2/dialogs/tag_categories.py | 210 +- src/calibre/gui2/dialogs/tag_categories.ui | 70 +- src/calibre/gui2/main.ui | 4 +- src/calibre/gui2/tag_view.py | 101 +- src/calibre/library/caches.py | 1 + src/calibre/library/database2.py | 10 +- src/calibre/utils/date.py | 9 +- 9 files changed, 2874 insertions(+), 213 deletions(-) create mode 100644 resources/images/drawer.svg diff --git a/resources/images/drawer.svg b/resources/images/drawer.svg new file mode 100644 index 0000000000..679bca53b2 --- /dev/null +++ b/resources/images/drawer.svg @@ -0,0 +1,2679 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/calibre/gui2/__init__.py b/src/calibre/gui2/__init__.py index 12987caeb9..a78c71316f 100644 --- a/src/calibre/gui2/__init__.py +++ b/src/calibre/gui2/__init__.py @@ -96,7 +96,8 @@ def _config(): help=_('Overwrite author and title with new metadata')) c.add_opt('enforce_cpu_limit', default=True, help=_('Limit max simultaneous jobs to number of CPUs')) - c.add_opt('tag_categories', default={}, help=_('User-created tag categories')) + c.add_opt('user_categories', default={}, + help=_('User-created tag browser categories')) return ConfigProxy(c) diff --git a/src/calibre/gui2/dialogs/tag_categories.py b/src/calibre/gui2/dialogs/tag_categories.py index 5711f4794f..74afc67242 100644 --- a/src/calibre/gui2/dialogs/tag_categories.py +++ b/src/calibre/gui2/dialogs/tag_categories.py @@ -1,8 +1,12 @@ __license__ = 'GPL v3' __copyright__ = '2008, Kovid Goyal ' -from PyQt4.QtCore import SIGNAL, Qt -from PyQt4.QtGui import QDialog, QDialogButtonBox, QLineEdit, QComboBox + +from copy import copy + +from PyQt4.QtCore import SIGNAL, Qt, QVariant +from PyQt4.QtGui import QDialog, QDialogButtonBox, QLineEdit, QComboBox, \ + QIcon, QListWidgetItem from PyQt4.Qt import QString from calibre.gui2.dialogs.tag_categories_ui import Ui_TagCategories @@ -11,10 +15,18 @@ from calibre.gui2 import question_dialog, error_dialog from calibre.gui2.dialogs.confirm_delete import confirm from calibre.constants import islinux -class TagCategories(QDialog, Ui_TagCategories): - category_names = [_('Authors'), _('Series'), _('Publishers'), _('Tags')] - category_labels = ['author', 'series', 'publisher', 'tag'] +class Item: + def __init__(self, name, label, index, icon, exists): + self.name = name + self.label = label + self.index = index + self.icon = icon + self.exists = exists + def __str__(self): + return 'name=%s, label=%s, index=%s, exists='%(self.name, self.label, self.index, self.exists) +class TagCategories(QDialog, Ui_TagCategories): + category_labels = ['', 'author', 'series', 'publisher', 'tag'] def __init__(self, window, db, index=None): QDialog.__init__(self, window) @@ -23,86 +35,110 @@ class TagCategories(QDialog, Ui_TagCategories): self.db = db self.index = index - self.tags = [] + self.applied_items = [] - self.all_items = {} - self.all_items['tag'] = sorted(self.db.all_tags(), cmp=lambda x,y: cmp(x.lower(), y.lower())) - self.all_items['author'] = sorted([i[1].replace('|', ',') for i in self.db.all_authors()], - cmp=lambda x,y: cmp(x.lower(), y.lower())) - self.all_items['publisher'] = sorted([i[1] for i in self.db.all_publishers()], - cmp=lambda x,y: cmp(x.lower(), y.lower())) - self.all_items['series'] = sorted([i[1] for i in self.db.all_series()], - cmp=lambda x,y: cmp(x.lower(), y.lower())) + category_icons = [None, QIcon(I('user_profile.svg')), QIcon(I('series.svg')), + QIcon(I('publisher.png')), QIcon(I('tags.svg'))] + category_values = [None, + lambda: [n for (id, n) in self.db.all_authors()], + lambda: [n for (id, n) in self.db.all_series()], + lambda: [n for (id, n) in self.db.all_publishers()], + lambda: self.db.all_tags() + ] + category_names = ['', _('Authors'), _('Series'), _('Publishers'), _('Tags')] + + self.all_items = [] + self.all_items_dict = {} + for idx,label in enumerate(self.category_labels): + if idx == 0: + continue + for n in category_values[idx](): + t = Item(name=n, label=label, index=len(self.all_items),icon=category_icons[idx], exists=True) + self.all_items.append(t) + self.all_items_dict[label+':'+n] = t + + self.categories = dict.copy(config['user_categories']) + if self.categories is None: + self.categories = {} + for cat in self.categories: + for item,l in enumerate(self.categories[cat]): + key = ':'.join([l[1], l[0]]) + t = self.all_items_dict.get(key, None) + if t is None: + t = Item(name=l[0], label=l[1], index=len(self.all_items), + icon=category_icons[self.category_labels.index(l[1])], exists=False) + self.all_items.append(t) + self.all_items_dict[key] = t + l[2] = t.index + + self.all_items_sorted = sorted(self.all_items, cmp=lambda x,y: cmp(x.name.lower(), y.name.lower())) + self.display_filtered_categories(0) + + for v in category_names: + self.category_filter_box.addItem(v) self.current_cat_name = None - self.current_cat_label= None - self.category_label_to_name = {} - self.category_name_to_label = {} - for i in range(len(self.category_labels)): - self.category_label_to_name[self.category_labels[i]] = self.category_names[i] - self.category_name_to_label[self.category_names[i]] = self.category_labels[i] self.connect(self.apply_button, SIGNAL('clicked()'), self.apply_tags) self.connect(self.unapply_button, SIGNAL('clicked()'), self.unapply_tags) self.connect(self.add_category_button, SIGNAL('clicked()'), self.add_category) self.connect(self.category_box, SIGNAL('currentIndexChanged(int)'), self.select_category) + self.connect(self.category_filter_box, SIGNAL('currentIndexChanged(int)'), self.display_filtered_categories) self.connect(self.delete_category_button, SIGNAL('clicked()'), self.del_category) if islinux: - self.available_tags.itemDoubleClicked.connect(self.apply_tags) + self.available_items_box.itemDoubleClicked.connect(self.apply_tags) else: - self.connect(self.available_tags, SIGNAL('itemActivated(QListWidgetItem*)'), self.apply_tags) - self.connect(self.applied_tags, SIGNAL('itemActivated(QListWidgetItem*)'), self.unapply_tags) + self.connect(self.available_items_box, SIGNAL('itemActivated(QListWidgetItem*)'), self.apply_tags) + self.connect(self.applied_items_box, SIGNAL('itemActivated(QListWidgetItem*)'), self.unapply_tags) - self.categories = dict.copy(config['tag_categories']) - if self.categories is None: - self.categories = {} self.populate_category_list() - self.category_kind_box.clear() - for i in range(len(self.category_names)): - self.category_kind_box.addItem(self.category_names[i]) + return self.select_category(0) - def apply_tags(self, item=None): - if self.current_cat_name[0] is None: + def make_list_widget(self, item): + n = item.name if item.exists else item.name + _(' (not on any book)') + w = QListWidgetItem(item.icon, n) + w.setData(Qt.UserRole, item.index) + return w + + def display_filtered_categories(self, idx): + idx = idx if idx is not None else self.category_filter_box.currentIndex() + self.available_items_box.clear() + self.applied_items_box.clear() + for item in self.all_items_sorted: + if idx == 0 or item.label == self.category_labels[idx]: + if item.index not in self.applied_items and item.exists: + self.available_items_box.addItem(self.make_list_widget(item)) + for index in self.applied_items: + self.applied_items_box.addItem(self.make_list_widget(self.all_items[index])) + + def apply_tags(self, node=None): + if self.current_cat_name is None: return - items = self.available_tags.selectedItems() if item is None else [item] - for item in items: - tag = qstring_to_unicode(item.text()) - if tag not in self.tags: - self.tags.append(tag) - self.available_tags.takeItem(self.available_tags.row(item)) - self.tags.sort() - self.applied_tags.clear() - for tag in self.tags: - self.applied_tags.addItem(tag) - def unapply_tags(self, item=None): - items = self.applied_tags.selectedItems() if item is None else [item] - for item in items: - tag = qstring_to_unicode(item.text()) - self.tags.remove(tag) - self.available_tags.addItem(tag) - self.tags.sort() - self.applied_tags.clear() - for tag in self.tags: - self.applied_tags.addItem(tag) - self.available_tags.sortItems() + nodes = self.available_items_box.selectedItems() if node is None else [node] + for node in nodes: + index = self.all_items[node.data(Qt.UserRole).toPyObject()].index + if index not in self.applied_items: + self.applied_items.append(index) + self.applied_items.sort(cmp=lambda x, y:cmp(self.all_items[x].name.lower(), self.all_items[y].name.lower())) + self.display_filtered_categories(None) + + def unapply_tags(self, node=None): + nodes = self.applied_items_box.selectedItems() if node is None else [node] + for node in nodes: + index = self.all_items[node.data(Qt.UserRole).toPyObject()].index + self.applied_items.remove(index) + self.display_filtered_categories(None) def add_category(self): self.save_category() cat_name = qstring_to_unicode(self.input_box.text()).strip() if cat_name == '': - return - cat_kind = unicode(self.category_kind_box.currentText()) - r_cat_kind = self.category_name_to_label[cat_kind] - if r_cat_kind not in self.categories: - self.categories[r_cat_kind] = {} - if cat_name not in self.categories[r_cat_kind]: + return False + if cat_name not in self.categories: self.category_box.clear() - self.category_kind_label.setText(cat_kind) self.current_cat_name = cat_name - self.current_cat_label = r_cat_kind - self.categories[r_cat_kind][cat_name] = [] - if len(self.tags): - self.clear_boxes(item_label=self.current_cat_label) + self.categories[cat_name] = [] + self.applied_items = [] self.populate_category_list() self.category_box.setCurrentIndex(self.category_box.findText(cat_name)) else: @@ -116,8 +152,8 @@ class TagCategories(QDialog, Ui_TagCategories): return if self.current_cat_name is not None: if self.current_cat_name == unicode(self.category_box.currentText()): - del self.categories[self.current_cat_label][self.current_cat_name] - self.current_category = [None, None] ## order here is important. RemoveItem will put it back + del self.categories[self.current_cat_name] + self.current_category = None self.category_box.removeItem(self.category_box.currentIndex()) def select_category(self, idx): @@ -125,47 +161,25 @@ class TagCategories(QDialog, Ui_TagCategories): s = self.category_box.itemText(idx) if s: self.current_cat_name = unicode(s) - self.current_cat_label = str(self.category_box.itemData(idx).toString()) else: self.current_cat_name = None - self.current_cat_label = None - self.clear_boxes(item_label=False) - if self.current_cat_label: - self.category_kind_label.setText(self.category_label_to_name[self.current_cat_label]) - self.tags = self.categories[self.current_cat_label].get(self.current_cat_name, []) - # Must do two loops because obsolete values can be saved - # We need to show these to the user so they can be deleted if desired - for t in self.tags: - self.applied_tags.addItem(t) - for t in self.all_items[self.current_cat_label]: - if t not in self.tags: - self.available_tags.addItem(t) - else: - self.category_kind_label.setText('') - - - def clear_boxes(self, item_label = None): - self.tags = [] - self.applied_tags.clear() - self.available_tags.clear() - if item_label: - for item in self.all_items[item_label]: - self.available_tags.addItem(item) + if self.current_cat_name: + self.applied_items = [tup[2] for tup in self.categories.get(self.current_cat_name, [])] + self.display_filtered_categories(None) def accept(self): self.save_category() - config['tag_categories'] = self.categories + config['user_categories'] = self.categories QDialog.accept(self) def save_category(self): if self.current_cat_name is not None: - self.categories[self.current_cat_label][self.current_cat_name] = self.tags + l = [] + for index in self.applied_items: + item = self.all_items[index] + l.append([item.name, item.label, item.index]) + self.categories[self.current_cat_name] = l def populate_category_list(self): - cat_list = {} - for c in self.categories: - for n in self.categories[c]: - if n.strip(): - cat_list[n] = c - for n in sorted(cat_list.keys(), cmp=lambda x,y: cmp(x.lower(), y.lower())): - self.category_box.addItem(n, cat_list[n]) \ No newline at end of file + for n in sorted(self.categories.keys(), cmp=lambda x,y: cmp(x.lower(), y.lower())): + self.category_box.addItem(n) \ No newline at end of file diff --git a/src/calibre/gui2/dialogs/tag_categories.ui b/src/calibre/gui2/dialogs/tag_categories.ui index 9b3b58bc87..2904b2464e 100644 --- a/src/calibre/gui2/dialogs/tag_categories.ui +++ b/src/calibre/gui2/dialogs/tag_categories.ui @@ -25,10 +25,10 @@ - A&vailable values + A&vailable items - available_tags + available_items_box @@ -50,7 +50,7 @@ - + true @@ -117,10 +117,10 @@ - A&pplied values + A&pplied items - applied_tags + applied_items_box @@ -140,7 +140,7 @@ - + true @@ -311,48 +311,6 @@ - - - - Select the content kind of the new category - - - - Author - - - - - Series - - - - - Formats - - - - - Publishers - - - - - Tags - - - - - - - - Category kind: - - - category_kind_box - - - @@ -366,23 +324,23 @@ - - - - TextLabel - - - - Category kind: + Category filter: Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter + + + + Select the content kind of the new category + + + diff --git a/src/calibre/gui2/main.ui b/src/calibre/gui2/main.ui index b3ed89af93..12ce6fb2c9 100644 --- a/src/calibre/gui2/main.ui +++ b/src/calibre/gui2/main.ui @@ -355,10 +355,10 @@ - Manage tag categories + Manage user categories - Create, edit, and delete tag categories + Create, edit, and delete user categories diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py index 7b24d9d2e0..2e964e8d8a 100644 --- a/src/calibre/gui2/tag_view.py +++ b/src/calibre/gui2/tag_view.py @@ -126,7 +126,8 @@ class TagTreeItem(object): TAG = 1 ROOT = 2 - def __init__(self, data=None, tag=None, category_icon=None, icon_map=None, parent=None): +# def __init__(self, data=None, tag=None, category_icon=None, icon_map=None, parent=None): + def __init__(self, data=None, category_icon=None, icon_map=None, parent=None): self.parent = parent self.children = [] if self.parent is not None: @@ -142,13 +143,14 @@ class TagTreeItem(object): self.bold_font.setBold(True) self.bold_font = QVariant(self.bold_font) elif self.type == self.TAG: - self.tag, self.icon_map = data, list(map(QVariant, icon_map)) + icon_map[0] = data.icon + self.tag, self.icon_state_map = data, list(map(QVariant, icon_map)) def __str__(self): if self.type == self.ROOT: return 'ROOT' if self.type == self.CATEGORY: - return 'CATEGORY:'+self.name+':%d'%len(self.children) + return 'CATEGORY:'+str(QVariant.toString(self.name))+':%d'%len(self.children) return 'TAG:'+self.tag.name def row(self): @@ -183,7 +185,7 @@ class TagTreeItem(object): else: return QVariant('[%d] %s'%(self.tag.count, self.tag.name)) if role == Qt.DecorationRole: - return self.icon_map[self.tag.state] + return self.icon_state_map[self.tag.state] if role == Qt.ToolTipRole and self.tag.tooltip: return QVariant(self.tag.tooltip) return NONE @@ -196,16 +198,20 @@ class TagTreeItem(object): class TagsModel(QAbstractItemModel): categories_orig = [_('Authors'), _('Series'), _('Formats'), _('Publishers'), _('News'), _('All tags')] row_map_orig = ['author', 'series', 'format', 'publisher', 'news', 'tag'] - fixed_categories= 5 + tags_categories_start= 5 search_keys=['search', _('Searches')] def __init__(self, db, parent=None): QAbstractItemModel.__init__(self, parent) - self.cmap_orig = list(map(QIcon, [I('user_profile.svg'), + self.cat_icon_map_orig = list(map(QIcon, [I('user_profile.svg'), I('series.svg'), I('book.svg'), I('publisher.png'), - I('news.svg')])) - self.icon_map = [QIcon(), QIcon(I('plus.svg')), - QIcon(I('minus.svg'))] + I('news.svg'), I('tags.svg')])) + self.icon_state_map = [None, QIcon(I('plus.svg')), QIcon(I('minus.svg'))] + self.custcol_icon = QIcon(I('column.svg')) + self.search_icon = QIcon(I('search.svg')) + self.usercat_icon = QIcon(I('drawer.svg')) + self.label_to_icon_map = dict(map(None, self.row_map_orig, self.cat_icon_map_orig)) + self.label_to_icon_map['*custom'] = self.custcol_icon self.db = db self.search_restriction = '' self.user_categories = {} @@ -214,9 +220,9 @@ class TagsModel(QAbstractItemModel): self.root_item = TagTreeItem() for i, r in enumerate(self.row_map): c = TagTreeItem(parent=self.root_item, - data=self.categories[i], category_icon=self.cmap[i]) + data=self.categories[i], category_icon=self.cat_icon_map[i]) for tag in data[r]: - TagTreeItem(parent=c, data=tag, icon_map=self.icon_map) + TagTreeItem(parent=c, data=tag, icon_map=self.icon_state_map) def set_search_restriction(self, s): self.search_restriction = s @@ -224,65 +230,58 @@ class TagsModel(QAbstractItemModel): def get_node_tree(self, sort): self.row_map = [] self.categories = [] - self.cmap = self.cmap_orig[:] - self.user_categories = dict.copy(config['tag_categories']) + self.cat_icon_map = self.cat_icon_map_orig[:-1] # strip the tags icon. We will put it back later + self.user_categories = dict.copy(config['user_categories']) column_map = config['column_map'] - for i in range(0, self.fixed_categories): # First the standard categories + for i in range(0, self.tags_categories_start): # First the standard categories self.row_map.append(self.row_map_orig[i]) self.categories.append(self.categories_orig[i]) if len(self.search_restriction): - data = self.db.get_categories(sort_on_count=sort, + data = self.db.get_categories(sort_on_count=sort, icon_map=self.label_to_icon_map, ids=self.db.search(self.search_restriction, return_matches=True)) else: - data = self.db.get_categories(sort_on_count=sort) + data = self.db.get_categories(sort_on_count=sort, icon_map=self.label_to_icon_map) - for i in data: # now the custom columns - if i not in self.row_map_orig and i in column_map: - self.row_map.append(i) - self.categories.append(self.db.custom_column_label_map[i]['name']) - self.cmap.append(QIcon(I('column.svg'))) + for c in data: # now the custom columns + if c not in self.row_map_orig and c in column_map: + self.row_map.append(c) + self.categories.append(self.db.custom_column_label_map[c]['name']) + self.cat_icon_map.append(self.custcol_icon) - for i in self.row_map_orig: - if i not in self.user_categories: - self.user_categories[i] = {} - config['tag_categories'] = self.user_categories + # Now do the user-defined categories. There is a time/space tradeoff here. + # By converting the tags into a map, we can do the verification in the category + # loop much faster, at the cost of duplicating the categories lists. + taglist = {} + for c in self.row_map_orig: + taglist[c] = dict(map(lambda t:(t.name if c != 'author' else t.name.replace('|', ','), t), data[c])) - taglist = {} # Now the user-defined categories - for i in data: - taglist[i] = dict(map(lambda t:(t.name if i != 'author' else t.name.replace('|', ','), t), data[i])) - for k in self.row_map_orig: - if k not in self.user_categories: - continue - for i in sorted(self.user_categories[k].keys()): # now the tag categories - l = [] - for t in self.user_categories[k][i]: - if t in taglist[k]: # use same tag node as the complete category - l.append(taglist[k][t]) - # else: eliminate nodes that have zero counts - data[i+'*'] = l - self.row_map.append(i+'*') - self.categories.append(i) - if k == 'tag': # choose the icon - self.cmap.append(QIcon(I('tags.svg'))) - else: - self.cmap.append(QIcon(self.cmap[self.row_map_orig.index(k)])) + for c in self.user_categories: + l = [] + for (name,label,ign) in self.user_categories[c]: + if name in taglist[label]: # use same node as the complete category + l.append(taglist[label][name]) + # else: do nothing, to eliminate nodes that have zero counts + data[c+'*'] = sorted(l, cmp=(lambda x, y: cmp(x.name.lower(), y.name.lower()))) + self.row_map.append(c+'*') + self.categories.append(c) + self.cat_icon_map.append(self.usercat_icon) # Now the rest of the normal tag categories - for i in range(self.fixed_categories, len(self.row_map_orig)): + for i in range(self.tags_categories_start, len(self.row_map_orig)): self.row_map.append(self.row_map_orig[i]) self.categories.append(self.categories_orig[i]) - self.cmap.append(QIcon(I('tags.svg'))) - data['search'] = self.get_search_nodes() # Add the search category + self.cat_icon_map.append(self.cat_icon_map_orig[i]) + data['search'] = self.get_search_nodes(self.search_icon) # Add the search category self.row_map.append(self.search_keys[0]) self.categories.append(self.search_keys[1]) - self.cmap.append(QIcon(I('search.svg'))) + self.cat_icon_map.append(self.search_icon) return data - def get_search_nodes(self): + def get_search_nodes(self, icon): l = [] for i in saved_searches.names(): - l.append(Tag(i, tooltip=saved_searches.lookup(i))) + l.append(Tag(i, tooltip=saved_searches.lookup(i), icon=icon)) return l def refresh(self): @@ -302,7 +301,7 @@ class TagsModel(QAbstractItemModel): self.beginInsertRows(category_index, 0, len(data[r])-1) for tag in data[r]: tag.state = state_map.get(tag.name, 0) - t = TagTreeItem(parent=category, data=tag, icon_map=self.icon_map) + t = TagTreeItem(parent=category, data=tag, icon_map=self.icon_state_map) self.endInsertRows() def columnCount(self, parent): diff --git a/src/calibre/library/caches.py b/src/calibre/library/caches.py index 1ce0843185..6794408ca0 100644 --- a/src/calibre/library/caches.py +++ b/src/calibre/library/caches.py @@ -15,6 +15,7 @@ from PyQt4.QtGui import QImage from calibre.utils.config import tweaks, prefs from calibre.utils.date import parse_date from calibre.utils.search_query_parser import SearchQueryParser +from calibre.utils.pyparsing import ParseException class CoverCache(QThread): diff --git a/src/calibre/library/database2.py b/src/calibre/library/database2.py index f704eb68a6..55aa7520f7 100644 --- a/src/calibre/library/database2.py +++ b/src/calibre/library/database2.py @@ -58,12 +58,13 @@ copyfile = os.link if hasattr(os, 'link') else shutil.copyfile class Tag(object): - def __init__(self, name, id=None, count=0, state=0, tooltip=None): + def __init__(self, name, id=None, count=0, state=0, tooltip=None, icon=None): self.name = name self.id = id self.count = count self.state = state self.tooltip = tooltip + self.icon = icon def __unicode__(self): return u'%s:%s:%s:%s:%s'%(self.name, self.count, self.id, self.state, self.tooltip) @@ -574,7 +575,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): def get_recipe(self, id): return self.conn.get('SELECT script FROM feeds WHERE id=?', (id,), all=False) - def get_categories(self, sort_on_count=False, ids=None): + def get_categories(self, sort_on_count=False, ids=None, icon_map=None): orig_category_columns = {'tags': ['tag', 'name'], 'series': ['series', 'name'], @@ -647,11 +648,12 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): query += ' ORDER BY {0} ASC'.format(cn[1]) data = self.conn.get(query) category = cn[0] + icon = icon_map[category] if category in icon_map else icon_map['*custom'] if ids is None: # no filtering - categories[category] = [Tag(r[1], count=r[2], id=r[0]) + categories[category] = [Tag(r[1], count=r[2], id=r[0], icon=icon) for r in data] else: # filter out zero-count tags - categories[category] = [Tag(r[1], count=r[2], id=r[0]) + categories[category] = [Tag(r[1], count=r[2], id=r[0], icon=icon) for r in data if r[2] > 0] categories['format'] = [] for fmt in self.conn.get('SELECT DISTINCT format FROM data'): diff --git a/src/calibre/utils/date.py b/src/calibre/utils/date.py index fb9d3e90b0..e48e10d90f 100644 --- a/src/calibre/utils/date.py +++ b/src/calibre/utils/date.py @@ -24,6 +24,13 @@ class SafeLocalTimeZone(tzlocal): pass return False +def compute_locale_info_for_parse_date(): + dt = datetime.strptime('1/5/2000', "%x") + if dt.month == 5: + return True + return False + +parse_date_day_first = compute_locale_info_for_parse_date() utc_tz = _utc_tz = tzutc() local_tz = _local_tz = SafeLocalTimeZone() @@ -44,7 +51,7 @@ def parse_date(date_string, assume_utc=False, as_utc=True, default=None): func = datetime.utcnow if assume_utc else datetime.now default = func().replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=_utc_tz if assume_utc else _local_tz) - dt = parse(date_string, default=default) + dt = parse(date_string, default=default, dayfirst=parse_date_day_first) if dt.tzinfo is None: dt = dt.replace(tzinfo=_utc_tz if assume_utc else _local_tz) return dt.astimezone(_utc_tz if as_utc else _local_tz) From e3c9ebf2839ce0acfefa171df1578eb074e73ce8 Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Mon, 19 Apr 2010 18:40:04 +0100 Subject: [PATCH 04/10] Commit for merging into the 'device' branch --- src/calibre/gui2/tag_view.py | 1 - src/calibre/gui2/ui.py | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py index 2e964e8d8a..f0764abb86 100644 --- a/src/calibre/gui2/tag_view.py +++ b/src/calibre/gui2/tag_view.py @@ -126,7 +126,6 @@ class TagTreeItem(object): TAG = 1 ROOT = 2 -# def __init__(self, data=None, tag=None, category_icon=None, icon_map=None, parent=None): def __init__(self, data=None, category_icon=None, icon_map=None, parent=None): self.parent = parent self.children = [] diff --git a/src/calibre/gui2/ui.py b/src/calibre/gui2/ui.py index d0ffad610c..c1ff3ececd 100644 --- a/src/calibre/gui2/ui.py +++ b/src/calibre/gui2/ui.py @@ -650,6 +650,7 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI): v.resizeRowToContents(0) height = v.rowHeight(0) self.library_view.verticalHeader().setDefaultSectionSize(height) + print datetime.now() def do_edit_categories(self): d = TagCategories(self, self.library_view.model().db) From feaebe3524aa36083fed224fcd198e69125df528 Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Tue, 20 Apr 2010 14:26:21 +0100 Subject: [PATCH 05/10] After testing, for submission --- .../dialogs/config/create_custom_column.py | 2 +- src/calibre/gui2/dialogs/tag_categories.py | 19 +++---- src/calibre/gui2/library.py | 49 ++++++++++++------- src/calibre/gui2/tag_view.py | 14 ++++-- src/calibre/gui2/ui.py | 1 - 5 files changed, 52 insertions(+), 33 deletions(-) diff --git a/src/calibre/gui2/dialogs/config/create_custom_column.py b/src/calibre/gui2/dialogs/config/create_custom_column.py index 0b6d15a2b5..b0f0fbcaac 100644 --- a/src/calibre/gui2/dialogs/config/create_custom_column.py +++ b/src/calibre/gui2/dialogs/config/create_custom_column.py @@ -75,7 +75,7 @@ class CreateCustomColumn(QDialog, Ui_QCreateCustomColumn): if col in self.standard_colnames: bad_col = True if bad_col: - self.parent.messagebox(_('The lookup name is already used')) + self.parent.messagebox(_('The lookup name %s is already used')%col) return bad_head = False for t in self.parent.custcols: diff --git a/src/calibre/gui2/dialogs/tag_categories.py b/src/calibre/gui2/dialogs/tag_categories.py index 74afc67242..dbba827cbe 100644 --- a/src/calibre/gui2/dialogs/tag_categories.py +++ b/src/calibre/gui2/dialogs/tag_categories.py @@ -146,15 +146,14 @@ class TagCategories(QDialog, Ui_TagCategories): return True def del_category(self): - if not confirm('

'+_('The current tag category will be ' - 'permanently deleted. Are you sure?') - +'

', 'tag_category_delete', self): - return if self.current_cat_name is not None: - if self.current_cat_name == unicode(self.category_box.currentText()): - del self.categories[self.current_cat_name] - self.current_category = None - self.category_box.removeItem(self.category_box.currentIndex()) + if not confirm('

'+_('The current tag category will be ' + 'permanently deleted. Are you sure?') + +'

', 'tag_category_delete', self): + return + del self.categories[self.current_cat_name] + self.current_cat_name = None + self.category_box.removeItem(self.category_box.currentIndex()) def select_category(self, idx): self.save_category() @@ -164,7 +163,9 @@ class TagCategories(QDialog, Ui_TagCategories): else: self.current_cat_name = None if self.current_cat_name: - self.applied_items = [tup[2] for tup in self.categories.get(self.current_cat_name, [])] + self.applied_items = [cat[2] for cat in self.categories.get(self.current_cat_name, [])] + else: + self.applied_items = [] self.display_filtered_categories(None) def accept(self): diff --git a/src/calibre/gui2/library.py b/src/calibre/gui2/library.py index 2261e29479..58a9ac5ea9 100644 --- a/src/calibre/gui2/library.py +++ b/src/calibre/gui2/library.py @@ -300,13 +300,13 @@ class BooksModel(QAbstractTableModel): self.headers[i] = self.orig_headers[i] elif i in self.custom_columns: self.headers[i] = self.custom_columns[i]['name'] + self.build_data_convertors() self.reset() self.emit(SIGNAL('columns_sorted()')) def set_database(self, db): self.db = db self.custom_columns = self.db.custom_column_label_map - self.build_data_convertors() self.read_config() def refresh_ids(self, ids, current_row=-1): @@ -703,9 +703,9 @@ class BooksModel(QAbstractTableModel): def bool_type(r, idx=-1): return None # displayed using a decorator - def bool_type_decorator(r, idx=-1): + def bool_type_decorator(r, idx=-1, bool_cols_are_tristate=True): val = self.db.data[r][idx] - if tweaks['bool_custom_columns_are_tristate'] == 'no': + if not bool_cols_are_tristate: if val is None or not val: return self.bool_no_icon if val: @@ -748,21 +748,32 @@ class BooksModel(QAbstractTableModel): self.dc[col] = functools.partial(datetime_type, idx=idx) elif datatype == 'bool': self.dc[col] = functools.partial(bool_type, idx=idx) - self.dc_decorator[col] = functools.partial(bool_type_decorator, idx=idx) + self.dc_decorator[col] = functools.partial( + bool_type_decorator, idx=idx, + bool_cols_are_tristate=tweaks['bool_custom_columns_are_tristate'] == 'yes') elif datatype == 'rating': self.dc[col] = functools.partial(rating_type, idx=idx) else: print 'What type is this?', col, datatype + # build a index column to data converter map, to remove the string lookup in the data loop + self.column_to_dc_map = [] + self.column_to_dc_decorator_map = [] + for col in self.column_map: + self.column_to_dc_map.append(self.dc[col]) + self.column_to_dc_decorator_map.append(self.dc_decorator.get(col, None)) def data(self, index, role): - if role in (Qt.DisplayRole, Qt.EditRole): - return self.dc[self.column_map[index.column()]](index.row()) - elif role == Qt.DecorationRole: - if self.column_map[index.column()] in self.dc_decorator: - return self.dc_decorator[self.column_map[index.column()]](index.row()) + col = index.column() + # in obscure cases where custom columns are both edited and added, for a time + # the column map does not accurately represent the screen. In these cases, + # we will get asked to display columns we don't know about. Must test for this. + if col >= len(self.column_to_dc_map): return None - #elif role == Qt.SizeHintRole: - # return QVariant(Qt.SizeHint(1, 23)) + if role in (Qt.DisplayRole, Qt.EditRole): + return self.column_to_dc_map[col](index.row()) + elif role == Qt.DecorationRole: + if self.column_to_dc_decorator_map[col] is not None: + return self.column_to_dc_decorator_map[index.column()](index.row()) #elif role == Qt.TextAlignmentRole and self.column_map[index.column()] in ('size', 'timestamp'): # return QVariant(Qt.AlignVCenter | Qt.AlignCenter) #elif role == Qt.ToolTipRole and index.isValid(): @@ -771,14 +782,18 @@ class BooksModel(QAbstractTableModel): return NONE def headerData(self, section, orientation, role): - if role == Qt.ToolTipRole: - return QVariant(_('The lookup/search name is "{0}"').format(self.column_map[section])) - if role != Qt.DisplayRole: - return NONE if orientation == Qt.Horizontal: - return QVariant(self.headers[self.column_map[section]]) - else: + if section >= len(self.column_map): # same problem as in data, the column_map can be wrong + return None + if role == Qt.ToolTipRole: + return QVariant(_('The lookup/search name is "{0}"').format(self.column_map[section])) + if role == Qt.DisplayRole: + return QVariant(self.headers[self.column_map[section]]) + return NONE + if role == Qt.DisplayRole: # orientation is vertical return QVariant(section+1) + return NONE + def flags(self, index): flags = QAbstractTableModel.flags(self, index) diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py index f0764abb86..7d79fedb72 100644 --- a/src/calibre/gui2/tag_view.py +++ b/src/calibre/gui2/tag_view.py @@ -229,7 +229,8 @@ class TagsModel(QAbstractItemModel): def get_node_tree(self, sort): self.row_map = [] self.categories = [] - self.cat_icon_map = self.cat_icon_map_orig[:-1] # strip the tags icon. We will put it back later + # strip the icons after the 'standard' categories. We will put them back later + self.cat_icon_map = self.cat_icon_map_orig[:self.tags_categories_start-len(self.row_map_orig)] self.user_categories = dict.copy(config['user_categories']) column_map = config['column_map'] @@ -261,7 +262,10 @@ class TagsModel(QAbstractItemModel): if name in taglist[label]: # use same node as the complete category l.append(taglist[label][name]) # else: do nothing, to eliminate nodes that have zero counts - data[c+'*'] = sorted(l, cmp=(lambda x, y: cmp(x.name.lower(), y.name.lower()))) + if config['sort_by_popularity']: + data[c+'*'] = sorted(l, cmp=(lambda x, y: cmp(x.count, y.count))) + else: + data[c+'*'] = sorted(l, cmp=(lambda x, y: cmp(x.name.lower(), y.name.lower()))) self.row_map.append(c+'*') self.categories.append(c) self.cat_icon_map.append(self.usercat_icon) @@ -418,14 +422,14 @@ class TagsModel(QAbstractItemModel): ans = [] tags_seen = [] for i, key in enumerate(self.row_map): + if key.endswith('*'): # User category, so skip it. The tag will be marked in its real category + continue category_item = self.root_item.children[i] for tag_item in category_item.children: tag = tag_item.tag if tag.state > 0: prefix = ' not ' if tag.state == 2 else '' - category = key if not key.endswith('*') and \ - key not in ['news', 'specialtags', 'normaltags'] \ - else 'tag' + category = key if key != 'news' else 'tag' if category == 'tag': if tag.name in tags_seen: continue diff --git a/src/calibre/gui2/ui.py b/src/calibre/gui2/ui.py index c1ff3ececd..d0ffad610c 100644 --- a/src/calibre/gui2/ui.py +++ b/src/calibre/gui2/ui.py @@ -650,7 +650,6 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI): v.resizeRowToContents(0) height = v.rowHeight(0) self.library_view.verticalHeader().setDefaultSectionSize(height) - print datetime.now() def do_edit_categories(self): d = TagCategories(self, self.library_view.model().db) From f12c081bde97032bbf0f7d419afbb9f009f521e0 Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Tue, 20 Apr 2010 14:45:00 +0100 Subject: [PATCH 06/10] Add #5332 - 'today' as a date search --- src/calibre/library/caches.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/src/calibre/library/caches.py b/src/calibre/library/caches.py index 6794408ca0..79115eb96a 100644 --- a/src/calibre/library/caches.py +++ b/src/calibre/library/caches.py @@ -13,7 +13,7 @@ from PyQt4.QtCore import QThread, QReadWriteLock from PyQt4.QtGui import QImage from calibre.utils.config import tweaks, prefs -from calibre.utils.date import parse_date +from calibre.utils.date import parse_date, now from calibre.utils.search_query_parser import SearchQueryParser from calibre.utils.pyparsing import ParseException @@ -232,14 +232,19 @@ class ResultCache(SearchQueryParser): loc = self.FIELD_MAP[self.custom_column_label_map[location]['num']] else: loc = self.FIELD_MAP[{'date':'timestamp', 'pubdate':'pubdate'}[location]] - try: - qd = parse_date(query) - except: - raise ParseException(query, len(query), 'Date conversion error', self) - if '-' in query: - field_count = query.count('-') + 1 + + if query == _('today'): + qd = now() + field_count = 3 else: - field_count = query.count('/') + 1 + try: + qd = parse_date(query) + except: + raise ParseException(query, len(query), 'Date conversion error', self) + if '-' in query: + field_count = query.count('-') + 1 + else: + field_count = query.count('/') + 1 for item in self._data: if item is None or item[loc] is None: continue if relop(item[loc], qd, field_count): From 54074590847fdfab52466ce96e2e6fc5e726963c Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Tue, 20 Apr 2010 15:03:45 +0100 Subject: [PATCH 07/10] More date: words (yesterday and thismonth) --- src/calibre/library/caches.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/calibre/library/caches.py b/src/calibre/library/caches.py index 79115eb96a..df6e78759f 100644 --- a/src/calibre/library/caches.py +++ b/src/calibre/library/caches.py @@ -8,6 +8,7 @@ __docformat__ = 'restructuredtext en' import collections, glob, os, re, itertools, functools from itertools import repeat +from datetime import timedelta from PyQt4.QtCore import QThread, QReadWriteLock from PyQt4.QtGui import QImage @@ -236,6 +237,12 @@ class ResultCache(SearchQueryParser): if query == _('today'): qd = now() field_count = 3 + elif query == _('yesterday'): + qd = now() - timedelta(1) + field_count = 3 + elif query == _('thismonth'): + qd = now() + field_count = 2 else: try: qd = parse_date(query) From 96c95279d1808bdc2d5f06ec29e1ad8cb37af42b Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Tue, 20 Apr 2010 17:50:41 +0100 Subject: [PATCH 08/10] Added the 'daysago' date search --- src/calibre/library/caches.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/calibre/library/caches.py b/src/calibre/library/caches.py index df6e78759f..4a38d386a6 100644 --- a/src/calibre/library/caches.py +++ b/src/calibre/library/caches.py @@ -243,6 +243,13 @@ class ResultCache(SearchQueryParser): elif query == _('thismonth'): qd = now() field_count = 2 + elif query.endswith(_('daysago')): + num = query[0:-len(_('daysago'))] + try: + qd = now() - timedelta(int(num)) + except: + raise ParseException(query, len(query), 'Number conversion error', self) + field_count = 3 else: try: qd = parse_date(query) From 1dd8c22239a98ebba8793c20af5296c215cb1ccb Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Fri, 23 Apr 2010 21:02:26 +0100 Subject: [PATCH 09/10] Fix search for fields with numeric values #5356 --- src/calibre/library/caches.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/calibre/library/caches.py b/src/calibre/library/caches.py index 4a38d386a6..eb456241ce 100644 --- a/src/calibre/library/caches.py +++ b/src/calibre/library/caches.py @@ -303,6 +303,7 @@ class ResultCache(SearchQueryParser): IS_CUSTOM = [] for x in range(len(self.FIELD_MAP)): # build a list containing '' the size of FIELD_MAP IS_CUSTOM.append('') + IS_CUSTOM[self.FIELD_MAP['rating']] = 'rating' # normal and custom ratings columns use the same code for x in self.custom_column_label_map: # add custom columns to MAP. Put the column's type into IS_CUSTOM if self.custom_column_label_map[x]['datatype'] != "datetime": MAP[x] = self.FIELD_MAP[self.custom_column_label_map[x]['num']] @@ -370,11 +371,6 @@ class ResultCache(SearchQueryParser): matches.add(item[0]) continue - if rating_query: - if (loc == MAP['rating'] and rating_query == int(item[loc])): - matches.add(item[0]) - continue - if IS_CUSTOM[loc] == 'rating': if rating_query and rating_query == int(item[loc]): matches.add(item[0]) From 9e78d87255e30d15bbc8ddccb07f0ceb3a98b044 Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Fri, 23 Apr 2010 21:28:14 +0100 Subject: [PATCH 10/10] Repair incorrect conflict repair --- src/calibre/gui2/library.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/calibre/gui2/library.py b/src/calibre/gui2/library.py index 707863d482..7cc264344c 100644 --- a/src/calibre/gui2/library.py +++ b/src/calibre/gui2/library.py @@ -11,7 +11,7 @@ from datetime import date from PyQt4.QtGui import QTableView, QAbstractItemView, QColor, \ QPainterPath, QLinearGradient, QBrush, \ QPen, QStyle, QPainter, QStyleOptionViewItemV4, \ - QImage, QMenu, \ + QIcon, QImage, QMenu, \ QStyledItemDelegate, QCompleter, QIntValidator, \ QPlainTextEdit, QDoubleValidator, QCheckBox, QMessageBox from PyQt4.QtCore import QAbstractTableModel, QVariant, Qt, pyqtSignal, \