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 @@
+
+
+
+
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 @@
+
+
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, \