From e61e40bdf0d8efd4374f659365ee2ec9503ae874 Mon Sep 17 00:00:00 2001
From: Charles Haley <>
Date: Sun, 18 Apr 2010 17:08:27 +0100
Subject: [PATCH 001/324] 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 002/324] 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 003/324] 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 004/324] 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 005/324] 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 006/324] 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 007/324] 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 008/324] 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 009/324] 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 010/324] 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, \
From b060a20f71bcf24e90b7609b4d2845cab25267de Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Sat, 1 May 2010 11:16:01 -0600
Subject: [PATCH 011/324] Minor cleanups
---
src/calibre/gui2/dialogs/comments_dialog.py | 1 +
src/calibre/gui2/library.py | 22 +++++++++------------
2 files changed, 10 insertions(+), 13 deletions(-)
diff --git a/src/calibre/gui2/dialogs/comments_dialog.py b/src/calibre/gui2/dialogs/comments_dialog.py
index f9806b44d1..8b4df07fbc 100644
--- a/src/calibre/gui2/dialogs/comments_dialog.py
+++ b/src/calibre/gui2/dialogs/comments_dialog.py
@@ -7,6 +7,7 @@ from PyQt4.Qt import QDialog
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)
diff --git a/src/calibre/gui2/library.py b/src/calibre/gui2/library.py
index f499c95e14..545cc1f5e1 100644
--- a/src/calibre/gui2/library.py
+++ b/src/calibre/gui2/library.py
@@ -162,17 +162,16 @@ class TagsDelegate(QStyledItemDelegate):
editor = TagsLineEdit(parent, self.db.all_tags())
else:
editor = TagsLineEdit(parent, sorted(list(self.db.all_custom(label=col))))
- return editor;
+ 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)
+ '''
+ Delegate for text/int/float data.
+ '''
+
def createEditor(self, parent, option, index):
m = index.model()
col = m.column_map[index.column()]
@@ -191,12 +190,9 @@ class CcTextDelegate(QStyledItemDelegate):
return editor
class CcCommentsDelegate(QStyledItemDelegate):
- def __init__(self, parent):
- '''
- Delegate for comments data.
- '''
- QStyledItemDelegate.__init__(self, parent)
- self.parent = parent
+ '''
+ Delegate for comments data.
+ '''
def createEditor(self, parent, option, index):
m = index.model()
@@ -211,7 +207,7 @@ class CcCommentsDelegate(QStyledItemDelegate):
return None
def setModelData(self, editor, model, index):
- model.setData(index, QVariant(editor.textbox.text()), Qt.EditRole)
+ model.setData(index, QVariant(editor.textbox.toPlainText()), Qt.EditRole)
class CcBoolDelegate(QStyledItemDelegate):
def __init__(self, parent):
From bb7edaa57b9aee6c9383dffa3bfae8431ef3b6cf Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Sat, 1 May 2010 11:36:38 -0600
Subject: [PATCH 012/324] Make SearchQueryParser accept a dynamic list of
locations
---
src/calibre/library/caches.py | 4 +++-
src/calibre/utils/search_query_parser.py | 13 ++++++++-----
src/calibre/web/feeds/recipes/model.py | 2 +-
3 files changed, 12 insertions(+), 7 deletions(-)
diff --git a/src/calibre/library/caches.py b/src/calibre/library/caches.py
index a31a8a846c..ee19f07644 100644
--- a/src/calibre/library/caches.py
+++ b/src/calibre/library/caches.py
@@ -155,7 +155,9 @@ class ResultCache(SearchQueryParser):
self._map = self._map_filtered = self._data = []
self.first_sort = True
self.search_restriction = ''
- SearchQueryParser.__init__(self, [c for c in cc_label_map])
+ SearchQueryParser.__init__(self,
+ locations=SearchQueryParser.DEFAULT_LOCATIONS +
+ [c for c in cc_label_map])
self.build_relop_dict()
def build_relop_dict(self):
diff --git a/src/calibre/utils/search_query_parser.py b/src/calibre/utils/search_query_parser.py
index 6768b66063..79324e6b8b 100644
--- a/src/calibre/utils/search_query_parser.py
+++ b/src/calibre/utils/search_query_parser.py
@@ -73,7 +73,7 @@ class SearchQueryParser(object):
When no operator is specified between two tokens, `and` is assumed.
Each token is a string of the form `location:query`. `location` is a string
- from :member:`LOCATIONS`. It is optional. If it is omitted, it is assumed to
+ from :member:`DEFAULT_LOCATIONS`. It is optional. If it is omitted, it is assumed to
be `all`. `query` is an arbitrary string that must not contain parentheses.
If it contains whitespace, it should be quoted by enclosing it in `"` marks.
@@ -86,7 +86,7 @@ class SearchQueryParser(object):
* `(author:Asimov or author:Hardy) and not tag:read` [search for unread books by Asimov or Hardy]
'''
- LOCATIONS = [
+ DEFAULT_LOCATIONS = [
'tag',
'title',
'author',
@@ -116,10 +116,13 @@ class SearchQueryParser(object):
failed.append(test[0])
return failed
- def __init__(self, custcols=[], test=False):
+ def __init__(self, locations=None, test=False):
+ if locations is None:
+ locations = self.DEFAULT_LOCATIONS
self._tests_failed = False
# Define a token
- standard_locations = map(lambda x : CaselessLiteral(x)+Suppress(':'), self.LOCATIONS+custcols)
+ standard_locations = map(lambda x : CaselessLiteral(x)+Suppress(':'),
+ locations)
location = NoMatch()
for l in standard_locations:
location |= l
@@ -228,7 +231,7 @@ class SearchQueryParser(object):
'''
Should return the set of matches for :param:'location` and :param:`query`.
- :param:`location` is one of the items in :member:`SearchQueryParser.LOCATIONS`.
+ :param:`location` is one of the items in :member:`SearchQueryParser.DEFAULT_LOCATIONS`.
:param:`query` is a string literal.
'''
return set([])
diff --git a/src/calibre/web/feeds/recipes/model.py b/src/calibre/web/feeds/recipes/model.py
index 55ff51d1e9..4eea0ce80c 100644
--- a/src/calibre/web/feeds/recipes/model.py
+++ b/src/calibre/web/feeds/recipes/model.py
@@ -121,7 +121,7 @@ class RecipeModel(QAbstractItemModel, SearchQueryParser):
def __init__(self, db, *args):
QAbstractItemModel.__init__(self, *args)
- SearchQueryParser.__init__(self)
+ SearchQueryParser.__init__(self, locations=['all'])
self.db = db
self.default_icon = QVariant(QIcon(I('news.svg')))
self.custom_icon = QVariant(QIcon(I('user_profile.svg')))
From 3f28c128ea0b0892609c0648128219832c92ad2a Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Sat, 1 May 2010 11:49:46 -0600
Subject: [PATCH 013/324] Fix Tag Browser sizing issues with long searches
---
src/calibre/gui2/main.ui | 27 ++++++++++++---------------
src/calibre/gui2/ui.py | 5 +++++
2 files changed, 17 insertions(+), 15 deletions(-)
diff --git a/src/calibre/gui2/main.ui b/src/calibre/gui2/main.ui
index 73cee4a061..4c9351909e 100644
--- a/src/calibre/gui2/main.ui
+++ b/src/calibre/gui2/main.ui
@@ -306,12 +306,6 @@
-
-
- 256
- 16777215
-
- true
@@ -354,12 +348,12 @@
-
- Manage user categories
+
+ Create, edit, and delete user categories
+
+
+ Manage &user categories
-
- Create, edit, and delete user categories
-
@@ -369,7 +363,10 @@
- Restrict display to:
+ &Restrict display to:
+
+
+ search_restriction
@@ -381,9 +378,9 @@
0
-
- Books display will be restricted to those matching the selected saved search
-
+
+ Books display will be restricted to those matching the selected saved search
+
diff --git a/src/calibre/gui2/ui.py b/src/calibre/gui2/ui.py
index d5f50b92b8..12f620532e 100644
--- a/src/calibre/gui2/ui.py
+++ b/src/calibre/gui2/ui.py
@@ -603,6 +603,11 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
self.library_view.model().count_changed()
+ ########################### Tags Browser ##############################
+ self.search_restriction.setSizeAdjustPolicy(self.search_restriction.AdjustToMinimumContentsLengthWithIcon)
+ self.search_restriction.setMinimumContentsLength(10)
+
+
########################### Cover Flow ################################
self.cover_flow = None
if CoverFlow is not None:
From 6574f349030bfd06b871d3aa9806625b5e5b41c9 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Sat, 1 May 2010 11:52:04 -0600
Subject: [PATCH 014/324] Rename LibraryDelegate
---
src/calibre/gui2/library.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/calibre/gui2/library.py b/src/calibre/gui2/library.py
index 545cc1f5e1..c7e2991010 100644
--- a/src/calibre/gui2/library.py
+++ b/src/calibre/gui2/library.py
@@ -29,7 +29,7 @@ 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(QStyledItemDelegate):
+class RatingDelegate(QStyledItemDelegate):
COLOR = QColor("blue")
SIZE = 16
PEN = QPen(COLOR, 1, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin)
@@ -889,7 +889,7 @@ 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)
From ebb5c43abc83fd82dadcd5f97f54c0f4df1d7e38 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Sat, 1 May 2010 12:12:23 -0600
Subject: [PATCH 015/324] Library icon tooltip now shows the path to the
currently displayed library
---
src/calibre/gui2/library.py | 2 ++
src/calibre/gui2/ui.py | 3 +++
src/calibre/gui2/widgets.py | 12 +++++++++++-
3 files changed, 16 insertions(+), 1 deletion(-)
diff --git a/src/calibre/gui2/library.py b/src/calibre/gui2/library.py
index c7e2991010..9f209a0066 100644
--- a/src/calibre/gui2/library.py
+++ b/src/calibre/gui2/library.py
@@ -243,6 +243,7 @@ class BooksModel(QAbstractTableModel):
about_to_be_sorted = pyqtSignal(object, name='aboutToBeSorted')
sorting_done = pyqtSignal(object, name='sortingDone')
+ database_changed = pyqtSignal(object, name='databaseChanged')
orig_headers = {
'title' : _("Title"),
@@ -300,6 +301,7 @@ class BooksModel(QAbstractTableModel):
self.db = db
self.custom_columns = self.db.custom_column_label_map
self.read_config()
+ self.database_changed.emit(db)
def refresh_ids(self, ids, current_row=-1):
rows = self.db.refresh_ids(ids)
diff --git a/src/calibre/gui2/ui.py b/src/calibre/gui2/ui.py
index 12f620532e..f8bce11114 100644
--- a/src/calibre/gui2/ui.py
+++ b/src/calibre/gui2/ui.py
@@ -602,6 +602,9 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
self.db_images.reset()
self.library_view.model().count_changed()
+ self.location_view.model().database_changed(self.library_view.model().db)
+ self.library_view.model().database_changed.connect(self.location_view.model().database_changed,
+ type=Qt.QueuedConnection)
########################### Tags Browser ##############################
self.search_restriction.setSizeAdjustPolicy(self.search_restriction.AdjustToMinimumContentsLengthWithIcon)
diff --git a/src/calibre/gui2/widgets.py b/src/calibre/gui2/widgets.py
index 586966d94f..c48ded1dc2 100644
--- a/src/calibre/gui2/widgets.py
+++ b/src/calibre/gui2/widgets.py
@@ -23,6 +23,7 @@ from calibre.ebooks import BOOK_EXTENSIONS
from calibre.ebooks.metadata.meta import metadata_from_filename
from calibre.utils.config import prefs, XMLConfig
from calibre.gui2.progress_indicator import ProgressIndicator as _ProgressIndicator
+from calibre.constants import filesystem_encoding
history = XMLConfig('history')
@@ -230,13 +231,22 @@ class LocationModel(QAbstractListModel):
self.free = [-1, -1, -1]
self.count = 0
self.highlight_row = 0
+ self.library_tooltip = _('Click to see the books available on your computer')
self.tooltips = [
- _('Click to see the books available on your computer'),
+ self.library_tooltip,
_('Click to see the books in the main memory of your reader'),
_('Click to see the books on storage card A in your reader'),
_('Click to see the books on storage card B in your reader')
]
+ def database_changed(self, db):
+ lp = db.library_path
+ if not isinstance(lp, unicode):
+ lp = lp.decode(filesystem_encoding, 'replace')
+ self.tooltips[0] = self.library_tooltip + '\n\n' + \
+ _('Books located at') + ' ' + lp
+ self.dataChanged.emit(self.index(0), self.index(0))
+
def rowCount(self, *args):
return 1 + len([i for i in self.free if i >= 0])
From 5a477915a981c29d000b22b70c486cc580c7d2b9 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Sat, 1 May 2010 12:44:48 -0600
Subject: [PATCH 016/324] Make Tag Browser visible by default and freely
resizable
---
src/calibre/gui2/main.ui | 79 +++++++++++++++++++-------------------
src/calibre/gui2/status.py | 15 --------
src/calibre/gui2/ui.py | 13 +++----
3 files changed, 46 insertions(+), 61 deletions(-)
diff --git a/src/calibre/gui2/main.ui b/src/calibre/gui2/main.ui
index 4c9351909e..2021e1bc88 100644
--- a/src/calibre/gui2/main.ui
+++ b/src/calibre/gui2/main.ui
@@ -301,8 +301,11 @@
-
-
+
+
+ Qt::Horizontal
+
+
@@ -363,7 +366,7 @@
- &Restrict display to:
+ &Restrict to:search_restriction
@@ -386,42 +389,40 @@
-
-
-
-
-
- 100
- 10
-
-
-
- true
-
-
- true
-
-
- false
-
-
- QAbstractItemView::DragDrop
-
-
- true
-
-
- QAbstractItemView::SelectRows
-
-
- false
-
-
- false
-
-
-
-
+
+
+
+
+ 100
+ 10
+
+
+
+ true
+
+
+ true
+
+
+ false
+
+
+ QAbstractItemView::DragDrop
+
+
+ true
+
+
+ QAbstractItemView::SelectRows
+
+
+ false
+
+
+ false
+
+
+
diff --git a/src/calibre/gui2/status.py b/src/calibre/gui2/status.py
index bdba768c5f..371efddb44 100644
--- a/src/calibre/gui2/status.py
+++ b/src/calibre/gui2/status.py
@@ -205,19 +205,6 @@ class CoverFlowButton(QToolButton):
self.setDisabled(True)
self.setToolTip(_('
Browsing books by their covers is disabled. Import of pictureflow module failed: ')+reason)
-class TagViewButton(QToolButton):
-
- def __init__(self, parent=None):
- QToolButton.__init__(self, parent)
- self.setIconSize(QSize(80, 80))
- self.setIcon(QIcon(I('tags.svg')))
- self.setToolTip(_('Click to browse books by tags'))
- self.setSizePolicy(QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Expanding))
- self.setCursor(Qt.PointingHandCursor)
- self.setCheckable(True)
- self.setChecked(False)
- self.setAutoRaise(True)
-
class StatusBar(QStatusBar):
@@ -227,9 +214,7 @@ class StatusBar(QStatusBar):
self.notifier = get_notifier(systray)
self.movie_button = MovieButton(jobs_dialog)
self.cover_flow_button = CoverFlowButton()
- self.tag_view_button = TagViewButton()
self.addPermanentWidget(self.cover_flow_button)
- self.addPermanentWidget(self.tag_view_button)
self.addPermanentWidget(self.movie_button)
self.book_info = BookInfoDisplay(self.clearMessage)
self.book_info.setAcceptDrops(True)
diff --git a/src/calibre/gui2/ui.py b/src/calibre/gui2/ui.py
index f8bce11114..6e0c5e333f 100644
--- a/src/calibre/gui2/ui.py
+++ b/src/calibre/gui2/ui.py
@@ -575,8 +575,6 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
self.connect(self.tags_view,
SIGNAL('tags_marked(PyQt_PyObject, PyQt_PyObject)'),
self.saved_search.clear_to_help)
- self.connect(self.status_bar.tag_view_button,
- SIGNAL('toggled(bool)'), self.toggle_tags_view)
self.connect(self.search,
SIGNAL('search(PyQt_PyObject, PyQt_PyObject)'),
self.tags_view.model().reinit)
@@ -674,8 +672,10 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
if self.cover_flow is not None and dynamic.get('cover_flow_visible', False):
self.status_bar.cover_flow_button.toggle()
- if dynamic.get('tag_view_visible', False):
- self.status_bar.tag_view_button.toggle()
+ tb_state = dynamic.get('tag_browser_state', None)
+ if tb_state is not None:
+ self.horizontal_splitter.restoreState(tb_state)
+ self.toggle_tags_view(True)
self._add_filesystem_book = Dispatcher(self.__add_filesystem_book)
v = self.library_view
@@ -2331,7 +2331,6 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
self.view_menu.actions()[1].setEnabled(True)
self.action_open_containing_folder.setEnabled(True)
self.action_sync.setEnabled(True)
- self.status_bar.tag_view_button.setEnabled(True)
self.status_bar.cover_flow_button.setEnabled(True)
for action in list(self.delete_menu.actions())[1:]:
action.setEnabled(True)
@@ -2342,7 +2341,6 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
self.view_menu.actions()[1].setEnabled(False)
self.action_open_containing_folder.setEnabled(False)
self.action_sync.setEnabled(False)
- self.status_bar.tag_view_button.setEnabled(False)
self.status_bar.cover_flow_button.setEnabled(False)
for action in list(self.delete_menu.actions())[1:]:
action.setEnabled(False)
@@ -2459,8 +2457,9 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
def write_settings(self):
config.set('main_window_geometry', self.saveGeometry())
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())
+ dynamic.set('tag_browser_state',
+ str(self.horizontal_splitter.saveState()))
self.library_view.write_settings()
if self.device_connected:
self.save_device_view_settings()
From 0bd19d1c2b930ce222cce903549b71c98dcc9bc1 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Sat, 1 May 2010 17:00:57 -0600
Subject: [PATCH 017/324] ...
---
src/calibre/gui2/library.py | 28 +++++++++++++++++++---------
1 file changed, 19 insertions(+), 9 deletions(-)
diff --git a/src/calibre/gui2/library.py b/src/calibre/gui2/library.py
index 9f209a0066..0b1cf461ae 100644
--- a/src/calibre/gui2/library.py
+++ b/src/calibre/gui2/library.py
@@ -718,15 +718,25 @@ class BooksModel(QAbstractTableModel):
return QVariant(self.db.data[r][idx])
self.dc = {
- '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']),
+ '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['publisher'], 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 = {}
From 98fa71af77e7b7fc8c4ce05eb49aba4d85405bca Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Sat, 1 May 2010 17:53:18 -0600
Subject: [PATCH 018/324] Make the book info display area also freely resizable
---
src/calibre/gui2/main.ui | 455 +++++++++++++++++++------------------
src/calibre/gui2/status.py | 53 +++--
src/calibre/gui2/ui.py | 11 +-
3 files changed, 274 insertions(+), 245 deletions(-)
diff --git a/src/calibre/gui2/main.ui b/src/calibre/gui2/main.ui
index 2021e1bc88..6cf7ed077a 100644
--- a/src/calibre/gui2/main.ui
+++ b/src/calibre/gui2/main.ui
@@ -28,8 +28,8 @@
:/images/library.png:/images/library.png
-
-
+
+
@@ -149,7 +149,7 @@
-
+ 6
@@ -287,110 +287,159 @@
-
-
+
+
-
- 100
+
+ 0100
-
- 0
+
+ Qt::Vertical
-
-
-
-
-
- Qt::Horizontal
-
-
-
-
-
-
- true
-
-
- true
-
-
- true
-
-
- true
-
-
-
-
-
-
- Sort by &popularity
-
-
-
-
-
-
-
-
- 0
-
-
-
- Match any
+
+
+
+ 100
+ 100
+
+
+
+ 0
+
+
+
+
+
+
+ Qt::Horizontal
+
+
+
+
+
+
+ true
+
+
+ true
+
+
+ true
+
+
+ true
+
+
+
+
+
+
+ Sort by &popularity
+
+
+
+
+
+
+
+
+ 0
-
-
-
- Match all
+
+
+ Match any
+
+
+
+
+ Match all
+
+
+
+
+
+
+
+ Create, edit, and delete user categories
-
-
-
-
-
-
- Create, edit, and delete user categories
-
-
- Manage &user categories
-
-
-
-
-
-
-
-
-
-
- &Restrict to:
-
-
- search_restriction
-
-
-
-
-
-
-
- 50
- 0
-
-
-
- Books display will be restricted to those matching the selected saved search
-
-
-
-
-
-
+
+ Manage &user categories
+
+
+
+
+
+
+
+
+
+
+ &Restrict to:
+
+
+ search_restriction
+
+
+
+
+
+
+
+ 50
+ 0
+
+
+
+ Books display will be restricted to those matching the selected saved search
+
+
+
+
+
+
+
+
+
+
+ 100
+ 10
+
+
+
+ true
+
+
+ true
+
+
+ false
+
+
+ QAbstractItemView::DragDrop
+
+
+ true
+
+
+ QAbstractItemView::SelectRows
+
+
+ false
+
+
+ false
+
+
-
+
+
+
+
+
+
+ 100
@@ -422,124 +471,87 @@
false
-
-
-
-
-
-
-
-
-
-
- 100
- 10
-
-
-
- true
-
-
- true
-
-
- false
-
-
- QAbstractItemView::DragDrop
-
-
- true
-
-
- QAbstractItemView::SelectRows
-
-
- false
-
-
- false
-
-
-
-
-
-
-
-
-
-
-
- 10
- 10
-
-
-
- true
-
-
- true
-
-
- false
-
-
- QAbstractItemView::DragDrop
-
-
- true
-
-
- QAbstractItemView::SelectRows
-
-
- false
-
-
- false
-
-
-
-
-
-
-
-
-
-
-
- 10
- 10
-
-
-
- true
-
-
- true
-
-
- false
-
-
- QAbstractItemView::DragDrop
-
-
- true
-
-
- QAbstractItemView::SelectRows
-
-
- false
-
-
- false
-
-
-
-
+
+
+
+
+
+
+
+
+
+ 10
+ 10
+
+
+
+ true
+
+
+ true
+
+
+ false
+
+
+ QAbstractItemView::DragDrop
+
+
+ true
+
+
+ QAbstractItemView::SelectRows
+
+
+ false
+
+
+ false
+
+
+
+
+
+
+
+
+
+
+
+ 10
+ 10
+
+
+
+ true
+
+
+ true
+
+
+ false
+
+
+ QAbstractItemView::DragDrop
+
+
+ true
+
+
+ QAbstractItemView::SelectRows
+
+
+ false
+
+
+ false
+
+
+
+
+
+
@@ -587,11 +599,6 @@
-
-
- true
-
-
@@ -813,6 +820,12 @@
QComboBoxcalibre.gui2.search_box
+
+ StatusBar
+ QWidget
+ calibre/gui2/status.h
+ 1
+
diff --git a/src/calibre/gui2/status.py b/src/calibre/gui2/status.py
index 371efddb44..d23384855d 100644
--- a/src/calibre/gui2/status.py
+++ b/src/calibre/gui2/status.py
@@ -4,7 +4,8 @@ import os, re, collections
from PyQt4.QtGui import QStatusBar, QLabel, QWidget, QHBoxLayout, QPixmap, \
QVBoxLayout, QSizePolicy, QToolButton, QIcon, QScrollArea, QFrame
-from PyQt4.QtCore import Qt, QSize, SIGNAL, QCoreApplication
+from PyQt4.QtCore import Qt, QSize, SIGNAL, QCoreApplication, pyqtSignal
+
from calibre import fit_image, preferred_encoding, isosx
from calibre.gui2 import qstring_to_unicode, config
from calibre.gui2.widgets import IMAGE_EXTENSIONS
@@ -48,35 +49,41 @@ class BookInfoDisplay(QWidget):
class BookCoverDisplay(QLabel):
- WIDTH = 81
- HEIGHT = 108
-
def __init__(self, coverpath=I('book.svg')):
QLabel.__init__(self)
- self.default_pixmap = QPixmap(coverpath).scaled(self.__class__.WIDTH,
- self.__class__.HEIGHT,
+ self.setMaximumWidth(81)
+ self.setMaximumHeight(108)
+ self.default_pixmap = QPixmap(coverpath).scaled(self.maximumWidth(),
+ self.maximumHeight(),
Qt.IgnoreAspectRatio,
Qt.SmoothTransformation)
self.setScaledContents(True)
- self.setMaximumHeight(self.HEIGHT)
+ self.statusbar_height = 120
self.setPixmap(self.default_pixmap)
-
- def setPixmap(self, pixmap):
- width, height = fit_image(pixmap.width(), pixmap.height(),
- self.WIDTH, self.HEIGHT)[1:]
+ def do_layout(self):
+ pixmap = self.pixmap()
+ pwidth, pheight = pixmap.width(), pixmap.height()
+ width, height = fit_image(pwidth, pheight,
+ pwidth, self.statusbar_height-12)[1:]
self.setMaximumHeight(height)
- self.setMaximumWidth(width)
- QLabel.setPixmap(self, pixmap)
-
try:
- aspect_ratio = pixmap.width()/float(pixmap.height())
+ aspect_ratio = pwidth/float(pheight)
except ZeroDivisionError:
aspect_ratio = 1
- self.setMaximumWidth(int(aspect_ratio*self.HEIGHT))
+ self.setMaximumWidth(int(aspect_ratio*self.maximumHeight()))
+
+ def setPixmap(self, pixmap):
+ QLabel.setPixmap(self, pixmap)
+ self.do_layout()
+
def sizeHint(self):
- return QSize(self.__class__.WIDTH, self.__class__.HEIGHT)
+ return QSize(self.maximumWidth(), self.maximumHeight())
+
+ def relayout(self, statusbar_size):
+ self.statusbar_height = statusbar_size.height()
+ self.do_layout()
class BookDataDisplay(QLabel):
@@ -208,8 +215,9 @@ class CoverFlowButton(QToolButton):
class StatusBar(QStatusBar):
- def __init__(self, jobs_dialog, systray=None):
- QStatusBar.__init__(self)
+ resized = pyqtSignal(object)
+
+ def initialize(self, jobs_dialog, systray=None):
self.systray = systray
self.notifier = get_notifier(systray)
self.movie_button = MovieButton(jobs_dialog)
@@ -220,7 +228,6 @@ class StatusBar(QStatusBar):
self.book_info.setAcceptDrops(True)
self.scroll_area = QScrollArea()
self.scroll_area.setWidget(self.book_info)
- self.scroll_area.setMaximumHeight(120)
self.scroll_area.setWidgetResizable(True)
self.connect(self.book_info, SIGNAL('show_book_info()'), self.show_book_info)
self.connect(self.book_info,
@@ -228,7 +235,11 @@ class StatusBar(QStatusBar):
self.files_dropped, Qt.QueuedConnection)
self.addWidget(self.scroll_area, 100)
self.setMinimumHeight(120)
- self.setMaximumHeight(120)
+ self.resized.connect(self.book_info.cover_display.relayout)
+ self.book_info.cover_display.relayout(self.size())
+
+ def resizeEvent(self, ev):
+ self.resized.emit(self.size())
def files_dropped(self, event, paths):
self.emit(SIGNAL('files_dropped(PyQt_PyObject, PyQt_PyObject)'), event,
diff --git a/src/calibre/gui2/ui.py b/src/calibre/gui2/ui.py
index 6e0c5e333f..40f5b5b57e 100644
--- a/src/calibre/gui2/ui.py
+++ b/src/calibre/gui2/ui.py
@@ -45,7 +45,6 @@ from calibre.gui2.update import CheckForUpdates
from calibre.gui2.main_window import MainWindow
from calibre.gui2.main_ui import Ui_MainWindow
from calibre.gui2.device import DeviceManager, DeviceMenu, DeviceGUI, Emailer
-from calibre.gui2.status import StatusBar
from calibre.gui2.jobs import JobManager, JobsDialog
from calibre.gui2.dialogs.metadata_single import MetadataSingleDialog
from calibre.gui2.dialogs.metadata_bulk import MetadataBulkDialog
@@ -263,8 +262,8 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
SIGNAL('update_found(PyQt_PyObject)'), self.update_found)
self.update_checker.start(2000)
####################### Status Bar #####################
- self.status_bar = StatusBar(self.jobs_dialog, self.system_tray_icon)
- self.setStatusBar(self.status_bar)
+ self.status_bar.initialize(self.jobs_dialog, self.system_tray_icon)
+ #self.setStatusBar(self.status_bar)
QObject.connect(self.job_manager, SIGNAL('job_added(int)'),
self.status_bar.job_added, Qt.QueuedConnection)
QObject.connect(self.job_manager, SIGNAL('job_done(int)'),
@@ -677,6 +676,10 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
self.horizontal_splitter.restoreState(tb_state)
self.toggle_tags_view(True)
+ bi_state = dynamic.get('book_info_state', None)
+ if bi_state is not None:
+ self.vertical_splitter.restoreState(bi_state)
+
self._add_filesystem_book = Dispatcher(self.__add_filesystem_book)
v = self.library_view
if v.model().rowCount(None) > 1:
@@ -2460,6 +2463,8 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
dynamic.set('cover_flow_visible', self.cover_flow.isVisible())
dynamic.set('tag_browser_state',
str(self.horizontal_splitter.saveState()))
+ dynamic.set('book_info_state',
+ str(self.vertical_splitter.saveState()))
self.library_view.write_settings()
if self.device_connected:
self.save_device_view_settings()
From 3ff7e6ecfe7a797b55d243fd1ea67c93a509399d Mon Sep 17 00:00:00 2001
From: Charles Haley <>
Date: Sun, 2 May 2010 01:15:37 +0100
Subject: [PATCH 019/324] Add custom columns to user defined categories. Fix
bug #5425 (saved searches enhancements)
---
src/calibre/gui2/dialogs/tag_categories.py | 13 ++++++++++++-
src/calibre/gui2/library.py | 2 +-
src/calibre/gui2/search_box.py | 3 ++-
src/calibre/gui2/tag_view.py | 15 ++++++++-------
src/calibre/library/database2.py | 9 +++++++--
5 files changed, 30 insertions(+), 12 deletions(-)
diff --git a/src/calibre/gui2/dialogs/tag_categories.py b/src/calibre/gui2/dialogs/tag_categories.py
index 9f55738000..ab2d8c52d1 100644
--- a/src/calibre/gui2/dialogs/tag_categories.py
+++ b/src/calibre/gui2/dialogs/tag_categories.py
@@ -22,7 +22,7 @@ class Item:
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']
+ category_labels_orig = ['', 'author', 'series', 'publisher', 'tag']
def __init__(self, window, db, index=None):
QDialog.__init__(self, window)
@@ -33,6 +33,9 @@ class TagCategories(QDialog, Ui_TagCategories):
self.index = index
self.applied_items = []
+ cc_icon = QIcon(I('column.svg'))
+
+ self.category_labels = self.category_labels_orig[:]
category_icons = [None, QIcon(I('user_profile.svg')), QIcon(I('series.svg')),
QIcon(I('publisher.png')), QIcon(I('tags.svg'))]
category_values = [None,
@@ -43,6 +46,14 @@ class TagCategories(QDialog, Ui_TagCategories):
]
category_names = ['', _('Authors'), _('Series'), _('Publishers'), _('Tags')]
+ cc_map = self.db.custom_column_label_map
+ for cc in cc_map:
+ if cc_map[cc]['datatype'] == 'text':
+ self.category_labels.append(cc)
+ category_icons.append(cc_icon)
+ category_values.append(lambda col=cc: self.db.all_custom(label=col))
+ category_names.append(cc_map[cc]['name'])
+
self.all_items = []
self.all_items_dict = {}
for idx,label in enumerate(self.category_labels):
diff --git a/src/calibre/gui2/library.py b/src/calibre/gui2/library.py
index 9f209a0066..417734b691 100644
--- a/src/calibre/gui2/library.py
+++ b/src/calibre/gui2/library.py
@@ -724,7 +724,7 @@ class BooksModel(QAbstractTableModel):
'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),
+ 'publisher': functools.partial(text_type, idx=self.db.FIELD_MAP['publisher'], 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']),
}
diff --git a/src/calibre/gui2/search_box.py b/src/calibre/gui2/search_box.py
index 5a4cf25966..4303881f02 100644
--- a/src/calibre/gui2/search_box.py
+++ b/src/calibre/gui2/search_box.py
@@ -279,9 +279,10 @@ class SavedSearchBox(QComboBox):
idx = self.currentIndex
if idx < 0:
return
+ ss = self.saved_searches.lookup(unicode(self.currentText()))
self.saved_searches.delete(unicode(self.currentText()))
self.clear_to_help()
- self.search_box.set_search_string('')
+ self.search_box.set_search_string(ss)
self.emit(SIGNAL('changed()'))
# SIGNALed from the main UI
diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py
index 89e3c37e25..c3088ba468 100644
--- a/src/calibre/gui2/tag_view.py
+++ b/src/calibre/gui2/tag_view.py
@@ -184,7 +184,7 @@ class TagTreeItem(object):
return QVariant('[%d] %s'%(self.tag.count, self.tag.name))
if role == Qt.DecorationRole:
return self.icon_state_map[self.tag.state]
- if role == Qt.ToolTipRole and self.tag.tooltip:
+ if role == Qt.ToolTipRole and self.tag.tooltip is not None:
return QVariant(self.tag.tooltip)
return NONE
@@ -248,11 +248,17 @@ class TagsModel(QAbstractItemModel):
self.categories.append(self.db.custom_column_label_map[c]['name'])
self.cat_icon_map.append(self.custcol_icon)
+ # Now the rest of the normal tag categories
+ 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.cat_icon_map.append(self.cat_icon_map_orig[i])
+
# 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:
+ for c in self.row_map:
taglist[c] = dict(map(lambda t:(t.name if c != 'author' else t.name.replace('|', ','), t), data[c]))
for c in self.user_categories:
@@ -269,11 +275,6 @@ class TagsModel(QAbstractItemModel):
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.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.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])
diff --git a/src/calibre/library/database2.py b/src/calibre/library/database2.py
index 6e1ef9308e..623a29159f 100644
--- a/src/calibre/library/database2.py
+++ b/src/calibre/library/database2.py
@@ -648,9 +648,14 @@ 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 category in icon_map:
+ icon = icon_map[category]
+ tooltip = ''
+ else:
+ icon = icon_map['*custom']
+ tooltip = self.custom_column_label_map[category]['name']
if ids is None: # no filtering
- categories[category] = [Tag(r[1], count=r[2], id=r[0], icon=icon)
+ categories[category] = [Tag(r[1], count=r[2], id=r[0], icon=icon, tooltip = tooltip)
for r in data]
else: # filter out zero-count tags
categories[category] = [Tag(r[1], count=r[2], id=r[0], icon=icon)
From be814cda275a51f4413d43df0e863b7d361759b6 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Sat, 1 May 2010 18:52:47 -0600
Subject: [PATCH 020/324] Highlight splitter when collapsed
---
src/calibre/gui2/main.ui | 10 ++++++++--
src/calibre/gui2/ui.py | 2 ++
src/calibre/gui2/widgets.py | 36 ++++++++++++++++++++++++++++++++++--
3 files changed, 44 insertions(+), 4 deletions(-)
diff --git a/src/calibre/gui2/main.ui b/src/calibre/gui2/main.ui
index 6cf7ed077a..68f2b8b6ba 100644
--- a/src/calibre/gui2/main.ui
+++ b/src/calibre/gui2/main.ui
@@ -288,7 +288,7 @@
-
+ 0
@@ -311,7 +311,7 @@
-
+ Qt::Horizontal
@@ -826,6 +826,12 @@
calibre/gui2/status.h1
+
+ Splitter
+ QSplitter
+ calibre/gui2/widgets.h
+ 1
+
diff --git a/src/calibre/gui2/ui.py b/src/calibre/gui2/ui.py
index 40f5b5b57e..849131b352 100644
--- a/src/calibre/gui2/ui.py
+++ b/src/calibre/gui2/ui.py
@@ -679,6 +679,8 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
bi_state = dynamic.get('book_info_state', None)
if bi_state is not None:
self.vertical_splitter.restoreState(bi_state)
+ self.horizontal_splitter.initialize()
+ self.vertical_splitter.initialize()
self._add_filesystem_book = Dispatcher(self.__add_filesystem_book)
v = self.library_view
diff --git a/src/calibre/gui2/widgets.py b/src/calibre/gui2/widgets.py
index c48ded1dc2..2259b77076 100644
--- a/src/calibre/gui2/widgets.py
+++ b/src/calibre/gui2/widgets.py
@@ -7,9 +7,9 @@ import re, os, traceback
from PyQt4.Qt import QListView, QIcon, QFont, QLabel, QListWidget, \
QListWidgetItem, QTextCharFormat, QApplication, \
QSyntaxHighlighter, QCursor, QColor, QWidget, \
- QPixmap, QPalette, QTimer, QDialog, \
+ QPixmap, QPalette, QTimer, QDialog, QSplitterHandle, \
QAbstractListModel, QVariant, Qt, SIGNAL, \
- QRegExp, QSettings, QSize, QModelIndex, \
+ QRegExp, QSettings, QSize, QModelIndex, QSplitter, \
QAbstractButton, QPainter, QLineEdit, QComboBox, \
QMenu, QStringListModel, QCompleter, QStringList
@@ -951,3 +951,35 @@ class PythonHighlighter(QSyntaxHighlighter):
QApplication.setOverrideCursor(QCursor(Qt.WaitCursor))
QSyntaxHighlighter.rehighlight(self)
QApplication.restoreOverrideCursor()
+
+class SplitterHandle(QSplitterHandle):
+
+ def __init__(self, orientation, splitter):
+ QSplitterHandle.__init__(self, orientation, splitter)
+ splitter.splitterMoved.connect(self.splitter_moved,
+ type=Qt.QueuedConnection)
+ self.highlight = False
+
+ def splitter_moved(self, *args):
+ oh = self.highlight
+ self.highlight = 0 in self.splitter().sizes()
+ if oh != self.highlight:
+ self.update()
+
+ def paintEvent(self, ev):
+ QSplitterHandle.paintEvent(self, ev)
+ if self.highlight:
+ painter = QPainter(self)
+ painter.setClipRect(ev.rect())
+ painter.fillRect(self.rect(), Qt.yellow)
+
+class Splitter(QSplitter):
+
+ def createHandle(self):
+ return SplitterHandle(self.orientation(), self)
+
+ def initialize(self):
+ for i in range(self.count()):
+ h = self.handle(i)
+ if h is not None:
+ h.splitter_moved()
From 82c88f16b60437e8d50e322f45775539b6b465c6 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Sat, 1 May 2010 19:34:01 -0600
Subject: [PATCH 021/324] Implement double click on splitters
---
src/calibre/gui2/widgets.py | 24 +++++++++++++++++++++++-
1 file changed, 23 insertions(+), 1 deletion(-)
diff --git a/src/calibre/gui2/widgets.py b/src/calibre/gui2/widgets.py
index 2259b77076..7ed296f584 100644
--- a/src/calibre/gui2/widgets.py
+++ b/src/calibre/gui2/widgets.py
@@ -8,7 +8,7 @@ from PyQt4.Qt import QListView, QIcon, QFont, QLabel, QListWidget, \
QListWidgetItem, QTextCharFormat, QApplication, \
QSyntaxHighlighter, QCursor, QColor, QWidget, \
QPixmap, QPalette, QTimer, QDialog, QSplitterHandle, \
- QAbstractListModel, QVariant, Qt, SIGNAL, \
+ QAbstractListModel, QVariant, Qt, SIGNAL, pyqtSignal, \
QRegExp, QSettings, QSize, QModelIndex, QSplitter, \
QAbstractButton, QPainter, QLineEdit, QComboBox, \
QMenu, QStringListModel, QCompleter, QStringList
@@ -954,10 +954,14 @@ class PythonHighlighter(QSyntaxHighlighter):
class SplitterHandle(QSplitterHandle):
+ double_clicked = pyqtSignal(object)
+
def __init__(self, orientation, splitter):
QSplitterHandle.__init__(self, orientation, splitter)
splitter.splitterMoved.connect(self.splitter_moved,
type=Qt.QueuedConnection)
+ self.double_clicked.connect(splitter.double_clicked,
+ type=Qt.QueuedConnection)
self.highlight = False
def splitter_moved(self, *args):
@@ -973,6 +977,9 @@ class SplitterHandle(QSplitterHandle):
painter.setClipRect(ev.rect())
painter.fillRect(self.rect(), Qt.yellow)
+ def mouseDoubleClickEvent(self, ev):
+ self.double_clicked.emit(self)
+
class Splitter(QSplitter):
def createHandle(self):
@@ -983,3 +990,18 @@ class Splitter(QSplitter):
h = self.handle(i)
if h is not None:
h.splitter_moved()
+
+ def double_clicked(self, handle):
+ sizes = list(self.sizes())
+ if 0 in sizes:
+ idx = sizes.index(0)
+ sizes[idx] = 80
+ else:
+ idx = 0 if self.orientation() == Qt.Horizontal else 1
+ sizes[idx] = 0
+ self.setSizes(sizes)
+ self.initialize()
+
+
+
+
From 7ac8f6f0e77806f8049817ed9f918d212130f348 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Sat, 1 May 2010 19:41:38 -0600
Subject: [PATCH 022/324] Use transparent background for no restriction search
count instead of white
---
src/calibre/gui2/ui.py | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/src/calibre/gui2/ui.py b/src/calibre/gui2/ui.py
index 849131b352..a36a7535ab 100644
--- a/src/calibre/gui2/ui.py
+++ b/src/calibre/gui2/ui.py
@@ -893,7 +893,8 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
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.setStyleSheet(
+ 'QLabel { background-color: transparent; }')
self.search_count.setText(t)
def search_box_cleared(self):
From f20255b98e1b8e7db4281a9731137b88af17e9c1 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Sun, 2 May 2010 13:23:35 -0600
Subject: [PATCH 023/324] Make filtered views permanent
---
src/calibre/library/caches.py | 2 +-
src/calibre/library/custom_columns.py | 17 +++-
src/calibre/library/database2.py | 116 ++++++++++---------------
src/calibre/library/schema_upgrades.py | 19 ++++
src/calibre/library/sqlite.py | 22 +++++
5 files changed, 106 insertions(+), 70 deletions(-)
diff --git a/src/calibre/library/caches.py b/src/calibre/library/caches.py
index ee19f07644..59c8085d4b 100644
--- a/src/calibre/library/caches.py
+++ b/src/calibre/library/caches.py
@@ -526,7 +526,7 @@ class ResultCache(SearchQueryParser):
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, return_matches = False):
+ def search(self, query, return_matches=False):
if not query or not query.strip():
q = self.search_restriction
else:
diff --git a/src/calibre/library/custom_columns.py b/src/calibre/library/custom_columns.py
index 6442db4a73..8a20e66a60 100644
--- a/src/calibre/library/custom_columns.py
+++ b/src/calibre/library/custom_columns.py
@@ -45,6 +45,7 @@ class CustomColumns(object):
DROP TRIGGER IF EXISTS fkc_insert_{table};
DROP TRIGGER IF EXISTS fkc_delete_{table};
DROP VIEW IF EXISTS tag_browser_{table};
+ DROP VIEW IF EXISTS tag_browser_filtered_{table};
DROP TABLE IF EXISTS {table};
DROP TABLE IF EXISTS {lt};
'''.format(table=table, lt=lt)
@@ -137,7 +138,14 @@ class CustomColumns(object):
'comments': lambda x,d: adapt_text(x, {'is_multiple':False}),
'datetime' : adapt_datetime,
'text':adapt_text
- }
+ }
+
+ # Create Tag Browser categories for custom columns
+ for i, v in self.custom_column_num_map.items():
+ if v['normalized']:
+ tn = 'custom_column_{0}'.format(i)
+ self.tag_browser_categories[tn] = [v['label'], 'value']
+
def get_custom(self, idx, label=None, num=None, index_is_id=False):
if label is not None:
@@ -396,6 +404,13 @@ class CustomColumns(object):
(SELECT COUNT(id) FROM {lt} WHERE value={table}.id) count
FROM {table};
+ CREATE VIEW tag_browser_filtered_{table} AS SELECT
+ id,
+ value,
+ (SELECT COUNT({lt}.id) FROM {lt} WHERE value={table}.id AND
+ books_list_filter(book)) count
+ FROM {table};
+
'''.format(lt=lt, table=table),
]
diff --git a/src/calibre/library/database2.py b/src/calibre/library/database2.py
index 623a29159f..fd4ca7aa6b 100644
--- a/src/calibre/library/database2.py
+++ b/src/calibre/library/database2.py
@@ -106,6 +106,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
self.conn = connect(self.dbpath, self.row_factory)
if self.user_version == 0:
self.initialize_database()
+ self.books_list_filter = self.conn.create_dynamic_filter('books_list_filter')
def __init__(self, library_path, row_factory=False):
if not os.path.exists(library_path):
@@ -118,6 +119,15 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
self.dbpath)
if isinstance(self.dbpath, unicode):
self.dbpath = self.dbpath.encode(filesystem_encoding)
+
+ self.tag_browser_categories = {
+ 'tags' : ['tag', 'name'],
+ 'series' : ['series', 'name'],
+ 'publishers': ['publisher', 'name'],
+ 'authors' : ['author', 'name'],
+ 'news' : ['news', 'name'],
+ }
+
self.connect()
self.is_case_sensitive = not iswindows and not isosx and \
not os.path.exists(self.dbpath.replace('metadata.db', 'MeTAdAtA.dB'))
@@ -125,6 +135,30 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
self.initialize_dynamic()
def initialize_dynamic(self):
+ 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.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()
+
+
CustomColumns.__init__(self)
template = '''\
(SELECT {query} FROM books_{table}_link AS link INNER JOIN
@@ -576,68 +610,10 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
return self.conn.get('SELECT script FROM feeds WHERE id=?', (id,), all=False)
def get_categories(self, sort_on_count=False, ids=None, icon_map=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)
+ self.books_list_filter.change([] if not ids else ids)
categories = {}
- for tn,cn in cat_cols.iteritems():
+ for tn, cn in self.tag_browser_categories.items():
if ids is None:
query = 'SELECT id, {0}, count FROM tag_browser_{1}'.format(cn[1], tn)
else:
@@ -648,12 +624,14 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
query += ' ORDER BY {0} ASC'.format(cn[1])
data = self.conn.get(query)
category = cn[0]
- if category in icon_map:
- icon = icon_map[category]
- tooltip = ''
- else:
- icon = icon_map['*custom']
- tooltip = self.custom_column_label_map[category]['name']
+ icon, tooltip = None, ''
+ if icon_map:
+ if category in icon_map:
+ icon = icon_map[category]
+ tooltip = ''
+ else:
+ icon = icon_map['*custom']
+ tooltip = self.custom_column_label_map[category]['name']
if ids is None: # no filtering
categories[category] = [Tag(r[1], count=r[2], id=r[0], icon=icon, tooltip = tooltip)
for r in data]
@@ -666,14 +644,15 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
if ids is not None:
count = self.conn.get('''SELECT COUNT(id)
FROM data
- WHERE format="%s" and books_list_filter(id)'''%fmt,
+ 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 count > 0:
+ categories['format'].append(Tag(fmt, count=count))
if sort_on_count:
categories['format'].sort(cmp=lambda x,y:cmp(x.count, y.count),
@@ -1475,6 +1454,7 @@ books_series_link feeds
conn = ndb.conn
conn.execute('create table temp_sequence(id INTEGER PRIMARY KEY AUTOINCREMENT)')
conn.commit()
+ conn.create_function(self.books_list_filter.name, 1, lambda x: 1)
conn.executescript(sql)
conn.commit()
conn.execute('pragma user_version=%d'%user_version)
diff --git a/src/calibre/library/schema_upgrades.py b/src/calibre/library/schema_upgrades.py
index b5733723b4..d4b4d3f9ad 100644
--- a/src/calibre/library/schema_upgrades.py
+++ b/src/calibre/library/schema_upgrades.py
@@ -269,3 +269,22 @@ class SchemaUpgrade(object):
CREATE INDEX IF NOT EXISTS formats_idx ON data (format);
''')
+ def upgrade_version_10(self):
+ 'Add restricted Tag Browser views'
+ def create_tag_browser_view(table_name, column_name, view_column_name):
+ script = ('''
+ DROP VIEW IF EXISTS tag_browser_filtered_{tn};
+ CREATE VIEW 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)
+
+ for tn, cn in self.tag_browser_categories.items():
+ if tn != 'news':
+ create_tag_browser_view(tn, cn[0], cn[1])
+
+
diff --git a/src/calibre/library/sqlite.py b/src/calibre/library/sqlite.py
index 9718cab872..755d8e64b4 100644
--- a/src/calibre/library/sqlite.py
+++ b/src/calibre/library/sqlite.py
@@ -36,6 +36,18 @@ def convert_bool(val):
sqlite.register_adapter(bool, lambda x : 1 if x else 0)
sqlite.register_converter('bool', convert_bool)
+class DynamicFilter(object):
+
+ def __init__(self, name):
+ self.name = name
+ self.ids = frozenset([])
+
+ def __call__(self, id_):
+ return int(id_ in self.ids)
+
+ def change(self, ids):
+ self.ids = frozenset(ids)
+
class Concatenate(object):
'''String concatenation aggregator for sqlite'''
@@ -119,6 +131,13 @@ class DBThread(Thread):
ok, res = True, '\n'.join(self.conn.iterdump())
except Exception, err:
ok, res = False, (err, traceback.format_exc())
+ elif func == 'create_dynamic_filter':
+ try:
+ f = DynamicFilter(args[0])
+ self.conn.create_function(args[0], 1, f)
+ ok, res = True, f
+ except Exception, err:
+ ok, res = False, (err, traceback.format_exc())
else:
func = getattr(self.conn, func)
try:
@@ -203,6 +222,9 @@ class ConnectionProxy(object):
@proxy
def dump(self): pass
+ @proxy
+ def create_dynamic_filter(self): pass
+
def connect(dbpath, row_factory=None):
conn = ConnectionProxy(DBThread(dbpath, row_factory))
conn.proxy.start()
From 547fd01be9155093ae131c9a2a97c9ad172565f4 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Sun, 2 May 2010 13:57:16 -0600
Subject: [PATCH 024/324] Remove qstring_to_unicode
---
src/calibre/gui2/__init__.py | 5 +----
src/calibre/gui2/dialogs/comicconf.py | 17 ++++++++---------
src/calibre/gui2/dialogs/config/__init__.py | 8 ++++----
src/calibre/gui2/dialogs/metadata_single.py | 12 ++++++------
src/calibre/gui2/dialogs/password.py | 20 ++++++++++----------
src/calibre/gui2/dialogs/search.py | 5 ++---
src/calibre/gui2/dialogs/tag_categories.py | 4 ++--
src/calibre/gui2/dialogs/tag_editor.py | 13 ++++++-------
src/calibre/gui2/dialogs/user_profiles.py | 12 ++++++------
src/calibre/gui2/library.py | 10 +++++-----
src/calibre/gui2/lrf_renderer/text.py | 7 +++----
src/calibre/gui2/status.py | 8 ++++----
src/calibre/gui2/viewer/bookmarkmanager.py | 4 ++--
src/calibre/gui2/widgets.py | 16 ++++++++--------
14 files changed, 67 insertions(+), 74 deletions(-)
diff --git a/src/calibre/gui2/__init__.py b/src/calibre/gui2/__init__.py
index c67c8e5ca4..2258457d45 100644
--- a/src/calibre/gui2/__init__.py
+++ b/src/calibre/gui2/__init__.py
@@ -218,9 +218,6 @@ def info_dialog(parent, title, msg, det_msg='', show=False):
return d
-def qstring_to_unicode(q):
- return unicode(q)
-
def human_readable(size):
""" Convert a size in bytes into a human readable form """
divisor, suffix = 1, "B"
@@ -380,7 +377,7 @@ class FileIconProvider(QFileIconProvider):
if fileinfo.isDir():
key = 'dir'
else:
- ext = qstring_to_unicode(fileinfo.completeSuffix()).lower()
+ ext = unicode(fileinfo.completeSuffix()).lower()
key = self.key_from_ext(ext)
return self.cached_icon(key)
diff --git a/src/calibre/gui2/dialogs/comicconf.py b/src/calibre/gui2/dialogs/comicconf.py
index a53865627f..ece2edb9df 100644
--- a/src/calibre/gui2/dialogs/comicconf.py
+++ b/src/calibre/gui2/dialogs/comicconf.py
@@ -6,18 +6,17 @@ __docformat__ = 'restructuredtext en'
''''''
from PyQt4.QtGui import QDialog
from calibre.gui2.dialogs.comicconf_ui import Ui_Dialog
-from calibre.gui2 import qstring_to_unicode
from calibre.ebooks.lrf.comic.convert_from import config, PROFILES
def set_conversion_defaults(window):
d = ComicConf(window)
d.exec_()
-
+
def get_bulk_conversion_options(window):
d = ComicConf(window, config_defaults=config(None).as_string())
if d.exec_() == QDialog.Accepted:
return d.config.parse()
-
+
def get_conversion_options(window, defaults, title, author):
if defaults is None:
defaults = config(None).as_string()
@@ -26,10 +25,10 @@ def get_conversion_options(window, defaults, title, author):
if d.exec_() == QDialog.Accepted:
return d.config.parse(), d.config.src
return None, None
-
+
class ComicConf(QDialog, Ui_Dialog):
-
+
def __init__(self, window, config_defaults=None, generic=True,
title=_('Set defaults for conversion of comics (CBR/CBZ files)')):
QDialog.__init__(self, window)
@@ -63,12 +62,12 @@ class ComicConf(QDialog, Ui_Dialog):
self.opt_despeckle.setChecked(opts.despeckle)
self.opt_wide.setChecked(opts.wide)
self.opt_right2left.setChecked(opts.right2left)
-
+
for opt in self.config.option_set.preferences:
g = getattr(self, 'opt_'+opt.name, False)
if opt.help and g:
g.setToolTip(opt.help)
-
+
def accept(self):
for opt in self.config.option_set.preferences:
g = getattr(self, 'opt_'+opt.name, False)
@@ -78,9 +77,9 @@ class ComicConf(QDialog, Ui_Dialog):
elif hasattr(g, 'value'):
val = g.value()
elif hasattr(g, 'itemText'):
- val = qstring_to_unicode(g.itemText(g.currentIndex()))
+ val = unicode(g.itemText(g.currentIndex()))
elif hasattr(g, 'text'):
- val = qstring_to_unicode(g.text())
+ val = unicode(g.text())
else:
raise Exception('Bad coding')
self.config.set(opt.name, val)
diff --git a/src/calibre/gui2/dialogs/config/__init__.py b/src/calibre/gui2/dialogs/config/__init__.py
index 72a5680bc8..dc7d6f8def 100644
--- a/src/calibre/gui2/dialogs/config/__init__.py
+++ b/src/calibre/gui2/dialogs/config/__init__.py
@@ -13,7 +13,7 @@ from PyQt4.Qt import QDialog, QListWidgetItem, QIcon, \
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, \
+from calibre.gui2 import choose_dir, error_dialog, config, \
ALL_COLUMNS, NONE, info_dialog, choose_files, \
warning_dialog, ResizableDialog
from calibre.utils.config import prefs
@@ -650,7 +650,7 @@ class ConfigDialog(ResizableDialog, Ui_Dialog):
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())
+ col = 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
@@ -759,12 +759,12 @@ class ConfigDialog(ResizableDialog, Ui_Dialog):
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())
+ path = 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
####### Now deal with changes to columns
- cols = [qstring_to_unicode(self.columns.item(i).data(Qt.UserRole).toString())\
+ 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]
if not cols:
diff --git a/src/calibre/gui2/dialogs/metadata_single.py b/src/calibre/gui2/dialogs/metadata_single.py
index b33b94def0..570143f520 100644
--- a/src/calibre/gui2/dialogs/metadata_single.py
+++ b/src/calibre/gui2/dialogs/metadata_single.py
@@ -14,7 +14,7 @@ import traceback
from PyQt4.Qt import SIGNAL, QObject, QCoreApplication, Qt, QTimer, QThread, QDate, \
QPixmap, QListWidgetItem, QDialog
-from calibre.gui2 import qstring_to_unicode, error_dialog, file_icon_provider, \
+from calibre.gui2 import error_dialog, file_icon_provider, \
choose_files, choose_images, ResizableDialog, \
warning_dialog
from calibre.gui2.dialogs.metadata_single_ui import Ui_MetadataSingleDialog
@@ -552,12 +552,12 @@ class MetadataSingleDialog(ResizableDialog, Ui_MetadataSingleDialog):
def fetch_metadata(self):
isbn = re.sub(r'[^0-9a-zA-Z]', '', unicode(self.isbn.text()))
- title = qstring_to_unicode(self.title.text())
+ title = unicode(self.title.text())
try:
author = string_to_authors(unicode(self.authors.text()))[0]
except:
author = ''
- publisher = qstring_to_unicode(self.publisher.currentText())
+ publisher = unicode(self.publisher.currentText())
if isbn or title or author or publisher:
d = FetchMetadata(self, isbn, title, author, publisher, self.timeout)
self._fetch_metadata_scope = d
@@ -623,12 +623,12 @@ class MetadataSingleDialog(ResizableDialog, Ui_MetadataSingleDialog):
def remove_unused_series(self):
self.db.remove_unused_series()
- idx = qstring_to_unicode(self.series.currentText())
+ idx = unicode(self.series.currentText())
self.series.clear()
self.initialize_series()
if idx:
for i in range(self.series.count()):
- if qstring_to_unicode(self.series.itemText(i)) == idx:
+ if unicode(self.series.itemText(i)) == idx:
self.series.setCurrentIndex(i)
break
@@ -648,7 +648,7 @@ class MetadataSingleDialog(ResizableDialog, Ui_MetadataSingleDialog):
self.db.set_isbn(self.id,
re.sub(r'[^0-9a-zA-Z]', '', unicode(self.isbn.text())), notify=False)
self.db.set_rating(self.id, 2*self.rating.value(), notify=False)
- self.db.set_publisher(self.id, qstring_to_unicode(self.publisher.currentText()), notify=False)
+ self.db.set_publisher(self.id, unicode(self.publisher.currentText()), notify=False)
self.db.set_tags(self.id, [x.strip() for x in
unicode(self.tags.text()).split(',')], notify=False)
self.db.set_series(self.id,
diff --git a/src/calibre/gui2/dialogs/password.py b/src/calibre/gui2/dialogs/password.py
index e95f1c53a3..0e58caf2d8 100644
--- a/src/calibre/gui2/dialogs/password.py
+++ b/src/calibre/gui2/dialogs/password.py
@@ -5,38 +5,38 @@ from PyQt4.QtGui import QDialog, QLineEdit
from PyQt4.QtCore import SIGNAL, Qt
from calibre.gui2.dialogs.password_ui import Ui_Dialog
-from calibre.gui2 import qstring_to_unicode, dynamic
+from calibre.gui2 import dynamic
class PasswordDialog(QDialog, Ui_Dialog):
-
+
def __init__(self, window, name, msg):
QDialog.__init__(self, window)
Ui_Dialog.__init__(self)
self.setupUi(self)
self.cfg_key = re.sub(r'[^0-9a-zA-Z]', '_', name)
-
+
un = dynamic[self.cfg_key+'__un']
pw = dynamic[self.cfg_key+'__pw']
if not un: un = ''
if not pw: pw = ''
self.gui_username.setText(un)
self.gui_password.setText(pw)
- self.sname = name
+ self.sname = name
self.msg.setText(msg)
self.connect(self.show_password, SIGNAL('stateChanged(int)'), self.toggle_password)
-
+
def toggle_password(self, state):
if state == Qt.Unchecked:
self.gui_password.setEchoMode(QLineEdit.Password)
else:
self.gui_password.setEchoMode(QLineEdit.Normal)
-
+
def username(self):
- return qstring_to_unicode(self.gui_username.text())
-
+ return unicode(self.gui_username.text())
+
def password(self):
- return qstring_to_unicode(self.gui_password.text())
-
+ return unicode(self.gui_password.text())
+
def accept(self):
dynamic.set(self.cfg_key+'__un', unicode(self.gui_username.text()))
dynamic.set(self.cfg_key+'__pw', unicode(self.gui_password.text()))
diff --git a/src/calibre/gui2/dialogs/search.py b/src/calibre/gui2/dialogs/search.py
index 75a97aec56..041e7ff1fc 100644
--- a/src/calibre/gui2/dialogs/search.py
+++ b/src/calibre/gui2/dialogs/search.py
@@ -4,7 +4,6 @@ import re
from PyQt4.QtGui import QDialog
from calibre.gui2.dialogs.search_ui import Ui_Dialog
-from calibre.gui2 import qstring_to_unicode
from calibre.library.caches import CONTAINS_MATCH, EQUALS_MATCH
class SearchDialog(QDialog, Ui_Dialog):
@@ -48,11 +47,11 @@ class SearchDialog(QDialog, Ui_Dialog):
return ans
def token(self):
- txt = qstring_to_unicode(self.text.text()).strip()
+ txt = unicode(self.text.text()).strip()
if txt:
if self.negate.isChecked():
txt = '!'+txt
- tok = self.FIELDS[qstring_to_unicode(self.field.currentText())]+txt
+ tok = self.FIELDS[unicode(self.field.currentText())]+txt
if re.search(r'\s', tok):
tok = '"%s"'%tok
return tok
diff --git a/src/calibre/gui2/dialogs/tag_categories.py b/src/calibre/gui2/dialogs/tag_categories.py
index ab2d8c52d1..869068a4f8 100644
--- a/src/calibre/gui2/dialogs/tag_categories.py
+++ b/src/calibre/gui2/dialogs/tag_categories.py
@@ -7,7 +7,7 @@ from PyQt4.QtCore import SIGNAL, Qt
from PyQt4.QtGui import QDialog, QIcon, QListWidgetItem
from calibre.gui2.dialogs.tag_categories_ui import Ui_TagCategories
-from calibre.gui2 import qstring_to_unicode, config
+from calibre.gui2 import config
from calibre.gui2.dialogs.confirm_delete import confirm
from calibre.constants import islinux
@@ -138,7 +138,7 @@ class TagCategories(QDialog, Ui_TagCategories):
def add_category(self):
self.save_category()
- cat_name = qstring_to_unicode(self.input_box.text()).strip()
+ cat_name = unicode(self.input_box.text()).strip()
if cat_name == '':
return False
if cat_name not in self.categories:
diff --git a/src/calibre/gui2/dialogs/tag_editor.py b/src/calibre/gui2/dialogs/tag_editor.py
index ca3f7176f1..9959e07f51 100644
--- a/src/calibre/gui2/dialogs/tag_editor.py
+++ b/src/calibre/gui2/dialogs/tag_editor.py
@@ -4,7 +4,6 @@ from PyQt4.QtCore import SIGNAL, Qt
from PyQt4.QtGui import QDialog
from calibre.gui2.dialogs.tag_editor_ui import Ui_TagEditor
-from calibre.gui2 import qstring_to_unicode
from calibre.gui2 import question_dialog, error_dialog
from calibre.constants import islinux
@@ -57,26 +56,26 @@ class TagEditor(QDialog, Ui_TagEditor):
error_dialog(self, 'No tags selected', 'You must select at least one tag from the list of Available tags.').exec_()
return
for item in items:
- if self.db.is_tag_used(qstring_to_unicode(item.text())):
+ if self.db.is_tag_used(unicode(item.text())):
confirms.append(item)
else:
deletes.append(item)
if confirms:
- ct = ', '.join([qstring_to_unicode(item.text()) for item in confirms])
+ ct = ', '.join([unicode(item.text()) for item in confirms])
if question_dialog(self, _('Are your sure?'),
'
'+_('The following tags are used by one or more books. '
'Are you certain you want to delete them?')+' '+ct):
deletes += confirms
for item in deletes:
- self.db.delete_tag(qstring_to_unicode(item.text()))
+ self.db.delete_tag(unicode(item.text()))
self.available_tags.takeItem(self.available_tags.row(item))
def apply_tags(self, item=None):
items = self.available_tags.selectedItems() if item is None else [item]
for item in items:
- tag = qstring_to_unicode(item.text())
+ tag = unicode(item.text())
self.tags.append(tag)
self.available_tags.takeItem(self.available_tags.row(item))
@@ -90,7 +89,7 @@ class TagEditor(QDialog, Ui_TagEditor):
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())
+ tag = unicode(item.text())
self.tags.remove(tag)
self.available_tags.addItem(tag)
@@ -102,7 +101,7 @@ class TagEditor(QDialog, Ui_TagEditor):
self.available_tags.sortItems()
def add_tag(self):
- tags = qstring_to_unicode(self.add_tag_input.text()).split(',')
+ tags = unicode(self.add_tag_input.text()).split(',')
for tag in tags:
tag = tag.strip()
for item in self.available_tags.findItems(tag, Qt.MatchFixedString):
diff --git a/src/calibre/gui2/dialogs/user_profiles.py b/src/calibre/gui2/dialogs/user_profiles.py
index bd332c2aa3..7b26fea0ae 100644
--- a/src/calibre/gui2/dialogs/user_profiles.py
+++ b/src/calibre/gui2/dialogs/user_profiles.py
@@ -9,7 +9,7 @@ from PyQt4.Qt import SIGNAL, QUrl, QDesktopServices, QAbstractListModel, Qt, \
from calibre.web.feeds.recipes import compile_recipe
from calibre.web.feeds.news import AutomaticNewsRecipe
from calibre.gui2.dialogs.user_profiles_ui import Ui_Dialog
-from calibre.gui2 import qstring_to_unicode, error_dialog, question_dialog, \
+from calibre.gui2 import error_dialog, question_dialog, \
choose_files, ResizableDialog, NONE
from calibre.gui2.widgets import PythonHighlighter
from calibre.ptempfile import PersistentTemporaryFile
@@ -162,19 +162,19 @@ class UserProfiles(ResizableDialog, Ui_Dialog):
else:
self.stacks.setCurrentIndex(1)
self.toggle_mode_button.setText(_('Switch to Basic mode'))
- if not qstring_to_unicode(self.source_code.toPlainText()).strip():
+ if not unicode(self.source_code.toPlainText()).strip():
src = self.options_to_profile()[0].replace('AutomaticNewsRecipe', 'BasicNewsRecipe')
self.source_code.setPlainText(src.replace('BasicUserRecipe', 'AdvancedUserRecipe'))
self.highlighter = PythonHighlighter(self.source_code.document())
def add_feed(self, *args):
- title = qstring_to_unicode(self.feed_title.text()).strip()
+ title = unicode(self.feed_title.text()).strip()
if not title:
error_dialog(self, _('Feed must have a title'),
_('The feed must have a title')).exec_()
return
- url = qstring_to_unicode(self.feed_url.text()).strip()
+ url = unicode(self.feed_url.text()).strip()
if not url:
error_dialog(self, _('Feed must have a URL'),
_('The feed %s must have a URL')%title).exec_()
@@ -190,7 +190,7 @@ class UserProfiles(ResizableDialog, Ui_Dialog):
def options_to_profile(self):
classname = 'BasicUserRecipe'+str(int(time.time()))
- title = qstring_to_unicode(self.profile_title.text()).strip()
+ title = unicode(self.profile_title.text()).strip()
if not title:
title = classname
self.profile_title.setText(title)
@@ -229,7 +229,7 @@ class %(classname)s(%(base_class)s):
return
profile = src
else:
- src = qstring_to_unicode(self.source_code.toPlainText())
+ src = unicode(self.source_code.toPlainText())
try:
title = compile_recipe(src).title
except Exception, err:
diff --git a/src/calibre/gui2/library.py b/src/calibre/gui2/library.py
index 0b1cf461ae..fa283d9032 100644
--- a/src/calibre/gui2/library.py
+++ b/src/calibre/gui2/library.py
@@ -19,7 +19,7 @@ from PyQt4.QtCore import QAbstractTableModel, QVariant, Qt, pyqtSignal, \
from calibre import strftime
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.gui2 import NONE, TableView, qstring_to_unicode, config, error_dialog
+from calibre.gui2 import NONE, TableView, 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
@@ -813,7 +813,7 @@ class BooksModel(QAbstractTableModel):
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 = unicode(value.toString()).strip()
val = val if val else None
if typ == 'bool':
val = value.toInt()[0] # tristate checkboxes put unknown in the middle
@@ -823,7 +823,7 @@ class BooksModel(QAbstractTableModel):
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()
+ val = unicode(value.toString()).strip()
if val is None or not val:
val = None
elif typ == 'datetime':
@@ -1034,7 +1034,7 @@ class BooksView(TableView):
and represent files with extensions.
'''
if event.mimeData().hasFormat('text/uri-list'):
- urls = [qstring_to_unicode(u.toLocalFile()) for u in event.mimeData().urls()]
+ urls = [unicode(u.toLocalFile()) for u in event.mimeData().urls()]
return [u for u in urls if os.path.splitext(u)[1] and os.access(u, os.R_OK)]
def dragEnterEvent(self, event):
@@ -1390,7 +1390,7 @@ class DeviceBooksModel(BooksModel):
row, col = index.row(), index.column()
if col in [2, 3]:
return False
- val = qstring_to_unicode(value.toString()).strip()
+ val = unicode(value.toString()).strip()
idx = self.map[row]
if col == 0:
self.db[idx].title = val
diff --git a/src/calibre/gui2/lrf_renderer/text.py b/src/calibre/gui2/lrf_renderer/text.py
index b6a2788353..0696cdd851 100644
--- a/src/calibre/gui2/lrf_renderer/text.py
+++ b/src/calibre/gui2/lrf_renderer/text.py
@@ -9,7 +9,6 @@ from PyQt4.QtGui import QFont, QColor, QPixmap, QGraphicsPixmapItem, \
from calibre.ebooks.lrf.fonts import FONT_MAP
from calibre.ebooks.BeautifulSoup import Tag
from calibre.ebooks.hyphenate import hyphenate_word
-from calibre.gui2 import qstring_to_unicode
WEIGHT_MAP = lambda wt : int((wt/10.)-1)
NULL = lambda a, b: a
@@ -527,12 +526,12 @@ class Line(QGraphicsItem):
while True:
word = words.next()
word.highlight = False
- if tokens[0] in qstring_to_unicode(word.string).lower():
+ if tokens[0] in unicode(word.string).lower():
matches.append(word)
for c in range(1, len(tokens)):
word = words.next()
print tokens[c], word.string
- if tokens[c] not in qstring_to_unicode(word.string):
+ if tokens[c] not in unicode(word.string):
return None
matches.append(word)
for w in matches:
@@ -556,7 +555,7 @@ class Line(QGraphicsItem):
if isinstance(tok, (int, float)):
s += ' '
elif isinstance(tok, Word):
- s += qstring_to_unicode(tok.string)
+ s += unicode(tok.string)
return s
def __str__(self):
diff --git a/src/calibre/gui2/status.py b/src/calibre/gui2/status.py
index d23384855d..a66b903a5e 100644
--- a/src/calibre/gui2/status.py
+++ b/src/calibre/gui2/status.py
@@ -7,7 +7,7 @@ from PyQt4.QtGui import QStatusBar, QLabel, QWidget, QHBoxLayout, QPixmap, \
from PyQt4.QtCore import Qt, QSize, SIGNAL, QCoreApplication, pyqtSignal
from calibre import fit_image, preferred_encoding, isosx
-from calibre.gui2 import qstring_to_unicode, config
+from calibre.gui2 import config
from calibre.gui2.widgets import IMAGE_EXTENSIONS
from calibre.gui2.progress_indicator import ProgressIndicator
from calibre.gui2.notify import get_notifier
@@ -260,7 +260,7 @@ class StatusBar(QStatusBar):
return ret
def jobs(self):
- src = qstring_to_unicode(self.movie_button.jobs.text())
+ src = unicode(self.movie_button.jobs.text())
return int(re.search(r'\d+', src).group())
def show_book_info(self):
@@ -268,7 +268,7 @@ class StatusBar(QStatusBar):
def job_added(self, nnum):
jobs = self.movie_button.jobs
- src = qstring_to_unicode(jobs.text())
+ src = unicode(jobs.text())
num = self.jobs()
text = src.replace(str(num), str(nnum))
jobs.setText(text)
@@ -276,7 +276,7 @@ class StatusBar(QStatusBar):
def job_done(self, nnum):
jobs = self.movie_button.jobs
- src = qstring_to_unicode(jobs.text())
+ src = unicode(jobs.text())
num = self.jobs()
text = src.replace(str(num), str(nnum))
jobs.setText(text)
diff --git a/src/calibre/gui2/viewer/bookmarkmanager.py b/src/calibre/gui2/viewer/bookmarkmanager.py
index 1c386a27e1..0c2be68022 100644
--- a/src/calibre/gui2/viewer/bookmarkmanager.py
+++ b/src/calibre/gui2/viewer/bookmarkmanager.py
@@ -9,7 +9,7 @@ from PyQt4.Qt import Qt, QDialog, QAbstractTableModel, QVariant, SIGNAL, \
QModelIndex, QInputDialog, QLineEdit, QFileDialog
from calibre.gui2.viewer.bookmarkmanager_ui import Ui_BookmarkManager
-from calibre.gui2 import NONE, qstring_to_unicode
+from calibre.gui2 import NONE
class BookmarkManager(QDialog, Ui_BookmarkManager):
def __init__(self, parent, bookmarks):
@@ -111,7 +111,7 @@ class BookmarkTableModel(QAbstractTableModel):
def setData(self, index, value, role):
if role == Qt.EditRole:
- self.bookmarks[index.row()] = (qstring_to_unicode(value.toString()).strip(), self.bookmarks[index.row()][1])
+ self.bookmarks[index.row()] = (unicode(value.toString()).strip(), self.bookmarks[index.row()][1])
self.emit(SIGNAL("dataChanged(QModelIndex, QModelIndex)"), index, index)
return True
return False
diff --git a/src/calibre/gui2/widgets.py b/src/calibre/gui2/widgets.py
index 7ed296f584..e39b06ea54 100644
--- a/src/calibre/gui2/widgets.py
+++ b/src/calibre/gui2/widgets.py
@@ -14,7 +14,7 @@ from PyQt4.Qt import QListView, QIcon, QFont, QLabel, QListWidget, \
QMenu, QStringListModel, QCompleter, QStringList
from calibre.gui2 import human_readable, NONE, TableView, \
- qstring_to_unicode, error_dialog, pixmap_to_data
+ error_dialog, pixmap_to_data
from calibre.gui2.dialogs.job_view_ui import Ui_Dialog
from calibre.gui2.filename_pattern_ui import Ui_Form
from calibre import fit_image
@@ -72,7 +72,7 @@ class FilenamePattern(QWidget, Ui_Form):
error_dialog(self, _('Invalid regular expression'),
_('Invalid regular expression: %s')%err).exec_()
return
- mi = metadata_from_filename(qstring_to_unicode(self.filename.text()), pat)
+ mi = metadata_from_filename(unicode(self.filename.text()), pat)
if mi.title:
self.title.setText(mi.title)
else:
@@ -96,7 +96,7 @@ class FilenamePattern(QWidget, Ui_Form):
def pattern(self):
- pat = qstring_to_unicode(self.re.text())
+ pat = unicode(self.re.text())
return re.compile(pat)
def commit(self):
@@ -158,7 +158,7 @@ class ImageView(QLabel):
and represent files with extensions.
'''
if event.mimeData().hasFormat('text/uri-list'):
- urls = [qstring_to_unicode(u.toLocalFile()) for u in event.mimeData().urls()]
+ urls = [unicode(u.toLocalFile()) for u in event.mimeData().urls()]
urls = [u for u in urls if os.path.splitext(u)[1] and os.access(u, os.R_OK)]
return [u for u in urls if os.path.splitext(u)[1][1:].lower() in cls.DROPABBLE_EXTENSIONS]
@@ -630,13 +630,13 @@ class TagsLineEdit(EnLineEdit):
self.completer.update_tags_cache(tags)
def text_changed(self, text):
- all_text = qstring_to_unicode(text)
+ all_text = unicode(text)
text = all_text[:self.cursorPosition()]
prefix = text.split(',')[-1].strip()
text_tags = []
for t in all_text.split(self.separator):
- t1 = qstring_to_unicode(t).strip()
+ t1 = unicode(t).strip()
if t1 != '':
text_tags.append(t)
text_tags = list(set(text_tags))
@@ -646,8 +646,8 @@ class TagsLineEdit(EnLineEdit):
def complete_text(self, text):
cursor_pos = self.cursorPosition()
- before_text = qstring_to_unicode(self.text())[:cursor_pos]
- after_text = qstring_to_unicode(self.text())[cursor_pos:]
+ before_text = unicode(self.text())[:cursor_pos]
+ after_text = unicode(self.text())[cursor_pos:]
prefix_len = len(before_text.split(',')[-1].strip())
self.setText('%s%s%s %s' % (before_text[:cursor_pos - prefix_len],
text, self.separator, after_text))
From daa81a787b746803bf613d76f47f1439ee03428b Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Sun, 2 May 2010 19:00:04 -0600
Subject: [PATCH 025/324] Clean up create custom column dialog and use calibre
message boxes
---
src/calibre/gui2/__init__.py | 5 +-
src/calibre/gui2/dialogs/config/__init__.py | 29 +--
.../dialogs/config/create_custom_column.py | 58 +++--
.../dialogs/config/create_custom_column.ui | 225 +++++++++---------
4 files changed, 167 insertions(+), 150 deletions(-)
diff --git a/src/calibre/gui2/__init__.py b/src/calibre/gui2/__init__.py
index 2258457d45..78b68a8bfb 100644
--- a/src/calibre/gui2/__init__.py
+++ b/src/calibre/gui2/__init__.py
@@ -193,11 +193,14 @@ def warning_dialog(parent, title, msg, det_msg='', show=False):
return d.exec_()
return d
-def error_dialog(parent, title, msg, det_msg='', show=False):
+def error_dialog(parent, title, msg, det_msg='', show=False,
+ show_copy_button=True):
d = MessageBox(QMessageBox.Critical, 'ERROR: '+title, msg, QMessageBox.Ok,
parent, det_msg)
d.setIconPixmap(QPixmap(I('dialog_error.svg')))
d.setEscapeButton(QMessageBox.Ok)
+ if not show_copy_button:
+ d.cb.setVisible(False)
if show:
return d.exec_()
return d
diff --git a/src/calibre/gui2/dialogs/config/__init__.py b/src/calibre/gui2/dialogs/config/__init__.py
index dc7d6f8def..b5d145dfc5 100644
--- a/src/calibre/gui2/dialogs/config/__init__.py
+++ b/src/calibre/gui2/dialogs/config/__init__.py
@@ -8,14 +8,14 @@ from PyQt4.Qt import QDialog, QListWidgetItem, QIcon, \
SIGNAL, QThread, Qt, QSize, QVariant, QUrl, \
QModelIndex, QAbstractTableModel, \
QDialogButtonBox, QTabWidget, QBrush, QLineEdit, \
- QProgressDialog, QMessageBox
+ QProgressDialog
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 choose_dir, error_dialog, config, \
ALL_COLUMNS, NONE, info_dialog, choose_files, \
- warning_dialog, ResizableDialog
+ warning_dialog, ResizableDialog, question_dialog
from calibre.utils.config import prefs
from calibre.ebooks import BOOK_EXTENSIONS
from calibre.ebooks.oeb.iterator import is_supported
@@ -648,16 +648,15 @@ class ConfigDialog(ResizableDialog, Ui_Dialog):
def del_custcol(self):
idx = self.columns.currentRow()
if idx < 0:
- self.messagebox(_('You must select a column to delete it'))
- return
+ return error_dialog(self, '', _('You must select a column to delete it'),
+ show=True)
col = 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 error_dialog(self, '',
+ _('The selected column is not a custom column'), show=True)
+ if not question_dialog(self, _('Are you sure?'),
+ _('Do you really want to delete column %s and all its data?') %
+ self.custcols[col]['name']):
return
self.columns.item(idx).setCheckState(False)
self.columns.takeItem(idx)
@@ -829,15 +828,13 @@ class ConfigDialog(ResizableDialog, Ui_Dialog):
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.'))
+ warning_dialog(self, _('Must restart'),
+ _('The changes you made require that Calibre be '
+ 'restarted. Please restart as soon as practical.'),
+ show=True)
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/create_custom_column.py b/src/calibre/gui2/dialogs/config/create_custom_column.py
index 89b31c41fa..03f8104223 100644
--- a/src/calibre/gui2/dialogs/config/create_custom_column.py
+++ b/src/calibre/gui2/dialogs/config/create_custom_column.py
@@ -3,25 +3,45 @@ __copyright__ = '2010, Kovid Goyal '
'''Dialog to create a new custom column'''
+from functools import partial
+
from PyQt4.QtCore import SIGNAL
from PyQt4.Qt import QDialog, Qt, QListWidgetItem, QVariant
+
from calibre.gui2.dialogs.config.create_custom_column_ui import Ui_QCreateCustomColumn
+from calibre.gui2 import error_dialog
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},
+ 0:{'datatype':'text',
+ 'text':_('Text, column shown in the tag browser'),
+ 'is_multiple':False},
+ 1:{'datatype':'*text',
+ 'text':_('Comma separated text, like tags, shown in the tag browser'),
+ 'is_multiple':True},
+ 2:{'datatype':'comments',
+ 'text':_('Long text, like comments, not shown in the tag browser'),
+ 'is_multiple':False},
+ 3:{'datatype':'datetime',
+ 'text':_('Date'), 'is_multiple':False},
+ 4:{'datatype':'float',
+ 'text':_('Floating point numbers'), 'is_multiple':False},
+ 5:{'datatype':'int',
+ 'text':_('Integers'), 'is_multiple':False},
+ 6:{'datatype':'rating',
+ 'text':_('Ratings, shown with 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.simple_error = partial(error_dialog, self, show=True,
+ show_copy_button=False)
self.connect(self.button_box, SIGNAL("accepted()"), self.accept)
self.connect(self.button_box, SIGNAL("rejected()"), self.reject)
self.parent = parent
@@ -35,12 +55,11 @@ class CreateCustomColumn(QDialog, Ui_QCreateCustomColumn):
return
idx = parent.columns.currentRow()
if idx < 0:
- self.parent.messagebox(_('No column has been selected'))
- return
+ return self.simple_error(_('No column selected'),
+ _('No column has been selected'))
col = 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
+ return self.simple_error('', _('Selected column is not a user-defined column'))
c = parent.custcols[col]
self.column_name_box.setText(c['label'])
@@ -62,11 +81,9 @@ class CreateCustomColumn(QDialog, Ui_QCreateCustomColumn):
else:
is_multiple = False
if not col:
- self.parent.messagebox(_('No lookup name was provided'))
- return
+ return self.simple_error('', _('No lookup name was provided'))
if not col_heading:
- self.parent.messagebox(_('No column heading was provided'))
- return
+ return self.simple_error('', _('No column heading was provided'))
bad_col = False
if col in self.parent.custcols:
if not self.editing_col or self.parent.custcols[col]['num'] != self.orig_column_number:
@@ -74,8 +91,7 @@ class CreateCustomColumn(QDialog, Ui_QCreateCustomColumn):
if col in self.standard_colnames:
bad_col = True
if bad_col:
- self.parent.messagebox(_('The lookup name %s is already used')%col)
- return
+ return self.simple_error('', _('The lookup name %s is already used')%col)
bad_head = False
for t in self.parent.custcols:
if self.parent.custcols[t]['name'] == col_heading:
@@ -85,11 +101,9 @@ class CreateCustomColumn(QDialog, Ui_QCreateCustomColumn):
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
+ return self.simple_error('', _('The heading %s is already used')%col_heading)
if ':' in col or ' ' in col or col.lower() != col:
- self.parent.messagebox(_('The lookup name must be lower case and cannot contain ":"s or spaces'))
- return
+ return self.simple_error('', _('The lookup name must be lower case and cannot contain ":"s or spaces'))
if not self.editing_col:
self.parent.custcols[col] = {
diff --git a/src/calibre/gui2/dialogs/config/create_custom_column.ui b/src/calibre/gui2/dialogs/config/create_custom_column.ui
index 9ba9c1d547..3e0556b815 100644
--- a/src/calibre/gui2/dialogs/config/create_custom_column.ui
+++ b/src/calibre/gui2/dialogs/config/create_custom_column.ui
@@ -9,8 +9,8 @@
00
- 391
- 157
+ 528
+ 165
@@ -20,116 +20,119 @@
- Create Tag-based Column
+ Create a custom 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
-
-
-
-
-
+
+
+
+
+ QLayout::SetDefaultConstraint
+
+
+ 5
+
+
+
+
+
+
+ &Lookup name
+
+
+ column_name_box
+
+
+
+
+
+
+ Column &heading
+
+
+ column_heading_box
+
+
+
+
+
+
+
+ 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 the tag browser
+
+
+
+
+
+
+ Column &type
+
+
+ column_type_box
+
+
+
+
+
+
+
+ 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
From 4b6a1b9f9fdd9b5231f0d30807f966fcc1005178 Mon Sep 17 00:00:00 2001
From: Charles Haley <>
Date: Mon, 3 May 2010 06:22:18 +0100
Subject: [PATCH 026/324] Fix three problems 1) user-defined tag categories
broke when the custom column behind an item was removed 2) get_categories was
called with an incorrect search restriction string. The 'search:' was
missing. 3) changing from a restriction that matched nothing to one that
matched some books incorrectly rebuilt the tags list (order of signals
problem)
---
src/calibre/gui2/dialogs/tag_categories.py | 16 ++++++++++------
src/calibre/gui2/library.py | 2 +-
src/calibre/gui2/tag_view.py | 6 +++---
src/calibre/library/caches.py | 2 +-
4 files changed, 15 insertions(+), 11 deletions(-)
diff --git a/src/calibre/gui2/dialogs/tag_categories.py b/src/calibre/gui2/dialogs/tag_categories.py
index 869068a4f8..e7884bfe75 100644
--- a/src/calibre/gui2/dialogs/tag_categories.py
+++ b/src/calibre/gui2/dialogs/tag_categories.py
@@ -71,12 +71,16 @@ class TagCategories(QDialog, Ui_TagCategories):
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
+ if l[1] in self.category_labels:
+ 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
+ else:
+ # remove any references to a category that no longer exists
+ del self.categories[cat][item]
self.all_items_sorted = sorted(self.all_items, cmp=lambda x,y: cmp(x.name.lower(), y.name.lower()))
self.display_filtered_categories(0)
diff --git a/src/calibre/gui2/library.py b/src/calibre/gui2/library.py
index fa283d9032..c1a8057844 100644
--- a/src/calibre/gui2/library.py
+++ b/src/calibre/gui2/library.py
@@ -1077,7 +1077,7 @@ class BooksView(TableView):
def connect_to_restriction_set(self, tv):
QObject.connect(tv, SIGNAL('restriction_set(PyQt_PyObject)'),
- self._model.set_search_restriction)
+ self._model.set_search_restriction) # must be synchronous (not queued)
def connect_to_book_display(self, bd):
QObject.connect(self._model, SIGNAL('new_bookdisplay_data(PyQt_PyObject)'),
diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py
index c3088ba468..5cce38bd4f 100644
--- a/src/calibre/gui2/tag_view.py
+++ b/src/calibre/gui2/tag_view.py
@@ -64,10 +64,10 @@ class TagsView(QTreeView):
if len(s) == 0:
self.search_restriction = ''
else:
- self.search_restriction = unicode(s)
+ self.search_restriction = 'search:"%s"' % unicode(s).strip()
self.model().set_search_restriction(self.search_restriction)
- self.recount()
self.emit(SIGNAL('restriction_set(PyQt_PyObject)'), self.search_restriction)
+ self.recount() # Must happen after the emission of the restriction_set signal
self.emit(SIGNAL('tags_marked(PyQt_PyObject, PyQt_PyObject)'),
self._model.tokens(), self.match_all)
@@ -264,7 +264,7 @@ class TagsModel(QAbstractItemModel):
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
+ if label in taglist and 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
if config['sort_by_popularity']:
diff --git a/src/calibre/library/caches.py b/src/calibre/library/caches.py
index 59c8085d4b..dfa39ad869 100644
--- a/src/calibre/library/caches.py
+++ b/src/calibre/library/caches.py
@@ -543,4 +543,4 @@ class ResultCache(SearchQueryParser):
return []
def set_search_restriction(self, s):
- self.search_restriction = '' if not s else 'search:"%s"' % (s.strip())
+ self.search_restriction = s
From 4dc5053429f52d37089f408667369dfe95206d11 Mon Sep 17 00:00:00 2001
From: Charles Haley <>
Date: Mon, 3 May 2010 06:51:50 +0100
Subject: [PATCH 027/324] Make tooltips on custom columns work when search
restriction is set
---
src/calibre/library/database2.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/calibre/library/database2.py b/src/calibre/library/database2.py
index fd4ca7aa6b..1033841626 100644
--- a/src/calibre/library/database2.py
+++ b/src/calibre/library/database2.py
@@ -636,7 +636,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
categories[category] = [Tag(r[1], count=r[2], id=r[0], icon=icon, tooltip = tooltip)
for r in data]
else: # filter out zero-count tags
- categories[category] = [Tag(r[1], count=r[2], id=r[0], icon=icon)
+ categories[category] = [Tag(r[1], count=r[2], id=r[0], icon=icon, tooltip = tooltip)
for r in data if r[2] > 0]
categories['format'] = []
for fmt in self.conn.get('SELECT DISTINCT format FROM data'):
From 5fe3812424393e911b46b487c019260589853b20 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Mon, 3 May 2010 09:47:38 -0600
Subject: [PATCH 028/324] ...
---
src/calibre/library/database2.py | 6 +-----
1 file changed, 1 insertion(+), 5 deletions(-)
diff --git a/src/calibre/library/database2.py b/src/calibre/library/database2.py
index e024638ae3..729c531897 100644
--- a/src/calibre/library/database2.py
+++ b/src/calibre/library/database2.py
@@ -632,11 +632,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
else:
icon = icon_map['*custom']
tooltip = self.custom_column_label_map[category]['name']
- if ids is None: # no filtering
- categories[category] = [Tag(r[1], count=r[2], id=r[0], icon=icon, tooltip = tooltip)
- for r in data if r[2] > 0]
- else: # filter out zero-count tags
- categories[category] = [Tag(r[1], count=r[2], id=r[0], icon=icon, tooltip = tooltip)
+ categories[category] = [Tag(r[1], count=r[2], id=r[0], icon=icon, tooltip = tooltip)
for r in data if r[2] > 0]
categories['format'] = []
for fmt in self.conn.get('SELECT DISTINCT format FROM data'):
From 97c0a0e18c6b7fe4a6570b2f9064ecf21a2e0b3b Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Mon, 3 May 2010 11:46:13 -0600
Subject: [PATCH 029/324] Improve look of QLabel showing search count when
restricted
---
src/calibre/gui2/ui.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/calibre/gui2/ui.py b/src/calibre/gui2/ui.py
index a36a7535ab..441ed18a9b 100644
--- a/src/calibre/gui2/ui.py
+++ b/src/calibre/gui2/ui.py
@@ -887,7 +887,7 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
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; }')
+ self.search_count.setStyleSheet('QLabel { border-radius: 8px; background-color: yellow; }')
else: # No restriction
if all == 'yes':
t = _("(all books)")
From 8f93da127fdd256ffe00c362e50fd7344cf288a1 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Mon, 3 May 2010 11:49:55 -0600
Subject: [PATCH 030/324] Show counts for each category in the Tag Browser
---
src/calibre/gui2/tag_view.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py
index 5cce38bd4f..2671b16580 100644
--- a/src/calibre/gui2/tag_view.py
+++ b/src/calibre/gui2/tag_view.py
@@ -169,7 +169,7 @@ class TagTreeItem(object):
def category_data(self, role):
if role == Qt.DisplayRole:
- return self.name
+ return QVariant(self.py_name + ' [%d]'%len(self.children))
if role == Qt.DecorationRole:
return self.icon
if role == Qt.FontRole:
From 1718859647491e8ea226cb501d4da003f4e62358 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Mon, 3 May 2010 20:00:39 -0600
Subject: [PATCH 031/324] Add a sidebar to the main GUI to control the optional
views (tag browser, book info and cover browser)
---
src/calibre/gui2/main.ui | 528 +++++++++++++++++++-----------------
src/calibre/gui2/sidebar.py | 235 ++++++++++++++++
src/calibre/gui2/status.py | 149 +++-------
src/calibre/gui2/ui.py | 94 ++-----
src/calibre/gui2/widgets.py | 25 +-
5 files changed, 589 insertions(+), 442 deletions(-)
create mode 100644 src/calibre/gui2/sidebar.py
diff --git a/src/calibre/gui2/main.ui b/src/calibre/gui2/main.ui
index 68f2b8b6ba..8dcb0e6d75 100644
--- a/src/calibre/gui2/main.ui
+++ b/src/calibre/gui2/main.ui
@@ -150,7 +150,7 @@
-
+ 6
@@ -288,271 +288,285 @@
-
-
-
- 0
- 100
-
-
-
- Qt::Vertical
-
-
-
-
- 100
- 100
-
-
-
- 0
-
-
-
-
-
-
- Qt::Horizontal
-
-
-
-
-
-
- true
-
-
- true
-
-
- true
-
-
- true
-
-
-
-
-
-
- Sort by &popularity
-
-
-
-
-
+
+
+
+
+
+ 0
+ 100
+
+
+
+ Qt::Vertical
+
+
+
+
+ 100
+ 100
+
+
+
+ 0
+
+
+
+
+
+
+ Qt::Horizontal
+
+
+
-
-
- 0
+
+
+ true
+
+ true
+
+
+ true
+
+
+ true
+
+
+
+
+
+
+ Sort by &popularity
+
+
+
+
+
-
- Match any
-
+
+
+ 0
+
+
+
+ Match any
+
+
+
+
+ Match all
+
+
+
-
- Match all
-
+
+
+ Create, edit, and delete user categories
+
+
+ Manage &user categories
+
+
-
+
-
-
- Create, edit, and delete user categories
-
-
- Manage &user categories
-
-
+
+
+
+
+ &Restrict to:
+
+
+ search_restriction
+
+
+
+
+
+
+
+ 50
+ 0
+
+
+
+ Books display will be restricted to those matching the selected saved search
+
+
+
+
-
-
-
-
-
-
- &Restrict to:
-
-
- search_restriction
-
-
-
-
-
-
-
- 50
- 0
-
-
-
- Books display will be restricted to those matching the selected saved search
-
-
-
-
-
-
-
-
-
-
- 100
- 10
-
-
-
- true
-
-
- true
-
-
- false
-
-
- QAbstractItemView::DragDrop
-
-
- true
-
-
- QAbstractItemView::SelectRows
-
-
- false
-
-
- false
-
-
-
-
-
+
+
+
+
+ 100
+ 10
+
+
+
+ true
+
+
+ true
+
+
+ false
+
+
+ QAbstractItemView::DragDrop
+
+
+ true
+
+
+ QAbstractItemView::SelectRows
+
+
+ false
+
+
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+ 100
+ 10
+
+
+
+ true
+
+
+ true
+
+
+ false
+
+
+ QAbstractItemView::DragDrop
+
+
+ true
+
+
+ QAbstractItemView::SelectRows
+
+
+ false
+
+
+ false
+
+
+
+
+
+
+
+
+
+
+
+ 10
+ 10
+
+
+
+ true
+
+
+ true
+
+
+ false
+
+
+ QAbstractItemView::DragDrop
+
+
+ true
+
+
+ QAbstractItemView::SelectRows
+
+
+ false
+
+
+ false
+
+
+
+
+
+
+
+
+
+
+
+ 10
+ 10
+
+
+
+ true
+
+
+ true
+
+
+ false
+
+
+ QAbstractItemView::DragDrop
+
+
+ true
+
+
+ QAbstractItemView::SelectRows
+
+
+ false
+
+
+ false
+
+
+
+
+
+
+
-
-
-
-
-
-
- 100
- 10
-
-
-
- true
-
-
- true
-
-
- false
-
-
- QAbstractItemView::DragDrop
-
-
- true
-
-
- QAbstractItemView::SelectRows
-
-
- false
-
-
- false
-
-
-
-
+
+
+
+
+
+ 0
+ 0
+
+
-
-
-
-
-
-
- 10
- 10
-
-
-
- true
-
-
- true
-
-
- false
-
-
- QAbstractItemView::DragDrop
-
-
- true
-
-
- QAbstractItemView::SelectRows
-
-
- false
-
-
- false
-
-
-
-
-
-
-
-
-
-
-
- 10
- 10
-
-
-
- true
-
-
- true
-
-
- false
-
-
- QAbstractItemView::DragDrop
-
-
- true
-
-
- QAbstractItemView::SelectRows
-
-
- false
-
-
- false
-
-
-
-
-
-
-
-
+
+
@@ -832,6 +846,12 @@
calibre/gui2/widgets.h1
+
+ SideBar
+ QWidget
+ calibre/gui2/sidebar.h
+ 1
+
diff --git a/src/calibre/gui2/sidebar.py b/src/calibre/gui2/sidebar.py
new file mode 100644
index 0000000000..375aafbaa2
--- /dev/null
+++ b/src/calibre/gui2/sidebar.py
@@ -0,0 +1,235 @@
+#!/usr/bin/env python
+# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai
+
+__license__ = 'GPL v3'
+__copyright__ = '2010, Kovid Goyal '
+__docformat__ = 'restructuredtext en'
+
+import re
+from functools import partial
+
+from PyQt4.Qt import QToolBar, Qt, QIcon, QSizePolicy, QWidget, \
+ QFrame, QVBoxLayout, QLabel, QSize, QCoreApplication, QToolButton
+
+from calibre.gui2.progress_indicator import ProgressIndicator
+from calibre.gui2 import dynamic
+
+class JobsButton(QFrame):
+
+ def __init__(self, parent):
+ QFrame.__init__(self, parent)
+ self.setLayout(QVBoxLayout())
+ self.pi = ProgressIndicator(self)
+ self.layout().addWidget(self.pi)
+ self.jobs = QLabel(''+_('Jobs:')+' 0')
+ self.jobs.setAlignment(Qt.AlignHCenter|Qt.AlignBottom)
+ self.layout().addWidget(self.jobs)
+ self.layout().setAlignment(self.jobs, Qt.AlignHCenter)
+ self.jobs.setMargin(0)
+ self.layout().setMargin(0)
+ self.jobs.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum)
+ self.setCursor(Qt.PointingHandCursor)
+ self.setToolTip(_('Click to see list of active jobs.'))
+
+ def initialize(self, jobs_dialog):
+ self.jobs_dialog = jobs_dialog
+ self.jobs_dialog.jobs_view.restore_column_widths()
+
+ def mouseReleaseEvent(self, event):
+ if self.jobs_dialog.isVisible():
+ self.jobs_dialog.jobs_view.write_settings()
+ self.jobs_dialog.hide()
+ else:
+ self.jobs_dialog.jobs_view.read_settings()
+ self.jobs_dialog.show()
+ self.jobs_dialog.jobs_view.restore_column_widths()
+
+ @property
+ def is_running(self):
+ return self.pi.isAnimated()
+
+ def start(self):
+ self.pi.startAnimation()
+
+ def stop(self):
+ self.pi.stopAnimation()
+
+
+class Jobs(ProgressIndicator):
+
+ def initialize(self, jobs_dialog):
+ self.jobs_dialog = jobs_dialog
+
+ def mouseClickEvent(self, event):
+ if self.jobs_dialog.isVisible():
+ self.jobs_dialog.jobs_view.write_settings()
+ self.jobs_dialog.hide()
+ else:
+ self.jobs_dialog.jobs_view.read_settings()
+ self.jobs_dialog.show()
+ self.jobs_dialog.jobs_view.restore_column_widths()
+
+ @property
+ def is_running(self):
+ return self.isAnimated()
+
+ def start(self):
+ self.startAnimation()
+
+ def stop(self):
+ self.stopAnimation()
+
+
+
+class SideBar(QToolBar):
+
+ toggle_texts = {
+ 'book_info' : (_('Show Book Details'), _('Hide Book Details')),
+ 'tag_browser' : (_('Show Tag Browser'), _('Hide Tag Browser')),
+ 'cover_browser': (_('Show Cover Browser'), _('Hide Cover Browser')),
+ }
+ toggle_icons = {
+ 'book_info' : 'book.svg',
+ 'tag_browser' : 'tags.svg',
+ 'cover_browser': 'cover_flow.svg',
+ }
+
+
+ def __init__(self, parent=None):
+ QToolBar.__init__(self, _('Side bar'), parent)
+ self.setOrientation(Qt.Vertical)
+ self.setMovable(False)
+ self.setFloatable(False)
+ self.setToolButtonStyle(Qt.ToolButtonIconOnly)
+ self.setIconSize(QSize(48, 48))
+
+ for ac in ('book_info', 'tag_browser', 'cover_browser'):
+ action = self.addAction(QIcon(I(self.toggle_icons[ac])),
+ self.toggle_texts[ac][1], getattr(self, '_toggle_'+ac))
+ setattr(self, 'action_toggle_'+ac, action)
+ w = self.widgetForAction(action)
+ w.setCheckable(True)
+ setattr(self, 'show_'+ac, partial(getattr(self, '_toggle_'+ac),
+ show=True))
+ setattr(self, 'hide_'+ac, partial(getattr(self, '_toggle_'+ac),
+ show=False))
+
+
+ self.spacer = QWidget(self)
+ self.spacer.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Expanding)
+ self.addWidget(self.spacer)
+ self.jobs_button = JobsButton(self)
+ self.addWidget(self.jobs_button)
+
+ self.show_cover_browser = partial(self._toggle_cover_browser, show=True)
+ self.hide_cover_browser = partial(self._toggle_cover_browser,
+ show=False)
+ for ch in self.children():
+ if isinstance(ch, QToolButton):
+ ch.setCursor(Qt.PointingHandCursor)
+
+ def initialize(self, jobs_dialog, cover_browser, toggle_cover_browser,
+ cover_browser_error, vertical_splitter, horizontal_splitter):
+ self.jobs_button.initialize(jobs_dialog)
+ self.cover_browser, self.do_toggle_cover_browser = cover_browser, \
+ toggle_cover_browser
+ if self.cover_browser is None:
+ self.action_toggle_cover_browser.setEnabled(False)
+ self.action_toggle_cover_browser.setText(
+ _('Cover browser could not be loaded: ') + cover_browser_error)
+ else:
+ self.cover_browser.stop.connect(self.hide_cover_browser)
+ self._toggle_cover_browser(dynamic.get('cover_flow_visible', False))
+
+ self.horizontal_splitter = horizontal_splitter
+ self.vertical_splitter = vertical_splitter
+
+ tb_state = dynamic.get('tag_browser_state', None)
+ if tb_state is not None:
+ self.horizontal_splitter.restoreState(tb_state)
+
+ bi_state = dynamic.get('book_info_state', None)
+ if bi_state is not None:
+ self.vertical_splitter.restoreState(bi_state)
+ self.horizontal_splitter.initialize()
+ self.vertical_splitter.initialize()
+ self.view_status_changed('book_info', not
+ self.vertical_splitter.is_side_index_hidden)
+ self.view_status_changed('tag_browser', not
+ self.horizontal_splitter.is_side_index_hidden)
+ self.vertical_splitter.state_changed.connect(partial(self.view_status_changed,
+ 'book_info'), type=Qt.QueuedConnection)
+ self.horizontal_splitter.state_changed.connect(partial(self.view_status_changed,
+ 'tag_browser'), type=Qt.QueuedConnection)
+
+
+
+ def view_status_changed(self, name, visible):
+ action = getattr(self, 'action_toggle_'+name)
+ texts = self.toggle_texts[name]
+ action.setText(texts[int(visible)])
+ w = self.widgetForAction(action)
+ w.setCheckable(True)
+ w.setChecked(visible)
+
+ def location_changed(self, location):
+ is_lib = location == 'library'
+ for ac in ('cover_browser', 'tag_browser'):
+ ac = getattr(self, 'action_toggle_'+ac)
+ ac.setEnabled(is_lib)
+ self.widgetForAction(ac).setVisible(is_lib)
+
+ def save_state(self):
+ dynamic.set('cover_flow_visible', self.is_cover_browser_visible)
+ dynamic.set('tag_browser_state',
+ str(self.horizontal_splitter.saveState()))
+ dynamic.set('book_info_state',
+ str(self.vertical_splitter.saveState()))
+
+
+ @property
+ def is_cover_browser_visible(self):
+ return self.cover_browser is not None and self.cover_browser.isVisible()
+
+ def _toggle_cover_browser(self, show=None):
+ if show is None:
+ show = not self.is_cover_browser_visible
+ self.do_toggle_cover_browser(show)
+ self.view_status_changed('cover_browser', show)
+
+ def external_cover_flow_finished(self, *args):
+ self.view_status_changed('cover_browser', False)
+
+ def _toggle_tag_browser(self, show=None):
+ self.horizontal_splitter.toggle_side_index()
+
+ def _toggle_book_info(self, show=None):
+ self.vertical_splitter.toggle_side_index()
+
+ def jobs(self):
+ src = unicode(self.jobs_button.jobs.text())
+ return int(re.search(r'\d+', src).group())
+
+ def job_added(self, nnum):
+ jobs = self.jobs_button.jobs
+ src = unicode(jobs.text())
+ num = self.jobs()
+ text = src.replace(str(num), str(nnum))
+ jobs.setText(text)
+ self.jobs_button.start()
+
+ def job_done(self, nnum):
+ jobs = self.jobs_button.jobs
+ src = unicode(jobs.text())
+ num = self.jobs()
+ text = src.replace(str(num), str(nnum))
+ jobs.setText(text)
+ if nnum == 0:
+ self.no_more_jobs()
+
+ def no_more_jobs(self):
+ if self.jobs_button.is_running:
+ self.jobs_button.stop()
+ QCoreApplication.instance().alert(self, 5000)
+
+
diff --git a/src/calibre/gui2/status.py b/src/calibre/gui2/status.py
index a66b903a5e..f7bafacf8b 100644
--- a/src/calibre/gui2/status.py
+++ b/src/calibre/gui2/status.py
@@ -1,15 +1,14 @@
__license__ = 'GPL v3'
__copyright__ = '2008, Kovid Goyal '
-import os, re, collections
+import os, collections
from PyQt4.QtGui import QStatusBar, QLabel, QWidget, QHBoxLayout, QPixmap, \
- QVBoxLayout, QSizePolicy, QToolButton, QIcon, QScrollArea, QFrame
-from PyQt4.QtCore import Qt, QSize, SIGNAL, QCoreApplication, pyqtSignal
+ QSizePolicy, QScrollArea
+from PyQt4.QtCore import Qt, QSize, pyqtSignal
from calibre import fit_image, preferred_encoding, isosx
from calibre.gui2 import config
from calibre.gui2.widgets import IMAGE_EXTENSIONS
-from calibre.gui2.progress_indicator import ProgressIndicator
from calibre.gui2.notify import get_notifier
from calibre.ebooks import BOOK_EXTENSIONS
from calibre.library.comments import comments_to_html
@@ -17,6 +16,7 @@ from calibre.library.comments import comments_to_html
class BookInfoDisplay(QWidget):
DROPABBLE_EXTENSIONS = IMAGE_EXTENSIONS+BOOK_EXTENSIONS
+ files_dropped = pyqtSignal(object, object)
@classmethod
def paths_from_event(cls, event):
@@ -40,8 +40,7 @@ class BookInfoDisplay(QWidget):
def dropEvent(self, event):
paths = self.paths_from_event(event)
event.setDropAction(Qt.CopyAction)
- self.emit(SIGNAL('files_dropped(PyQt_PyObject, PyQt_PyObject)'), event,
- paths)
+ self.files_dropped.emit(event, paths)
def dragMoveEvent(self, event):
event.acceptProposedAction()
@@ -87,6 +86,9 @@ class BookInfoDisplay(QWidget):
class BookDataDisplay(QLabel):
+
+ mr = pyqtSignal(int)
+
def __init__(self):
QLabel.__init__(self)
self.setText('')
@@ -94,7 +96,7 @@ class BookInfoDisplay(QWidget):
self.setSizePolicy(QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding))
def mouseReleaseEvent(self, ev):
- self.emit(SIGNAL('mr(int)'), 1)
+ self.mr.emit(1)
WEIGHTS = collections.defaultdict(lambda : 100)
WEIGHTS[_('Path')] = 0
@@ -103,6 +105,8 @@ class BookInfoDisplay(QWidget):
WEIGHTS[_('Series')] = 2
WEIGHTS[_('Tags')] = 3
+ show_book_info = pyqtSignal()
+
def __init__(self, clear_message):
QWidget.__init__(self)
self.setCursor(Qt.PointingHandCursor)
@@ -113,14 +117,14 @@ class BookInfoDisplay(QWidget):
self.cover_display = BookInfoDisplay.BookCoverDisplay()
self._layout.addWidget(self.cover_display)
self.book_data = BookInfoDisplay.BookDataDisplay()
- self.connect(self.book_data, SIGNAL('mr(int)'), self.mouseReleaseEvent)
+ self.book_data.mr.connect(self.mouseReleaseEvent)
self._layout.addWidget(self.book_data)
self.data = {}
self.setVisible(False)
self._layout.setAlignment(self.cover_display, Qt.AlignTop|Qt.AlignLeft)
def mouseReleaseEvent(self, ev):
- self.emit(SIGNAL('show_book_info()'))
+ self.show_book_info.emit()
def show_data(self, data):
if data.has_key('cover'):
@@ -128,7 +132,7 @@ class BookInfoDisplay(QWidget):
else:
self.cover_display.setPixmap(self.cover_display.default_pixmap)
- rows = u''
+ rows, comments = [], ''
self.book_data.setText('')
self.data = data.copy()
keys = data.keys()
@@ -142,97 +146,43 @@ class BookInfoDisplay(QWidget):
if isinstance(txt, str):
txt = txt.decode(preferred_encoding, 'replace')
if key == _('Comments'):
- txt = comments_to_html(txt)
- rows += u'
Browsing books by their covers is disabled. Import of pictureflow module failed: ')+reason)
-
class StatusBar(QStatusBar):
resized = pyqtSignal(object)
+ files_dropped = pyqtSignal(object, object)
+ show_book_info = pyqtSignal()
- def initialize(self, jobs_dialog, systray=None):
+ def initialize(self, systray=None):
self.systray = systray
self.notifier = get_notifier(systray)
- self.movie_button = MovieButton(jobs_dialog)
- self.cover_flow_button = CoverFlowButton()
- self.addPermanentWidget(self.cover_flow_button)
- self.addPermanentWidget(self.movie_button)
self.book_info = BookInfoDisplay(self.clearMessage)
self.book_info.setAcceptDrops(True)
self.scroll_area = QScrollArea()
self.scroll_area.setWidget(self.book_info)
self.scroll_area.setWidgetResizable(True)
- self.connect(self.book_info, SIGNAL('show_book_info()'), self.show_book_info)
- self.connect(self.book_info,
- SIGNAL('files_dropped(PyQt_PyObject,PyQt_PyObject)'),
- self.files_dropped, Qt.QueuedConnection)
+ self.book_info.show_book_info.connect(self.show_book_info.emit,
+ type=Qt.QueuedConnection)
+ self.book_info.files_dropped.connect(self.files_dropped.emit,
+ type=Qt.QueuedConnection)
self.addWidget(self.scroll_area, 100)
self.setMinimumHeight(120)
self.resized.connect(self.book_info.cover_display.relayout)
@@ -241,10 +191,6 @@ class StatusBar(QStatusBar):
def resizeEvent(self, ev):
self.resized.emit(self.size())
- def files_dropped(self, event, paths):
- self.emit(SIGNAL('files_dropped(PyQt_PyObject, PyQt_PyObject)'), event,
- paths)
-
def reset_info(self):
self.book_info.show_data({})
@@ -259,33 +205,4 @@ class StatusBar(QStatusBar):
self.notifier(msg)
return ret
- def jobs(self):
- src = unicode(self.movie_button.jobs.text())
- return int(re.search(r'\d+', src).group())
-
- def show_book_info(self):
- self.emit(SIGNAL('show_book_info()'))
-
- def job_added(self, nnum):
- jobs = self.movie_button.jobs
- src = unicode(jobs.text())
- num = self.jobs()
- text = src.replace(str(num), str(nnum))
- jobs.setText(text)
- self.movie_button.start()
-
- def job_done(self, nnum):
- jobs = self.movie_button.jobs
- src = unicode(jobs.text())
- num = self.jobs()
- text = src.replace(str(num), str(nnum))
- jobs.setText(text)
- if nnum == 0:
- self.no_more_jobs()
-
- def no_more_jobs(self):
- if self.movie_button.is_running:
- self.movie_button.stop()
- QCoreApplication.instance().alert(self, 5000)
-
diff --git a/src/calibre/gui2/ui.py b/src/calibre/gui2/ui.py
index 441ed18a9b..b35270f963 100644
--- a/src/calibre/gui2/ui.py
+++ b/src/calibre/gui2/ui.py
@@ -262,16 +262,10 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
SIGNAL('update_found(PyQt_PyObject)'), self.update_found)
self.update_checker.start(2000)
####################### Status Bar #####################
- self.status_bar.initialize(self.jobs_dialog, self.system_tray_icon)
- #self.setStatusBar(self.status_bar)
- QObject.connect(self.job_manager, SIGNAL('job_added(int)'),
- self.status_bar.job_added, Qt.QueuedConnection)
- QObject.connect(self.job_manager, SIGNAL('job_done(int)'),
- self.status_bar.job_done, Qt.QueuedConnection)
- QObject.connect(self.status_bar, SIGNAL('show_book_info()'),
- self.show_book_info)
- QObject.connect(self.status_bar, SIGNAL('files_dropped(PyQt_PyObject,PyQt_PyObject)'),
- self.files_dropped_on_book)
+ self.status_bar.initialize(self.system_tray_icon)
+ self.status_bar.show_book_info.connect(self.show_book_info)
+ self.status_bar.files_dropped.connect(self.files_dropped_on_book)
+
####################### Setup Toolbar #####################
md = QMenu()
md.addAction(_('Edit metadata individually'))
@@ -459,6 +453,10 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
QObject.connect(self.advanced_search_button, SIGNAL('clicked(bool)'),
self.do_advanced_search)
+ for ch in self.tool_bar.children():
+ if isinstance(ch, QToolButton):
+ ch.setCursor(Qt.PointingHandCursor)
+
####################### Library view ########################
similar_menu = QMenu(_('Similar books...'))
similar_menu.addAction(self.action_books_by_same_author)
@@ -554,12 +552,6 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
self.cover_cache = CoverCache(self.library_path)
self.cover_cache.start()
self.library_view.model().cover_cache = self.cover_cache
- 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)
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,
@@ -626,22 +618,28 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
if not config['separate_cover_flow']:
self.library.layout().addWidget(self.cover_flow)
self.cover_flow.currentChanged.connect(self.sync_listview_to_cf)
- self.connect(self.status_bar.cover_flow_button,
- SIGNAL('toggled(bool)'), self.toggle_cover_flow)
- self.connect(self.cover_flow, SIGNAL('stop()'),
- self.status_bar.cover_flow_button.toggle)
self.library_view.selectionModel().currentRowChanged.connect(
self.sync_cf_to_listview)
self.db_images = DatabaseImages(self.library_view.model())
self.cover_flow.setImages(self.db_images)
- else:
- self.status_bar.cover_flow_button.disable(pictureflowerror)
self._calculated_available_height = min(max_available_height()-15,
self.height())
self.resize(self.width(), self._calculated_available_height)
self.search.setMaximumWidth(self.width()-150)
+ ####################### Side Bar ###############################
+
+ self.sidebar.initialize(self.jobs_dialog, self.cover_flow,
+ self.toggle_cover_flow, pictureflowerror,
+ self.vertical_splitter, self.horizontal_splitter)
+ QObject.connect(self.job_manager, SIGNAL('job_added(int)'),
+ self.sidebar.job_added, Qt.QueuedConnection)
+ QObject.connect(self.job_manager, SIGNAL('job_done(int)'),
+ self.sidebar.job_done, Qt.QueuedConnection)
+
+
+
if config['autolaunch_server']:
from calibre.library.server import start_threaded_server
from calibre.library import server_config
@@ -668,19 +666,6 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
self.location_view.setCurrentIndex(self.location_view.model().index(0))
- if self.cover_flow is not None and dynamic.get('cover_flow_visible', False):
- self.status_bar.cover_flow_button.toggle()
-
- tb_state = dynamic.get('tag_browser_state', None)
- if tb_state is not None:
- self.horizontal_splitter.restoreState(tb_state)
- self.toggle_tags_view(True)
-
- bi_state = dynamic.get('book_info_state', None)
- if bi_state is not None:
- self.vertical_splitter.restoreState(bi_state)
- self.horizontal_splitter.initialize()
- self.vertical_splitter.initialize()
self._add_filesystem_book = Dispatcher(self.__add_filesystem_book)
v = self.library_view
@@ -782,11 +767,6 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
if search:
self.search.set_search_string(join.join(search))
-
-
- def uncheck_cover_button(self, *args):
- self.status_bar.cover_flow_button.setChecked(False)
-
def toggle_cover_flow(self, show):
if config['separate_cover_flow']:
if show:
@@ -802,8 +782,7 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
self.cover_flow.setFocus(Qt.OtherFocusReason)
self.library_view.scrollTo(self.library_view.currentIndex())
d.show()
- self.connect(d, SIGNAL('finished(int)'),
- self.uncheck_cover_button)
+ d.finished.connect(self.sidebar.external_cover_flow_finished)
self.cf_dialog = d
self.cover_flow_sync_timer.start(500)
else:
@@ -825,8 +804,6 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
self.library_view.currentIndex())
self.cover_flow.setVisible(True)
self.cover_flow.setFocus(Qt.OtherFocusReason)
- #self.status_bar.book_info.book_data.setMaximumHeight(100)
- #self.status_bar.setMaximumHeight(120)
self.library_view.scrollTo(self.library_view.currentIndex())
self.cover_flow_sync_timer.start(500)
else:
@@ -837,26 +814,8 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
sm = self.library_view.selectionModel()
sm.select(idx, sm.ClearAndSelect|sm.Rows)
self.library_view.setCurrentIndex(idx)
- #self.status_bar.book_info.book_data.setMaximumHeight(1000)
- #self.resize(self.width(), self._calculated_available_height)
- #self.setMaximumHeight(available_height())
- def toggle_tags_view(self, show):
- if show:
- 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)
+
'''
Handling of the count of books in a restricted view requires that
@@ -2330,6 +2289,7 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
view.resizeColumnsToContents()
view.resize_on_select = False
self.status_bar.reset_info()
+ self.sidebar.location_changed(location)
if location == 'library':
self.action_edit.setEnabled(True)
self.action_merge.setEnabled(True)
@@ -2337,7 +2297,6 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
self.view_menu.actions()[1].setEnabled(True)
self.action_open_containing_folder.setEnabled(True)
self.action_sync.setEnabled(True)
- self.status_bar.cover_flow_button.setEnabled(True)
for action in list(self.delete_menu.actions())[1:]:
action.setEnabled(True)
else:
@@ -2347,7 +2306,6 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
self.view_menu.actions()[1].setEnabled(False)
self.action_open_containing_folder.setEnabled(False)
self.action_sync.setEnabled(False)
- self.status_bar.cover_flow_button.setEnabled(False)
for action in list(self.delete_menu.actions())[1:]:
action.setEnabled(False)
@@ -2463,11 +2421,7 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
def write_settings(self):
config.set('main_window_geometry', self.saveGeometry())
dynamic.set('sort_history', self.library_view.model().sort_history)
- dynamic.set('cover_flow_visible', self.cover_flow.isVisible())
- dynamic.set('tag_browser_state',
- str(self.horizontal_splitter.saveState()))
- dynamic.set('book_info_state',
- str(self.vertical_splitter.saveState()))
+ self.sidebar.save_state()
self.library_view.write_settings()
if self.device_connected:
self.save_device_view_settings()
diff --git a/src/calibre/gui2/widgets.py b/src/calibre/gui2/widgets.py
index e39b06ea54..db5f222408 100644
--- a/src/calibre/gui2/widgets.py
+++ b/src/calibre/gui2/widgets.py
@@ -982,6 +982,12 @@ class SplitterHandle(QSplitterHandle):
class Splitter(QSplitter):
+ state_changed = pyqtSignal(object)
+
+ def __init__(self, *args):
+ QSplitter.__init__(self, *args)
+ self.splitterMoved.connect(self.splitter_moved, type=Qt.QueuedConnection)
+
def createHandle(self):
return SplitterHandle(self.orientation(), self)
@@ -990,6 +996,22 @@ class Splitter(QSplitter):
h = self.handle(i)
if h is not None:
h.splitter_moved()
+ self.state_changed.emit(not self.is_side_index_hidden)
+
+ def splitter_moved(self, *args):
+ self.state_changed.emit(not self.is_side_index_hidden)
+
+ @property
+ def side_index(self):
+ return 0 if self.orientation() == Qt.Horizontal else 1
+
+ @property
+ def is_side_index_hidden(self):
+ sizes = list(self.sizes())
+ return sizes[self.side_index] == 0
+
+ def toggle_side_index(self):
+ self.double_clicked(None)
def double_clicked(self, handle):
sizes = list(self.sizes())
@@ -997,8 +1019,7 @@ class Splitter(QSplitter):
idx = sizes.index(0)
sizes[idx] = 80
else:
- idx = 0 if self.orientation() == Qt.Horizontal else 1
- sizes[idx] = 0
+ sizes[self.side_index] = 0
self.setSizes(sizes)
self.initialize()
From d6bf59d83f8729e83956edf709b5ac11fef911e1 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Mon, 3 May 2010 20:49:09 -0600
Subject: [PATCH 032/324] ...
---
src/calibre/gui2/__init__.py | 5 ++++-
src/calibre/gui2/dialogs/config/__init__.py | 2 +-
src/calibre/gui2/dialogs/config/create_custom_column.ui | 4 ++--
3 files changed, 7 insertions(+), 4 deletions(-)
diff --git a/src/calibre/gui2/__init__.py b/src/calibre/gui2/__init__.py
index 78b68a8bfb..c8b2a47b0e 100644
--- a/src/calibre/gui2/__init__.py
+++ b/src/calibre/gui2/__init__.py
@@ -205,11 +205,14 @@ def error_dialog(parent, title, msg, det_msg='', show=False,
return d.exec_()
return d
-def question_dialog(parent, title, msg, det_msg=''):
+def question_dialog(parent, title, msg, det_msg='', show_copy_button=True):
d = MessageBox(QMessageBox.Question, title, msg, QMessageBox.Yes|QMessageBox.No,
parent, det_msg)
d.setIconPixmap(QPixmap(I('dialog_information.svg')))
d.setEscapeButton(QMessageBox.No)
+ if not show_copy_button:
+ d.cb.setVisible(False)
+
return d.exec_() == QMessageBox.Yes
def info_dialog(parent, title, msg, det_msg='', show=False):
diff --git a/src/calibre/gui2/dialogs/config/__init__.py b/src/calibre/gui2/dialogs/config/__init__.py
index b5d145dfc5..731c7b7f12 100644
--- a/src/calibre/gui2/dialogs/config/__init__.py
+++ b/src/calibre/gui2/dialogs/config/__init__.py
@@ -656,7 +656,7 @@ class ConfigDialog(ResizableDialog, Ui_Dialog):
_('The selected column is not a custom column'), show=True)
if not question_dialog(self, _('Are you sure?'),
_('Do you really want to delete column %s and all its data?') %
- self.custcols[col]['name']):
+ self.custcols[col]['name'], show_copy_button=False):
return
self.columns.item(idx).setCheckState(False)
self.columns.takeItem(idx)
diff --git a/src/calibre/gui2/dialogs/config/create_custom_column.ui b/src/calibre/gui2/dialogs/config/create_custom_column.ui
index 3e0556b815..247fbd9537 100644
--- a/src/calibre/gui2/dialogs/config/create_custom_column.ui
+++ b/src/calibre/gui2/dialogs/config/create_custom_column.ui
@@ -20,7 +20,7 @@
- Create a custom column
+ Create or edit custom columns
@@ -126,7 +126,7 @@
- Create and edit custom columns
+ Create or edit custom columns
From c1fd349e1064df127814bed72ea4d0d11a23de82 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Mon, 3 May 2010 21:03:09 -0600
Subject: [PATCH 033/324] Fix display or custom columns of type rating in Tag
Browser
---
src/calibre/gui2/__init__.py | 5 ++++-
src/calibre/gui2/dialogs/config/__init__.py | 2 +-
src/calibre/library/custom_columns.py | 2 ++
src/calibre/library/database2.py | 4 +++-
4 files changed, 10 insertions(+), 3 deletions(-)
diff --git a/src/calibre/gui2/__init__.py b/src/calibre/gui2/__init__.py
index c8b2a47b0e..876b2cc74c 100644
--- a/src/calibre/gui2/__init__.py
+++ b/src/calibre/gui2/__init__.py
@@ -184,11 +184,14 @@ class MessageBox(QMessageBox):
-def warning_dialog(parent, title, msg, det_msg='', show=False):
+def warning_dialog(parent, title, msg, det_msg='', show=False,
+ show_copy_button=True):
d = MessageBox(QMessageBox.Warning, 'WARNING: '+title, msg, QMessageBox.Ok,
parent, det_msg)
d.setEscapeButton(QMessageBox.Ok)
d.setIconPixmap(QPixmap(I('dialog_warning.svg')))
+ if not show_copy_button:
+ d.cb.setVisible(False)
if show:
return d.exec_()
return d
diff --git a/src/calibre/gui2/dialogs/config/__init__.py b/src/calibre/gui2/dialogs/config/__init__.py
index 731c7b7f12..e9f551af48 100644
--- a/src/calibre/gui2/dialogs/config/__init__.py
+++ b/src/calibre/gui2/dialogs/config/__init__.py
@@ -831,7 +831,7 @@ class ConfigDialog(ResizableDialog, Ui_Dialog):
warning_dialog(self, _('Must restart'),
_('The changes you made require that Calibre be '
'restarted. Please restart as soon as practical.'),
- show=True)
+ show=True, show_copy_button=False)
self.parent.must_restart_before_config = True
QDialog.accept(self)
diff --git a/src/calibre/library/custom_columns.py b/src/calibre/library/custom_columns.py
index 8a20e66a60..e721a825c8 100644
--- a/src/calibre/library/custom_columns.py
+++ b/src/calibre/library/custom_columns.py
@@ -145,6 +145,8 @@ class CustomColumns(object):
if v['normalized']:
tn = 'custom_column_{0}'.format(i)
self.tag_browser_categories[tn] = [v['label'], 'value']
+ if v['datatype'] == 'rating':
+ self.tag_browser_formatters[tn] = lambda x:u'\u2605'*int(round(x/2.))
def get_custom(self, idx, label=None, num=None, index_is_id=False):
diff --git a/src/calibre/library/database2.py b/src/calibre/library/database2.py
index 729c531897..8e51143ef2 100644
--- a/src/calibre/library/database2.py
+++ b/src/calibre/library/database2.py
@@ -127,6 +127,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
'authors' : ['author', 'name'],
'news' : ['news', 'name'],
}
+ self.tag_browser_formatters = {}
self.connect()
self.is_case_sensitive = not iswindows and not isosx and \
@@ -632,7 +633,8 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
else:
icon = icon_map['*custom']
tooltip = self.custom_column_label_map[category]['name']
- categories[category] = [Tag(r[1], count=r[2], id=r[0], icon=icon, tooltip = tooltip)
+ formatter = self.tag_browser_formatters.get(tn, lambda x: x)
+ categories[category] = [Tag(formatter(r[1]), count=r[2], id=r[0], icon=icon, tooltip = tooltip)
for r in data if r[2] > 0]
categories['format'] = []
for fmt in self.conn.get('SELECT DISTINCT format FROM data'):
From 7483a4389fbe9f485cab845179586a9d0a5becf6 Mon Sep 17 00:00:00 2001
From: Charles Haley <>
Date: Tue, 4 May 2010 16:29:01 +0100
Subject: [PATCH 034/324] Play with main UI layout -- move restriction box
---
src/calibre/gui2/main.ui | 122 ++++++++++++++++++---------------------
1 file changed, 56 insertions(+), 66 deletions(-)
diff --git a/src/calibre/gui2/main.ui b/src/calibre/gui2/main.ui
index 8dcb0e6d75..c076bf6b03 100644
--- a/src/calibre/gui2/main.ui
+++ b/src/calibre/gui2/main.ui
@@ -157,6 +157,30 @@
0
+
+
+
+ &Restrict to:
+
+
+ search_restriction
+
+
+
+
+
+
+ Books display will be restricted to those matching the selected saved search
+
+
+
+
+
+
+ set in ui.py
+
+
+
@@ -206,13 +230,6 @@
-
-
-
- set in ui.py
-
-
-
@@ -336,69 +353,42 @@
-
-
- Sort by &popularity
+
+
+
+ Sort by &popularity
+
+
+
+
+
+
+
+ 0
+
+
+ Match any
+
+
+
+
+ Match all
+
+
-
-
-
-
- 0
-
-
-
- Match any
-
-
-
-
- Match all
-
-
-
-
-
-
-
- Create, edit, and delete user categories
-
-
- Manage &user categories
-
-
-
-
-
-
-
-
-
-
- &Restrict to:
-
-
- search_restriction
-
-
-
-
-
-
-
- 50
- 0
-
-
-
- Books display will be restricted to those matching the selected saved search
-
-
-
-
+
+
+
+ Create, edit, and delete user categories
+
+
+ Manage &user categories
+
+
+
From 22e104d9b2a12b7c0a0270ac76f1cb2817d93883 Mon Sep 17 00:00:00 2001
From: Charles Haley <>
Date: Tue, 4 May 2010 16:36:30 +0100
Subject: [PATCH 035/324] Change restriction explanation label
---
src/calibre/gui2/main.ui | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/calibre/gui2/main.ui b/src/calibre/gui2/main.ui
index c076bf6b03..48396861b3 100644
--- a/src/calibre/gui2/main.ui
+++ b/src/calibre/gui2/main.ui
@@ -160,7 +160,7 @@
- &Restrict to:
+ &Restrict display to:search_restriction
From 873481851dce529c0add189cf3ffc9bb273fad51 Mon Sep 17 00:00:00 2001
From: Charles Haley <>
Date: Tue, 4 May 2010 17:24:40 +0100
Subject: [PATCH 036/324] Fix problem with adding multiple columns: should not
delete from item being interated on
---
src/calibre/gui2/library.py | 19 ++++++++++---------
1 file changed, 10 insertions(+), 9 deletions(-)
diff --git a/src/calibre/gui2/library.py b/src/calibre/gui2/library.py
index c1a8057844..cd8a078001 100644
--- a/src/calibre/gui2/library.py
+++ b/src/calibre/gui2/library.py
@@ -283,16 +283,17 @@ class BooksModel(QAbstractTableModel):
def read_config(self):
self.use_roman_numbers = config['use_roman_numerals_for_series_number']
- self.column_map = config['column_map'][:] # force a copy
+ cmap = 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.column_map = []
+ for col in cmap: # take out any columns no longer in the db
+ if col in self.orig_headers or col in self.custom_columns:
+ self.column_map.append(col)
+ for col in self.column_map:
+ if col in self.orig_headers:
+ self.headers[col] = self.orig_headers[col]
+ elif col in self.custom_columns:
+ self.headers[col] = self.custom_columns[col]['name']
self.build_data_convertors()
self.reset()
self.emit(SIGNAL('columns_sorted()'))
From a4ef43708ac3fd34ed13c0f804e222c6de565c7f Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Tue, 4 May 2010 21:36:26 -0600
Subject: [PATCH 037/324] Add support for custom columns to the edit metadata
single dialog
---
src/calibre/gui2/__init__.py | 3 +-
src/calibre/gui2/custom_column_widgets.py | 270 ++++
src/calibre/gui2/dialogs/metadata_single.py | 26 +-
src/calibre/gui2/dialogs/metadata_single.ui | 1242 ++++++++++---------
src/calibre/gui2/library.py | 50 +-
src/calibre/gui2/search_box.py | 4 +-
src/calibre/gui2/widgets.py | 4 +-
7 files changed, 960 insertions(+), 639 deletions(-)
create mode 100644 src/calibre/gui2/custom_column_widgets.py
diff --git a/src/calibre/gui2/__init__.py b/src/calibre/gui2/__init__.py
index 876b2cc74c..8ce4e53649 100644
--- a/src/calibre/gui2/__init__.py
+++ b/src/calibre/gui2/__init__.py
@@ -6,7 +6,7 @@ from threading import RLock
from PyQt4.QtCore import QVariant, QFileInfo, QObject, SIGNAL, QBuffer, Qt, QSize, \
QByteArray, QTranslator, QCoreApplication, QThread, \
- QEvent, QTimer, pyqtSignal
+ QEvent, QTimer, pyqtSignal, QDate
from PyQt4.QtGui import QFileDialog, QMessageBox, QPixmap, QFileIconProvider, \
QIcon, QTableView, QApplication, QDialog, QPushButton
@@ -21,6 +21,7 @@ from calibre.ebooks.metadata import MetaInformation
gprefs = JSONConfig('gui')
NONE = QVariant() #: Null value to return from the data function of item models
+UNDEFINED_DATE = QDate(101,1,1)
ALL_COLUMNS = ['title', 'authors', 'size', 'timestamp', 'rating', 'publisher',
'tags', 'series', 'pubdate']
diff --git a/src/calibre/gui2/custom_column_widgets.py b/src/calibre/gui2/custom_column_widgets.py
new file mode 100644
index 0000000000..91a13e9236
--- /dev/null
+++ b/src/calibre/gui2/custom_column_widgets.py
@@ -0,0 +1,270 @@
+#!/usr/bin/env python
+# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai
+
+__license__ = 'GPL v3'
+__copyright__ = '2010, Kovid Goyal '
+__docformat__ = 'restructuredtext en'
+
+import sys
+
+from PyQt4.Qt import QComboBox, QLabel, QSpinBox, QDoubleSpinBox, QDateEdit, \
+ QDate, QGroupBox, QVBoxLayout, QPlainTextEdit, QSizePolicy, \
+ QSpacerItem, QIcon
+
+from calibre.utils.date import qt_to_dt
+from calibre.gui2.widgets import TagsLineEdit, EnComboBox
+from calibre.gui2 import UNDEFINED_DATE
+from calibre.utils.config import tweaks
+
+class Base(object):
+
+ def __init__(self, db, col_id):
+ self.db, self.col_id = db, col_id
+ self.col_metadata = db.custom_column_num_map[col_id]
+ self.initial_val = None
+
+ def initialize(self, book_id):
+ val = self.db.get_custom(book_id, num=self.col_id, index_is_id=True)
+ self.initial_val = val
+ val = self.normalize_db_val(val)
+ self.setter(val)
+
+ def commit(self, book_id, notify=False):
+ val = self.getter()
+ val = self.normalize_ui_val(val)
+ if val != self.initial_val:
+ self.db.set_custom(book_id, val, num=self.col_id, notify=notify)
+
+ def normalize_db_val(self, val):
+ return val
+
+ def normalize_ui_val(self, val):
+ return val
+
+class Bool(Base):
+
+ def __init__(self, db, col_id, parent=None):
+ Base.__init__(self, db, col_id)
+ self.widgets = [QLabel('&'+self.col_metadata['name'], parent),
+ QComboBox(parent)]
+ w = self.widgets[1]
+ items = [_('Yes'), _('No'), _('Undefined')]
+ icons = [I('ok.svg'), I('list_remove.svg'), I('blank.svg')]
+ if tweaks['bool_custom_columns_are_tristate'] == 'no':
+ items = items[:-1]
+ icons = icons[:-1]
+ for icon, text in zip(icons, items):
+ w.addItem(QIcon(icon), text)
+
+
+ def setter(self, val):
+ val = {None: 2, False: 1, True: 0}[val]
+ if tweaks['bool_custom_columns_are_tristate'] == 'no' and val == 2:
+ val = 1
+ self.widgets[1].setCurrentIndex(val)
+
+ def getter(self):
+ val = self.widgets[1].currentIndex()
+ return {2: None, 1: False, 0: True}[val]
+
+class Int(Base):
+
+ def __init__(self, db, col_id, parent=None):
+ Base.__init__(self, db, col_id)
+ self.widgets = [QLabel('&'+self.col_metadata['name']+':', parent),
+ QSpinBox(parent)]
+ w = self.widgets[1]
+ w.setRange(-100, sys.maxint)
+ w.setSpecialValueText(_('Undefined'))
+ w.setSingleStep(1)
+
+ def setter(self, val):
+ if val is None:
+ val = self.widgets[1].minimum()
+ else:
+ val = int(val)
+ self.widgets[1].setValue(val)
+
+ def getter(self):
+ val = self.widgets[1].value()
+ if val == self.widgets[1].minimum():
+ val = None
+ return val
+
+class Float(Int):
+
+ def __init__(self, db, col_id, parent=None):
+ Base.__init__(self, db, col_id)
+ self.widgets = [QLabel('&'+self.col_metadata['name']+':', parent),
+ QDoubleSpinBox(parent)]
+ w = self.widgets[1]
+ self.setRange(-100., float(sys.maxint))
+ w.setDecimals(2)
+
+class Rating(Int):
+
+ def __init__(self, db, col_id, parent=None):
+ Int.__init__(self, db, col_id)
+ w = self.widgets[1]
+ w.setRange(0, 5)
+ w.setSuffix(' '+_('stars'))
+ w.setSpecialValueText(_('Unrated'))
+
+ def setter(self, val):
+ if val is None:
+ val = 0
+ self.widgets[1].setValue(int(round(val/2.)))
+
+ def getter(self):
+ val = self.widgets[1].value()
+ if val == 0:
+ val = None
+ else:
+ val *= 2
+ return val
+
+class DateTime(Base):
+
+ def __init__(self, db, col_id, parent=None):
+ Base.__init__(self, db, col_id)
+ self.widgets = [QLabel('&'+self.col_metadata['name']+':', parent),
+ QDateEdit(parent)]
+ w = self.widgets[1]
+ w.setDisplayFormat('dd MMM yyyy')
+ w.setCalendarPopup(True)
+ w.setMinimumDate(UNDEFINED_DATE)
+ w.setSpecialValueText(_('Undefined'))
+
+ def setter(self, val):
+ if val is None:
+ val = self.widgets[1].minimumDate()
+ else:
+ val = QDate(val.year, val.month, val.day)
+ self.widgets[1].setDate(val)
+
+ def getter(self):
+ val = self.widgets[1].date()
+ if val == UNDEFINED_DATE:
+ val = None
+ else:
+ val = qt_to_dt(val)
+ return val
+
+
+class Comments(Base):
+
+ def __init__(self, db, col_id, parent=None):
+ Base.__init__(self, db, col_id)
+ self._box = QGroupBox(parent)
+ self._box.setTitle('&'+self.col_metadata['name'])
+ self._layout = QVBoxLayout()
+ self._tb = QPlainTextEdit(self._box)
+ self._tb.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
+ self._layout.addWidget(self._tb)
+ self._box.setLayout(self._layout)
+ self.widgets = [self._box]
+
+ def setter(self, val):
+ if val is None:
+ val = ''
+ self._tb.setPlainText(val)
+
+ def getter(self):
+ val = unicode(self._tb.toPlainText()).strip()
+ if not val:
+ val = None
+ return val
+
+class Text(Base):
+
+ def __init__(self, db, col_id, parent=None):
+ Base.__init__(self, db, col_id)
+ values = self.all_values = list(self.db.all_custom(num=col_id))
+ values.sort(cmp = lambda x,y: cmp(x.lower(), y.lower()))
+ if self.col_metadata['is_multiple']:
+ w = TagsLineEdit(parent, values)
+ w.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Preferred)
+ else:
+ w = EnComboBox(parent)
+ w.setSizeAdjustPolicy(w.AdjustToMinimumContentsLengthWithIcon)
+ w.setMinimumContentsLength(25)
+
+
+
+ self.widgets = [QLabel('&'+self.col_metadata['name']+':', parent),
+ w]
+
+ def initialize(self, book_id):
+ val = self.db.get_custom(book_id, num=self.col_id, index_is_id=True)
+ self.initial_val = val
+ val = self.normalize_db_val(val)
+ if self.col_metadata['is_multiple']:
+ self.setter(val)
+ self.widgets[1].update_tags_cache(self.all_values)
+ else:
+ idx = None
+ for i, c in enumerate(self.all_values):
+ if c == val:
+ idx = i
+ self.widgets[1].addItem(c)
+ self.widgets[1].setEditText('')
+ if idx is not None:
+ self.widgets[1].setCurrentIndex(idx)
+
+
+ def setter(self, val):
+ if self.col_metadata['is_multiple']:
+ if not val:
+ val = []
+ self.widgets[1].setText(u', '.join(val))
+
+ def getter(self):
+ if self.col_metadata['is_multiple']:
+ val = unicode(self.widgets[1].text()).strip()
+ return [x.strip() for x in val.split(',')]
+ val = unicode(self.widgets[1].currentText()).strip()
+ if not val:
+ val = None
+ return val
+
+widgets = {
+ 'bool' : Bool,
+ 'rating' : Rating,
+ 'int': Int,
+ 'float': Float,
+ 'datetime': DateTime,
+ 'text' : Text,
+ 'comments': Comments,
+}
+
+def populate_single_metadata_page(left, right, db, book_id, parent=None):
+ x = db.custom_column_num_map
+ cols = list(x)
+ cols.sort(cmp=lambda z,y: cmp(x[z]['name'].lower(), x[y]['name'].lower()))
+ ans = []
+ for i, col in enumerate(cols):
+ w = widgets[x[col]['datatype']](db, col, parent)
+ ans.append(w)
+ w.initialize(book_id)
+ layout = left if i%2 == 0 else right
+ row = layout.rowCount()
+ if len(w.widgets) == 1:
+ layout.addWidget(w.widgets[0], row, 0, 1, -1)
+ else:
+ w.widgets[0].setBuddy(w.widgets[1])
+ for c, widget in enumerate(w.widgets):
+ layout.addWidget(widget, row, c)
+ items = []
+ if len(ans) > 0:
+ items.append(QSpacerItem(10, 10, QSizePolicy.Minimum,
+ QSizePolicy.Expanding))
+ left.addItem(items[-1], left.rowCount(), 0, 1, 1)
+ left.setRowStretch(left.rowCount()-1, 100)
+ if len(ans) > 1:
+ items.append(QSpacerItem(10, 100, QSizePolicy.Minimum,
+ QSizePolicy.Expanding))
+ right.addItem(items[-1], left.rowCount(), 0, 1, 1)
+ right.setRowStretch(right.rowCount()-1, 100)
+
+ return ans, items
+
diff --git a/src/calibre/gui2/dialogs/metadata_single.py b/src/calibre/gui2/dialogs/metadata_single.py
index 3df197e6a5..8c5d3e6c41 100644
--- a/src/calibre/gui2/dialogs/metadata_single.py
+++ b/src/calibre/gui2/dialogs/metadata_single.py
@@ -11,8 +11,9 @@ import re
import time
import traceback
+import sip
from PyQt4.Qt import SIGNAL, QObject, QCoreApplication, Qt, QTimer, QThread, QDate, \
- QPixmap, QListWidgetItem, QDialog
+ QPixmap, QListWidgetItem, QDialog, QHBoxLayout, QGridLayout
from calibre.gui2 import error_dialog, file_icon_provider, \
choose_files, choose_images, ResizableDialog, \
@@ -31,6 +32,7 @@ from calibre.utils.config import prefs, tweaks
from calibre.utils.date import qt_to_dt
from calibre.customize.ui import run_plugins_on_import, get_isbndb_key
from calibre.gui2.dialogs.config.social import SocialMetadata
+from calibre.gui2.custom_column_widgets import populate_single_metadata_page
class CoverFetcher(QThread):
@@ -405,6 +407,26 @@ class MetadataSingleDialog(ResizableDialog, Ui_MetadataSingleDialog):
self.cover.setPixmap(pm)
self.cover_data = cover
self.original_series_name = unicode(self.series.text()).strip()
+ if len(db.custom_column_label_map) == 0:
+ self.central_widget.tabBar().setVisible(False)
+ else:
+ self.create_custom_column_editors()
+
+ def create_custom_column_editors(self):
+ w = self.central_widget.widget(1)
+ top_layout = QHBoxLayout()
+ top_layout.setSpacing(20)
+ left_layout = QGridLayout()
+ right_layout = QGridLayout()
+ top_layout.addLayout(left_layout)
+
+ self.custom_column_widgets, self.__cc_spacers = populate_single_metadata_page(
+ left_layout, right_layout, self.db, self.id, w)
+ top_layout.addLayout(right_layout)
+ sip.delete(w.layout())
+ w.setLayout(top_layout)
+ self.__custom_col_layouts = [top_layout, left_layout, right_layout]
+
def validate_isbn(self, isbn):
isbn = unicode(isbn).strip()
@@ -675,6 +697,8 @@ class MetadataSingleDialog(ResizableDialog, Ui_MetadataSingleDialog):
self.db.set_cover(self.id, self.cover_data)
else:
self.db.remove_cover(self.id)
+ for w in getattr(self, 'custom_column_widgets', []):
+ w.commit(self.id)
except IOError, err:
if err.errno == 13: # Permission denied
fname = err.filename if err.filename else 'file'
diff --git a/src/calibre/gui2/dialogs/metadata_single.ui b/src/calibre/gui2/dialogs/metadata_single.ui
index 5d2b98f70f..6d8dcca615 100644
--- a/src/calibre/gui2/dialogs/metadata_single.ui
+++ b/src/calibre/gui2/dialogs/metadata_single.ui
@@ -43,8 +43,8 @@
00
- 869
- 698
+ 879
+ 711
@@ -52,625 +52,639 @@
0
-
+ 800665
-
-
-
-
- Qt::Horizontal
-
-
-
-
-
-
- Meta information
-
-
-
-
-
- &Title:
-
-
- Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
-
-
- title
-
-
-
-
-
-
- Change the title of this book
-
-
-
-
-
-
- Swap the author and title
-
-
- ...
-
-
-
- :/images/swap.svg:/images/swap.svg
-
-
-
- 16
- 16
-
-
-
-
-
-
-
- &Author(s):
-
-
- Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
-
-
- authors
-
-
-
-
-
-
- Author S&ort:
-
-
- Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
-
-
- author_sort
-
-
-
-
-
-
-
-
- Specify how the author(s) of this book should be sorted. For example Charles Dickens should be sorted as Dickens, Charles.
-
-
-
-
-
-
- Automatically create the author sort entry based on the current author entry
-
-
- ...
-
-
-
- :/images/auto_author_sort.svg:/images/auto_author_sort.svg
-
-
-
-
-
-
-
-
- &Rating:
-
-
- Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
-
-
- rating
-
-
-
-
-
-
- Rating of this book. 0-5 stars
-
-
- Rating of this book. 0-5 stars
-
-
- QAbstractSpinBox::PlusMinus
-
-
- stars
-
-
- 5
-
-
-
-
-
-
- &Publisher:
-
-
- Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
-
-
- publisher
-
-
-
-
-
-
- Ta&gs:
-
-
- Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
-
-
- tags
-
-
-
-
-
-
-
-
- Tags categorize the book. This is particularly useful while searching. <br><br>They can be any words or phrases, separated by commas.
-
-
-
-
-
-
- Open Tag Editor
-
-
- Open Tag Editor
-
-
-
- :/images/chapters.svg:/images/chapters.svg
-
-
-
-
-
-
-
-
- &Series:
-
-
- Qt::PlainText
-
-
- Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
-
-
- series
-
-
-
-
-
-
- 5
-
-
-
-
-
- 0
- 0
-
-
-
- List of known series. You can add new series.
-
-
- List of known series. You can add new series.
-
-
- true
-
-
- QComboBox::InsertAlphabetically
-
-
- QComboBox::AdjustToContents
-
-
-
-
-
-
- Remove unused series (Series that have no books)
-
-
- ...
-
-
-
- :/images/trash.svg:/images/trash.svg
-
-
-
-
-
-
-
-
- IS&BN:
-
-
- Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
-
-
- isbn
-
-
-
-
-
-
-
-
-
- Publishe&d:
-
-
- Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
-
-
- pubdate
-
-
-
-
-
-
- true
-
-
-
-
-
-
- false
-
-
- Book
-
-
- 9999.989999999999782
-
-
-
-
-
-
- MMM yyyy
-
-
- true
-
-
-
-
-
-
- true
-
-
-
-
-
-
- dd MMM yyyy
-
-
- true
-
-
-
-
-
-
- &Date:
-
-
- Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
-
-
- date
-
-
-
-
-
-
-
-
-
- &Comments
-
-
-
-
-
- false
-
-
-
-
-
-
-
-
-
- &Fetch metadata from server
-
-
-
-
+
+ 0
+
+
+
+ &Basic metadata
+
+
+
+
+
+ Qt::Horizontal
+
+
+
+
+
+
+ Meta information
+
+
+
+
+
+ &Title:
+
+
+ Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
+
+
+ title
+
+
+
+
+
+
+ Change the title of this book
+
+
+
+
+
+
+ Swap the author and title
+
+
+ ...
+
+
+
+ :/images/swap.svg:/images/swap.svg
+
+
+
+ 16
+ 16
+
+
+
+
+
+
+
+ &Author(s):
+
+
+ Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
+
+
+ authors
+
+
+
+
+
+
+ Author S&ort:
+
+
+ Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
+
+
+ author_sort
+
+
+
+
+
+
+
+
+ Specify how the author(s) of this book should be sorted. For example Charles Dickens should be sorted as Dickens, Charles.
+
+
+
+
+
+
+ Automatically create the author sort entry based on the current author entry
+
+
+ ...
+
+
+
+ :/images/auto_author_sort.svg:/images/auto_author_sort.svg
+
+
+
+
+
+
+
+
+ &Rating:
+
+
+ Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
+
+
+ rating
+
+
+
+
+
+
+ Rating of this book. 0-5 stars
+
+
+ Rating of this book. 0-5 stars
+
+
+ QAbstractSpinBox::PlusMinus
+
+
+ stars
+
+
+ 5
+
+
+
+
+
+
+ &Publisher:
+
+
+ Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
+
+
+ publisher
+
+
+
+
+
+
+ Ta&gs:
+
+
+ Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
+
+
+ tags
+
+
+
+
+
+
+
+
+ Tags categorize the book. This is particularly useful while searching. <br><br>They can be any words or phrases, separated by commas.
+
+
+
+
+
+
+ Open Tag Editor
+
+
+ Open Tag Editor
+
+
+
+ :/images/chapters.svg:/images/chapters.svg
+
+
+
+
+
+
+
+
+ &Series:
+
+
+ Qt::PlainText
+
+
+ Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
+
+
+ series
+
+
+
+
+
+
+ 5
+
+
+
+
+
+ 0
+ 0
+
+
+
+ List of known series. You can add new series.
+
+
+ List of known series. You can add new series.
+
+
+ true
+
+
+ QComboBox::InsertAlphabetically
+
+
+ QComboBox::AdjustToContents
+
+
+
+
+
+
+ Remove unused series (Series that have no books)
+
+
+ ...
+
+
+
+ :/images/trash.svg:/images/trash.svg
+
+
+
+
+
+
+
+
+ IS&BN:
+
+
+ Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
+
+
+ isbn
+
+
+
+
+
+
+
+
+
+ Publishe&d:
+
+
+ Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
+
+
+ pubdate
+
+
+
+
+
+
+ true
+
+
+
+
+
+
+ false
+
+
+ Book
+
+
+ 9999.989999999999782
+
+
+
+
+
+
+ MMM yyyy
+
+
+ true
+
+
+
+
+
+
+ true
+
+
+
+
+
+
+ dd MMM yyyy
+
+
+ true
+
+
+
+
+
+
+ &Date:
+
+
+ Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
+
+
+ date
+
+
+
+
+
+
+
+
+
+ &Comments
+
+
+
+
+
+ false
+
+
+
+
+
+
+
+
+
+ &Fetch metadata from server
+
+
+
+
+
+
+
+
+
+
+
+ 0
+ 0
+
+
+
+ Available Formats
+
+
+
+
+
+
+
+
+ 0
+ 0
+
+
+
+
+ 16777215
+ 130
+
+
+
+ QAbstractItemView::DropOnly
+
+
+
+ 64
+ 64
+
+
+
+
+
+
+
+ Add a new format for this book to the database
+
+
+ ...
+
+
+
+ :/images/add_book.svg:/images/add_book.svg
+
+
+
+ 32
+ 32
+
+
+
+
+
+
+
+ Remove the selected formats for this book from the database.
+
+
+ ...
+
+
+
+ :/images/trash.svg:/images/trash.svg
+
+
+
+ 32
+ 32
+
+
+
+
+
+
+
+ Set the cover for the book from the selected format
+
+
+ ...
+
+
+
+ :/images/book.svg:/images/book.svg
+
+
+
+ 32
+ 32
+
+
+
+
+
+
+
+ Update metadata from the metadata in the selected format
+
+
+
+
+
+
+ :/images/edit_input.svg:/images/edit_input.svg
+
+
+
+ 32
+ 32
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 0
+ 10
+
+
+
+ Book Cover
+
+
+
+
+
+
+ 0
+ 0
+
+
+
+
+
+
+ :/images/book.svg
+
+
+ true
+
+
+
+
+
+
+ 6
+
+
+ QLayout::SetMaximumSize
+
+
+ 0
+
+
+
+
+ Change &cover image:
+
+
+ cover_path
+
+
+
+
+
+
+ 6
+
+
+ 0
+
+
+
+
+ true
+
+
+
+
+
+
+ Browse for an image to use as the cover of this book.
+
+
+ ...
+
+
+
+ :/images/document_open.svg:/images/document_open.svg
+
+
+
+
+
+
+ Reset cover to default
+
+
+ ...
+
+
+
+ :/images/trash.svg:/images/trash.svg
+
+
+
+
+
+
+
+
+
+
+
+
+ Download &cover
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
- 0
- 0
-
-
-
- Available Formats
-
-
-
-
-
-
-
-
- 0
- 0
-
-
-
-
- 16777215
- 130
-
-
-
- QAbstractItemView::DropOnly
-
-
-
- 64
- 64
-
-
-
-
-
-
-
- Add a new format for this book to the database
-
-
- ...
-
-
-
- :/images/add_book.svg:/images/add_book.svg
-
-
-
- 32
- 32
-
-
-
-
-
-
-
- Remove the selected formats for this book from the database.
-
-
- ...
-
-
-
- :/images/trash.svg:/images/trash.svg
-
-
-
- 32
- 32
-
-
-
-
-
-
-
- Set the cover for the book from the selected format
-
-
- ...
-
-
-
- :/images/book.svg:/images/book.svg
-
-
-
- 32
- 32
-
-
-
-
-
-
-
- Update metadata from the metadata in the selected format
-
-
-
-
-
-
- :/images/edit_input.svg:/images/edit_input.svg
-
-
-
- 32
- 32
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 0
- 10
-
-
-
- Book Cover
-
-
-
-
-
-
- 0
- 0
-
-
-
-
-
-
- :/images/book.svg
-
-
- true
-
-
-
-
-
-
- 6
-
-
- QLayout::SetMaximumSize
-
-
- 0
-
-
-
-
- Change &cover image:
-
-
- cover_path
-
-
-
-
-
-
- 6
-
-
- 0
-
-
-
-
- true
-
-
-
-
-
-
- Browse for an image to use as the cover of this book.
-
-
- ...
-
-
-
- :/images/document_open.svg:/images/document_open.svg
-
-
-
-
-
-
- Reset cover to default
-
-
- ...
-
-
-
- :/images/trash.svg:/images/trash.svg
-
-
-
-
-
-
-
-
-
-
-
-
- Download &cover
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+ &Custom metadata
+
+
+
diff --git a/src/calibre/gui2/library.py b/src/calibre/gui2/library.py
index c1a8057844..97d66c3856 100644
--- a/src/calibre/gui2/library.py
+++ b/src/calibre/gui2/library.py
@@ -12,14 +12,14 @@ from PyQt4.QtGui import QTableView, QAbstractItemView, QColor, \
QPen, QStyle, QPainter, QStyleOptionViewItemV4, \
QIcon, QImage, QMenu, \
QStyledItemDelegate, QCompleter, QIntValidator, \
- QDoubleValidator, QCheckBox
+ QDoubleValidator, QComboBox
from PyQt4.QtCore import QAbstractTableModel, QVariant, Qt, pyqtSignal, \
SIGNAL, QObject, QSize, QModelIndex, QDate
from calibre import strftime
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.gui2 import NONE, TableView, config, error_dialog
+from calibre.gui2 import NONE, TableView, config, error_dialog, UNDEFINED_DATE
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
@@ -29,6 +29,7 @@ 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 RatingDelegate(QStyledItemDelegate):
COLOR = QColor("blue")
SIZE = 16
@@ -79,14 +80,14 @@ class RatingDelegate(QStyledItemDelegate):
painter.setRenderHint(QPainter.Antialiasing)
painter.setClipRect(option.rect)
y = option.rect.center().y()-self.SIZE/2.
- x = option.rect.right() - self.SIZE
+ x = option.rect.left()
painter.setPen(self.PEN)
painter.setBrush(self.brush)
painter.translate(x, y)
i = 0
while i < num:
draw_star()
- painter.translate(-self.SIZE, 0)
+ painter.translate(self.SIZE, 0)
i += 1
except:
traceback.print_exc()
@@ -99,8 +100,11 @@ class RatingDelegate(QStyledItemDelegate):
return sb
class DateDelegate(QStyledItemDelegate):
+
def displayText(self, val, locale):
d = val.toDate()
+ if d == UNDEFINED_DATE:
+ return ''
return d.toString('dd MMM yyyy')
def createEditor(self, parent, option, index):
@@ -109,18 +113,24 @@ class DateDelegate(QStyledItemDelegate):
if 'yyyy' not in stdformat:
stdformat = stdformat.replace('yy', 'yyyy')
qde.setDisplayFormat(stdformat)
- qde.setMinimumDate(QDate(101,1,1))
+ qde.setMinimumDate(UNDEFINED_DATE)
+ qde.setSpecialValueText(_('Undefined'))
qde.setCalendarPopup(True)
return qde
class PubDateDelegate(QStyledItemDelegate):
+
def displayText(self, val, locale):
- return val.toDate().toString('MMM yyyy')
+ d = val.toDate()
+ if d == UNDEFINED_DATE:
+ return ''
+ return d.toString('MMM yyyy')
def createEditor(self, parent, option, index):
qde = QStyledItemDelegate.createEditor(self, parent, option, index)
qde.setDisplayFormat('MM yyyy')
- qde.setMinimumDate(QDate(101,1,1))
+ qde.setMinimumDate(UNDEFINED_DATE)
+ qde.setSpecialValueText(_('Undefined'))
qde.setCalendarPopup(True)
return qde
@@ -217,16 +227,19 @@ class CcBoolDelegate(QStyledItemDelegate):
QStyledItemDelegate.__init__(self, parent)
def createEditor(self, parent, option, index):
- editor = QCheckBox(parent)
+ editor = QComboBox(parent)
+ items = [_('Y'), _('N'), ' ']
+ icons = [I('ok.svg'), I('list_remove.svg'), I('blank.svg')]
if tweaks['bool_custom_columns_are_tristate'] == 'no':
- pass
- else:
- if tweaks['bool_custom_columns_are_tristate'] == 'yes':
- editor.setTristate(True)
+ items = items[:-1]
+ icons = icons[:-1]
+ for icon, text in zip(icons, items):
+ editor.addItem(QIcon(icon), text)
return editor
def setModelData(self, editor, model, index):
- model.setData(index, QVariant(editor.checkState()), Qt.EditRole)
+ val = {0:True, 1:False, 2:None}[editor.currentIndex()]
+ model.setData(index, QVariant(val), Qt.EditRole)
def setEditorData(self, editor, index):
m = index.model()
@@ -234,10 +247,10 @@ class CcBoolDelegate(QStyledItemDelegate):
# 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
+ val = 1 if not val else 0
else:
- val = Qt.PartiallyChecked if val is None else Qt.Unchecked if not val else Qt.Checked
- editor.setCheckState(val)
+ val = 2 if val is None else 1 if not val else 0
+ editor.setCurrentIndex(val)
class BooksModel(QAbstractTableModel):
@@ -692,7 +705,7 @@ class BooksModel(QAbstractTableModel):
if val is not None:
return QVariant(QDate(val))
else:
- return QVariant(QDate())
+ return QVariant(UNDEFINED_DATE)
def bool_type(r, idx=-1):
return None # displayed using a decorator
@@ -816,8 +829,7 @@ class BooksModel(QAbstractTableModel):
val = 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
+ val = value.toPyObject()
elif typ == 'rating':
val = value.toInt()[0]
val = 0 if val < 0 else 5 if val > 5 else val
diff --git a/src/calibre/gui2/search_box.py b/src/calibre/gui2/search_box.py
index 4303881f02..8770758eeb 100644
--- a/src/calibre/gui2/search_box.py
+++ b/src/calibre/gui2/search_box.py
@@ -73,7 +73,7 @@ class SearchBox2(QComboBox):
self.setInsertPolicy(self.NoInsert)
self.setMaxCount(self.MAX_COUNT)
self.setSizeAdjustPolicy(self.AdjustToMinimumContentsLengthWithIcon)
- self.setMinimumContentsLength(50)
+ self.setMinimumContentsLength(25)
def initialize(self, opt_name, colorize=False,
help_text=_('Search')):
@@ -306,4 +306,4 @@ class SavedSearchBox(QComboBox):
idx = self.currentIndex();
if idx < 0:
return
- self.search_box.set_search_string(self.saved_searches.lookup(unicode(self.currentText())))
\ No newline at end of file
+ self.search_box.set_search_string(self.saved_searches.lookup(unicode(self.currentText())))
diff --git a/src/calibre/gui2/widgets.py b/src/calibre/gui2/widgets.py
index db5f222408..33fff1bfcb 100644
--- a/src/calibre/gui2/widgets.py
+++ b/src/calibre/gui2/widgets.py
@@ -530,7 +530,7 @@ class BasicList(QListWidget):
class LineEditECM(object):
'''
- Extend the contenxt menu of a QLineEdit to include more actions.
+ Extend the context menu of a QLineEdit to include more actions.
'''
def contextMenuEvent(self, event):
@@ -659,7 +659,7 @@ class EnComboBox(QComboBox):
'''
Enhanced QComboBox.
- Includes an extended content menu.
+ Includes an extended context menu.
'''
def __init__(self, *args):
From d67541f26d42dee61cb5f6ef55f84be2ab254753 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Wed, 5 May 2010 08:44:05 -0600
Subject: [PATCH 038/324] ...
---
src/calibre/gui2/custom_column_widgets.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/calibre/gui2/custom_column_widgets.py b/src/calibre/gui2/custom_column_widgets.py
index 91a13e9236..e366dbb7f2 100644
--- a/src/calibre/gui2/custom_column_widgets.py
+++ b/src/calibre/gui2/custom_column_widgets.py
@@ -98,7 +98,7 @@ class Float(Int):
self.widgets = [QLabel('&'+self.col_metadata['name']+':', parent),
QDoubleSpinBox(parent)]
w = self.widgets[1]
- self.setRange(-100., float(sys.maxint))
+ w.setRange(-100., float(sys.maxint))
w.setDecimals(2)
class Rating(Int):
From 8d402a2a4c7b3cb79bbb34d96d17d6be039899b3 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Wed, 5 May 2010 09:01:03 -0600
Subject: [PATCH 039/324] Move comments to bottom of field list
---
src/calibre/gui2/custom_column_widgets.py | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
diff --git a/src/calibre/gui2/custom_column_widgets.py b/src/calibre/gui2/custom_column_widgets.py
index e366dbb7f2..0e4cf12e4c 100644
--- a/src/calibre/gui2/custom_column_widgets.py
+++ b/src/calibre/gui2/custom_column_widgets.py
@@ -240,7 +240,13 @@ widgets = {
def populate_single_metadata_page(left, right, db, book_id, parent=None):
x = db.custom_column_num_map
cols = list(x)
- cols.sort(cmp=lambda z,y: cmp(x[z]['name'].lower(), x[y]['name'].lower()))
+ def field_sort(y, z):
+ m1, m2 = x[y], x[z]
+ n1 = 'zzzzz' if m1['datatype'] == 'comments' else m1['name']
+ n2 = 'zzzzz' if m2['datatype'] == 'comments' else m2['name']
+ return cmp(n1.lower(), n2.lower())
+
+ cols.sort(cmp=field_sort)
ans = []
for i, col in enumerate(cols):
w = widgets[x[col]['datatype']](db, col, parent)
From 228960b96c554ad05cee0d6b4325a38ae49d8b80 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Wed, 5 May 2010 09:08:25 -0600
Subject: [PATCH 040/324] Fix tab order
---
src/calibre/gui2/dialogs/metadata_single.py | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/src/calibre/gui2/dialogs/metadata_single.py b/src/calibre/gui2/dialogs/metadata_single.py
index 8c5d3e6c41..95a2102cc1 100644
--- a/src/calibre/gui2/dialogs/metadata_single.py
+++ b/src/calibre/gui2/dialogs/metadata_single.py
@@ -426,6 +426,10 @@ class MetadataSingleDialog(ResizableDialog, Ui_MetadataSingleDialog):
sip.delete(w.layout())
w.setLayout(top_layout)
self.__custom_col_layouts = [top_layout, left_layout, right_layout]
+ ans = self.custom_column_widgets
+ for i in range(len(ans)-1):
+ w.setTabOrder(ans[i].widgets[-1], ans[i+1].widgets[-1])
+
def validate_isbn(self, isbn):
From 6e9f81954a5c86ba69b040d9242dc1ebc48ebf44 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Wed, 5 May 2010 09:16:46 -0600
Subject: [PATCH 041/324] Allow tabbing across comments fields
---
src/calibre/gui2/custom_column_widgets.py | 1 +
src/calibre/gui2/dialogs/metadata_single.ui | 3 +++
2 files changed, 4 insertions(+)
diff --git a/src/calibre/gui2/custom_column_widgets.py b/src/calibre/gui2/custom_column_widgets.py
index 0e4cf12e4c..287d89aa84 100644
--- a/src/calibre/gui2/custom_column_widgets.py
+++ b/src/calibre/gui2/custom_column_widgets.py
@@ -160,6 +160,7 @@ class Comments(Base):
self._layout = QVBoxLayout()
self._tb = QPlainTextEdit(self._box)
self._tb.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
+ self._tb.setTabChangesFocus()
self._layout.addWidget(self._tb)
self._box.setLayout(self._layout)
self.widgets = [self._box]
diff --git a/src/calibre/gui2/dialogs/metadata_single.ui b/src/calibre/gui2/dialogs/metadata_single.ui
index 6d8dcca615..36375589a7 100644
--- a/src/calibre/gui2/dialogs/metadata_single.ui
+++ b/src/calibre/gui2/dialogs/metadata_single.ui
@@ -416,6 +416,9 @@
+
+ true
+ false
From 941849c58ff9a960a0f5df79e8a1fe2e55ea496c Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Wed, 5 May 2010 10:03:58 -0600
Subject: [PATCH 042/324] ...
---
src/calibre/gui2/custom_column_widgets.py | 5 +-
src/calibre/gui2/dialogs/metadata_bulk.py | 11 +-
src/calibre/gui2/dialogs/metadata_bulk.ui | 498 +++++++++++-----------
3 files changed, 265 insertions(+), 249 deletions(-)
diff --git a/src/calibre/gui2/custom_column_widgets.py b/src/calibre/gui2/custom_column_widgets.py
index 287d89aa84..79faff3bb9 100644
--- a/src/calibre/gui2/custom_column_widgets.py
+++ b/src/calibre/gui2/custom_column_widgets.py
@@ -160,7 +160,7 @@ class Comments(Base):
self._layout = QVBoxLayout()
self._tb = QPlainTextEdit(self._box)
self._tb.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
- self._tb.setTabChangesFocus()
+ self._tb.setTabChangesFocus(True)
self._layout.addWidget(self._tb)
self._box.setLayout(self._layout)
self.widgets = [self._box]
@@ -275,3 +275,6 @@ def populate_single_metadata_page(left, right, db, book_id, parent=None):
return ans, items
+def populate_bulk_metadata_page(left, right, db, book_id, parent=None):
+ pass
+
diff --git a/src/calibre/gui2/dialogs/metadata_bulk.py b/src/calibre/gui2/dialogs/metadata_bulk.py
index 5909f56c28..3e2f98af71 100644
--- a/src/calibre/gui2/dialogs/metadata_bulk.py
+++ b/src/calibre/gui2/dialogs/metadata_bulk.py
@@ -10,6 +10,7 @@ from calibre.gui2.dialogs.metadata_bulk_ui import Ui_MetadataBulkDialog
from calibre.gui2.dialogs.tag_editor import TagEditor
from calibre.ebooks.metadata import string_to_authors, authors_to_sort_string, \
authors_to_string
+from calibre.gui2.custom_column_widgets import populate_bulk_metadata_page
class MetadataBulkDialog(QDialog, Ui_MetadataBulkDialog):
@@ -19,7 +20,8 @@ class MetadataBulkDialog(QDialog, Ui_MetadataBulkDialog):
self.setupUi(self)
self.db = db
self.ids = [db.id(r) for r in rows]
- self.groupBox.setTitle(_('Editing meta information for %d books') %
+ self.box_title.setText('
' +
+ _('Editing meta information for %d books') %
len(rows))
self.write_series = False
self.changed = False
@@ -38,9 +40,16 @@ class MetadataBulkDialog(QDialog, Ui_MetadataBulkDialog):
QObject.connect(self.series, SIGNAL('currentIndexChanged(int)'), self.series_changed)
QObject.connect(self.series, SIGNAL('editTextChanged(QString)'), self.series_changed)
QObject.connect(self.tag_editor_button, SIGNAL('clicked()'), self.tag_editor)
+ if len(db.custom_column_label_map) == 0:
+ self.central_widget.tabBar().setVisible(False)
+ else:
+ self.create_custom_column_editors()
self.exec_()
+ def create_custom_column_editors(self):
+ pass
+
def initialize_combos(self):
self.initalize_authors()
self.initialize_series()
diff --git a/src/calibre/gui2/dialogs/metadata_bulk.ui b/src/calibre/gui2/dialogs/metadata_bulk.ui
index 01b5fc0adb..a69c02dbc4 100644
--- a/src/calibre/gui2/dialogs/metadata_bulk.ui
+++ b/src/calibre/gui2/dialogs/metadata_bulk.ui
@@ -6,8 +6,8 @@
00
- 495
- 468
+ 526
+ 499
@@ -18,6 +18,16 @@
:/images/edit_input.svg:/images/edit_input.svg
+
+
+
+
+
+
+ Qt::AlignCenter
+
+
+
@@ -27,239 +37,249 @@
0
-
-
- Meta information
+
+
+ 0
-
-
-
-
- &Author(s):
-
-
- Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
-
-
- authors
-
-
-
-
-
-
- A&utomatically set author sort
-
-
-
-
-
-
- Author s&ort:
-
-
- Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
-
-
- author_sort
-
-
-
-
-
-
- Specify how the author(s) of this book should be sorted. For example Charles Dickens should be sorted as Dickens, Charles.
-
-
-
-
-
-
- &Rating:
-
-
- Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
-
-
- rating
-
-
-
-
-
-
- Rating of this book. 0-5 stars
-
-
- Rating of this book. 0-5 stars
-
-
- QAbstractSpinBox::PlusMinus
-
-
- No change
-
-
- stars
-
-
- -1
-
-
- 5
-
-
- -1
-
-
-
-
-
-
- &Publisher:
-
-
- Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
-
-
- publisher
-
-
-
-
-
-
- true
-
-
-
-
-
-
- Add ta&gs:
-
-
- Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
-
-
- tags
-
-
-
-
-
-
- Tags categorize the book. This is particularly useful while searching. <br><br>They can be any words or phrases, separated by commas.
-
-
-
-
-
-
- Open Tag Editor
-
-
- Open Tag Editor
-
-
-
- :/images/chapters.svg:/images/chapters.svg
-
-
-
-
-
-
- &Remove tags:
-
-
- remove_tags
-
-
-
-
-
-
- Comma separated list of tags to remove from the books.
-
-
-
-
-
-
- &Series:
-
-
- Qt::PlainText
-
-
- Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
-
-
- series
-
-
-
-
-
-
- List of known series. You can add new series.
-
-
- List of known series. You can add new series.
-
-
- true
-
-
- QComboBox::InsertAlphabetically
-
-
- QComboBox::AdjustToContents
-
-
-
-
-
-
- Remove &format:
-
-
- remove_format
-
-
-
-
-
-
-
-
-
- true
-
-
-
-
-
-
- &Swap title and author
-
-
-
-
-
-
- Selected books will be automatically numbered,
+
+
+ &Basic metadata
+
+
+
+
+
+ &Author(s):
+
+
+ Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
+
+
+ authors
+
+
+
+
+
+
+ A&utomatically set author sort
+
+
+
+
+
+
+ Author s&ort:
+
+
+ Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
+
+
+ author_sort
+
+
+
+
+
+
+ Specify how the author(s) of this book should be sorted. For example Charles Dickens should be sorted as Dickens, Charles.
+
+
+
+
+
+
+ &Rating:
+
+
+ Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
+
+
+ rating
+
+
+
+
+
+
+ Rating of this book. 0-5 stars
+
+
+ Rating of this book. 0-5 stars
+
+
+ QAbstractSpinBox::PlusMinus
+
+
+ No change
+
+
+ stars
+
+
+ -1
+
+
+ 5
+
+
+ -1
+
+
+
+
+
+
+ &Publisher:
+
+
+ Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
+
+
+ publisher
+
+
+
+
+
+
+ true
+
+
+
+
+
+
+ Add ta&gs:
+
+
+ Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
+
+
+ tags
+
+
+
+
+
+
+ Tags categorize the book. This is particularly useful while searching. <br><br>They can be any words or phrases, separated by commas.
+
+
+
+
+
+
+ Open Tag Editor
+
+
+ Open Tag Editor
+
+
+
+ :/images/chapters.svg:/images/chapters.svg
+
+
+
+
+
+
+ &Remove tags:
+
+
+ remove_tags
+
+
+
+
+
+
+ Comma separated list of tags to remove from the books.
+
+
+
+
+
+
+ &Series:
+
+
+ Qt::PlainText
+
+
+ Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
+
+
+ series
+
+
+
+
+
+
+ List of known series. You can add new series.
+
+
+ List of known series. You can add new series.
+
+
+ true
+
+
+ QComboBox::InsertAlphabetically
+
+
+ QComboBox::AdjustToContents
+
+
+
+
+
+
+ Remove &format:
+
+
+ remove_format
+
+
+
+
+
+
+
+
+
+ true
+
+
+
+
+
+
+ &Swap title and author
+
+
+
+
+
+
+ Selected books will be automatically numbered,
in the order you selected them.
So if you selected Book A and then Book B,
Book A will have series number 1 and Book B series number 2.
-
-
- Automatically number books in this series
-
-
-
-
+
+
+ Automatically number books in this series
+
+
+
+
+
+
+
+ &Custom metadata
+
+
@@ -342,21 +362,5 @@ Book A will have series number 1 and Book B series number 2.
-
- auto_author_sort
- toggled(bool)
- author_sort
- setDisabled(bool)
-
-
- 240
- 95
-
-
- 240
- 113
-
-
-
From b64092f881e15d2cddb4370576558273344b101f Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Wed, 5 May 2010 12:17:58 -0600
Subject: [PATCH 043/324] Move update check into its own thread
---
src/calibre/gui2/ui.py | 8 +++----
src/calibre/gui2/update.py | 46 +++++++++++++++++---------------------
2 files changed, 25 insertions(+), 29 deletions(-)
diff --git a/src/calibre/gui2/ui.py b/src/calibre/gui2/ui.py
index b35270f963..ccbe04db9f 100644
--- a/src/calibre/gui2/ui.py
+++ b/src/calibre/gui2/ui.py
@@ -258,9 +258,9 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
self.device_info = ' '
if not opts.no_update_check:
self.update_checker = CheckForUpdates(self)
- QObject.connect(self.update_checker,
- SIGNAL('update_found(PyQt_PyObject)'), self.update_found)
- self.update_checker.start(2000)
+ self.update_checker.update_found.connect(self.update_found,
+ type=Qt.QueuedConnection)
+ self.update_checker.start()
####################### Status Bar #####################
self.status_bar.initialize(self.system_tray_icon)
self.status_bar.show_book_info.connect(self.show_book_info)
@@ -2493,7 +2493,7 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
if write_settings:
self.write_settings()
self.check_messages_timer.stop()
- self.update_checker.stop()
+ self.update_checker.terminate()
self.listener.close()
self.job_manager.server.close()
while self.spare_servers:
diff --git a/src/calibre/gui2/update.py b/src/calibre/gui2/update.py
index 69337bb494..92e9db1cf2 100644
--- a/src/calibre/gui2/update.py
+++ b/src/calibre/gui2/update.py
@@ -3,7 +3,7 @@ __copyright__ = '2008, Kovid Goyal '
import traceback
-from PyQt4.QtCore import QObject, SIGNAL, QTimer
+from PyQt4.QtCore import QThread, pyqtSignal
import mechanize
from calibre.constants import __version__, iswindows, isosx
@@ -12,31 +12,27 @@ from calibre.utils.config import prefs
URL = 'http://status.calibre-ebook.com/latest'
-class CheckForUpdates(QObject):
+class CheckForUpdates(QThread):
+
+ update_found = pyqtSignal(object)
+ INTERVAL = 24*60*60
def __init__(self, parent):
- QObject.__init__(self, parent)
- self.timer = QTimer(self)
- self.first = True
- self.connect(self.timer, SIGNAL('timeout()'), self)
- self.start = self.timer.start
- self.stop = self.timer.stop
+ QThread.__init__(self, parent)
- def __call__(self):
- if self.first:
- self.timer.setInterval(1000*24*60*60)
- self.first = False
-
- try:
- br = browser()
- req = mechanize.Request(URL)
- req.add_header('CALIBRE_VERSION', __version__)
- req.add_header('CALIBRE_OS',
- 'win' if iswindows else 'osx' if isosx else 'oth')
- req.add_header('CALIBRE_INSTALL_UUID', prefs['installation_uuid'])
- version = br.open(req).read().strip()
- if version and version != __version__:
- self.emit(SIGNAL('update_found(PyQt_PyObject)'), version)
- except:
- traceback.print_exc()
+ def run(self):
+ while True:
+ try:
+ br = browser()
+ req = mechanize.Request(URL)
+ req.add_header('CALIBRE_VERSION', __version__)
+ req.add_header('CALIBRE_OS',
+ 'win' if iswindows else 'osx' if isosx else 'oth')
+ req.add_header('CALIBRE_INSTALL_UUID', prefs['installation_uuid'])
+ version = br.open(req).read().strip()
+ if version and version != __version__:
+ self.update_found.emit(version)
+ except:
+ traceback.print_exc()
+ self.sleep(self.INTERVAL)
From a1c9fa36714d999fba054cbd4a305e982a93bcbc Mon Sep 17 00:00:00 2001
From: Charles Haley <>
Date: Wed, 5 May 2010 19:21:21 +0100
Subject: [PATCH 044/324] Add ratings to tag browser. Fix searching for ratings
from tag browser by counting the stars. Add relational searching for ratings
(e.g., rating:>3).
---
src/calibre/gui2/tag_view.py | 21 ++++---
src/calibre/library/caches.py | 77 ++++++++++++++++++++------
src/calibre/library/database2.py | 3 +-
src/calibre/library/schema_upgrades.py | 8 ++-
4 files changed, 82 insertions(+), 27 deletions(-)
diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py
index 2671b16580..6b5285e6cd 100644
--- a/src/calibre/gui2/tag_view.py
+++ b/src/calibre/gui2/tag_view.py
@@ -194,15 +194,17 @@ class TagTreeItem(object):
class TagsModel(QAbstractItemModel):
- categories_orig = [_('Authors'), _('Series'), _('Formats'), _('Publishers'), _('News'), _('All tags')]
- row_map_orig = ['author', 'series', 'format', 'publisher', 'news', 'tag']
+ categories_orig = [_('Authors'), _('Series'), _('Formats'), _('Publishers'),
+ _('Ratings'), _('News'), _('All tags')]
+ row_map_orig = ['author', 'series', 'format', 'publisher', 'rating',
+ 'news', 'tag']
tags_categories_start= 5
search_keys=['search', _('Searches')]
def __init__(self, db, parent=None):
QAbstractItemModel.__init__(self, parent)
self.cat_icon_map_orig = list(map(QIcon, [I('user_profile.svg'),
- I('series.svg'), I('book.svg'), I('publisher.png'),
+ I('series.svg'), I('book.svg'), I('publisher.png'), I('star.png'),
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'))
@@ -430,9 +432,12 @@ class TagsModel(QAbstractItemModel):
if tag.state > 0:
prefix = ' not ' if tag.state == 2 else ''
category = key if key != 'news' 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))
+ if tag.name[0] == u'\u2605': # char is a star. Assume rating
+ ans.append('%s%s:%s'%(prefix, category, len(tag.name)))
+ else:
+ 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
diff --git a/src/calibre/library/caches.py b/src/calibre/library/caches.py
index dfa39ad869..b161e8ec02 100644
--- a/src/calibre/library/caches.py
+++ b/src/calibre/library/caches.py
@@ -158,9 +158,10 @@ class ResultCache(SearchQueryParser):
SearchQueryParser.__init__(self,
locations=SearchQueryParser.DEFAULT_LOCATIONS +
[c for c in cc_label_map])
- self.build_relop_dict()
+ self.build_date_relop_dict()
+ self.build_rating_relop_dict()
- def build_relop_dict(self):
+ def build_date_relop_dict(self):
'''
Because the database dates have time in them, we can't use direct
comparisons even when field_count == 3. The query has time = 0, but
@@ -204,9 +205,18 @@ class ResultCache(SearchQueryParser):
def relop_le(db, query, field_count):
return not relop_gt(db, query, field_count)
- self.search_relops = {'=':[1, relop_eq], '>':[1, relop_gt], '<':[1, relop_lt], \
+ self.date_search_relops = {'=':[1, relop_eq], '>':[1, relop_gt], '<':[1, relop_lt], \
'!=':[2, relop_ne], '>=':[2, relop_ge], '<=':[2, relop_le]}
+ def build_rating_relop_dict(self):
+ self.rating_search_relops = {
+ '=':[1, lambda r, q: r == q],
+ '>':[1, lambda r, q: r > q],
+ '<':[1, lambda r, q: r < q],
+ '!=':[2, lambda r, q: r != q],
+ '>=':[2, lambda r, q: r >= q],
+ '<=':[2, lambda r, q: r <= q]}
+
def __getitem__(self, row):
return self._data[self._map_filtered[row]]
@@ -220,17 +230,17 @@ 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):
+ def get_dates_matches(self, location, query):
matches = set([])
if len(query) < 2:
return matches
relop = None
- for k in self.search_relops.keys():
+ for k in self.date_search_relops.keys():
if query.startswith(k):
- (p, relop) = self.search_relops[k]
+ (p, relop) = self.date_search_relops[k]
query = query[p:]
if relop is None:
- (p, relop) = self.search_relops['=']
+ (p, relop) = self.date_search_relops['=']
if location in self.custom_column_label_map:
loc = self.FIELD_MAP[self.custom_column_label_map[location]['num']]
else:
@@ -267,6 +277,32 @@ class ResultCache(SearchQueryParser):
matches.add(item[0])
return matches
+ def get_ratings_matches(self, location, query):
+ matches = set([])
+ if len(query) == 0:
+ return matches
+ relop = None
+ for k in self.rating_search_relops.keys():
+ if query.startswith(k):
+ (p, relop) = self.rating_search_relops[k]
+ query = query[p:]
+ if relop is None:
+ (p, relop) = self.rating_search_relops['=']
+ try:
+ r = int(query)
+ except:
+ return matches
+ if location in self.custom_column_label_map:
+ loc = self.FIELD_MAP[self.custom_column_label_map[location]['num']]
+ else:
+ loc = self.FIELD_MAP['rating']
+
+ for item in self._data:
+ if item is None or item[loc] is None: continue
+ if relop(item[loc]/2, r):
+ matches.add(item[0])
+ return matches
+
def get_matches(self, location, query):
matches = set([])
if query and query.strip():
@@ -276,7 +312,13 @@ class ResultCache(SearchQueryParser):
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)
+ return self.get_dates_matches(location, query)
+
+ ### take care of ratings special case
+ if location == 'rating' or \
+ ((location in self.custom_column_label_map) and \
+ self.custom_column_label_map[location]['datatype'] == 'rating'):
+ return self.get_ratings_matches(location, query)
### everything else
matchkind = CONTAINS_MATCH
@@ -297,7 +339,8 @@ class ResultCache(SearchQueryParser):
if location in ('tag', 'author', 'format', 'comment'):
location += 's'
- all = ('title', 'authors', 'publisher', 'tags', 'comments', 'series', 'formats', 'isbn', 'rating', 'cover')
+ all = ('title', 'authors', 'publisher', 'tags', 'comments', 'series',
+ 'formats', 'isbn', 'rating', 'cover')
MAP = {}
for x in all: # get the db columns for the standard searchables
@@ -317,21 +360,22 @@ class ResultCache(SearchQueryParser):
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
+ location = [location] if location != 'all' else list(MAP.keys())
+ for i, loc in enumerate(location):
+ location[i] = MAP[loc]
+
# 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
+ ### DB stores authors with commas changed to bars, so change query
+ q = query.replace(',', '|');
else:
q = query
@@ -388,7 +432,8 @@ class ResultCache(SearchQueryParser):
matches.add(item[0])
continue
except:
- continue ## A conversion threw an exception. Because of the type, no further match possible
+ # A conversion threw an exception. Because of the type, no further match possible
+ continue
if loc not in EXCLUDE_FIELDS:
if loc in SPLITABLE_FIELDS:
@@ -397,7 +442,7 @@ class ResultCache(SearchQueryParser):
else:
vals = item[loc].split(',')
else:
- vals = [item[loc]] ### make into list to make _match happy
+ vals = [item[loc]] ### make into list to make _match happy
if _match(q, vals, matchkind):
matches.add(item[0])
continue
diff --git a/src/calibre/library/database2.py b/src/calibre/library/database2.py
index 8e51143ef2..64314d306f 100644
--- a/src/calibre/library/database2.py
+++ b/src/calibre/library/database2.py
@@ -126,8 +126,9 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
'publishers': ['publisher', 'name'],
'authors' : ['author', 'name'],
'news' : ['news', 'name'],
+ 'ratings' : ['rating', 'rating']
}
- self.tag_browser_formatters = {}
+ self.tag_browser_formatters = {'ratings': lambda x:u'\u2605'*int(round(x/2.))}
self.connect()
self.is_case_sensitive = not iswindows and not isosx and \
diff --git a/src/calibre/library/schema_upgrades.py b/src/calibre/library/schema_upgrades.py
index d4b4d3f9ad..f1e68b3916 100644
--- a/src/calibre/library/schema_upgrades.py
+++ b/src/calibre/library/schema_upgrades.py
@@ -273,6 +273,12 @@ class SchemaUpgrade(object):
'Add restricted Tag Browser views'
def create_tag_browser_view(table_name, column_name, view_column_name):
script = ('''
+ DROP VIEW IF EXISTS tag_browser_{tn};
+ CREATE VIEW tag_browser_{tn} AS SELECT
+ id,
+ {vcn},
+ (SELECT COUNT(id) FROM books_{tn}_link WHERE {cn}={tn}.id) count
+ FROM {tn};
DROP VIEW IF EXISTS tag_browser_filtered_{tn};
CREATE VIEW tag_browser_filtered_{tn} AS SELECT
id,
@@ -286,5 +292,3 @@ class SchemaUpgrade(object):
for tn, cn in self.tag_browser_categories.items():
if tn != 'news':
create_tag_browser_view(tn, cn[0], cn[1])
-
-
From 9e8d7a365349999d0294d81503746d36b6e2c3ec Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Wed, 5 May 2010 12:35:59 -0600
Subject: [PATCH 045/324] Framework for bulk metadata edits of custom columns
---
src/calibre/gui2/custom_column_widgets.py | 129 ++++++++++++++++++----
src/calibre/gui2/dialogs/metadata_bulk.py | 15 ++-
2 files changed, 118 insertions(+), 26 deletions(-)
diff --git a/src/calibre/gui2/custom_column_widgets.py b/src/calibre/gui2/custom_column_widgets.py
index 79faff3bb9..2c5b274d26 100644
--- a/src/calibre/gui2/custom_column_widgets.py
+++ b/src/calibre/gui2/custom_column_widgets.py
@@ -6,6 +6,7 @@ __copyright__ = '2010, Kovid Goyal '
__docformat__ = 'restructuredtext en'
import sys
+from functools import partial
from PyQt4.Qt import QComboBox, QLabel, QSpinBox, QDoubleSpinBox, QDateEdit, \
QDate, QGroupBox, QVBoxLayout, QPlainTextEdit, QSizePolicy, \
@@ -18,10 +19,11 @@ from calibre.utils.config import tweaks
class Base(object):
- def __init__(self, db, col_id):
+ def __init__(self, db, col_id, parent=None):
self.db, self.col_id = db, col_id
self.col_metadata = db.custom_column_num_map[col_id]
self.initial_val = None
+ self.setup_ui(parent)
def initialize(self, book_id):
val = self.db.get_custom(book_id, num=self.col_id, index_is_id=True)
@@ -43,8 +45,7 @@ class Base(object):
class Bool(Base):
- def __init__(self, db, col_id, parent=None):
- Base.__init__(self, db, col_id)
+ def setup_ui(self, parent):
self.widgets = [QLabel('&'+self.col_metadata['name'], parent),
QComboBox(parent)]
w = self.widgets[1]
@@ -69,8 +70,7 @@ class Bool(Base):
class Int(Base):
- def __init__(self, db, col_id, parent=None):
- Base.__init__(self, db, col_id)
+ def setup_ui(self, parent):
self.widgets = [QLabel('&'+self.col_metadata['name']+':', parent),
QSpinBox(parent)]
w = self.widgets[1]
@@ -93,8 +93,7 @@ class Int(Base):
class Float(Int):
- def __init__(self, db, col_id, parent=None):
- Base.__init__(self, db, col_id)
+ def setup_ui(self, parent):
self.widgets = [QLabel('&'+self.col_metadata['name']+':', parent),
QDoubleSpinBox(parent)]
w = self.widgets[1]
@@ -103,8 +102,8 @@ class Float(Int):
class Rating(Int):
- def __init__(self, db, col_id, parent=None):
- Int.__init__(self, db, col_id)
+ def setup_ui(self, parent):
+ Int.setup_ui(self, parent)
w = self.widgets[1]
w.setRange(0, 5)
w.setSuffix(' '+_('stars'))
@@ -125,8 +124,7 @@ class Rating(Int):
class DateTime(Base):
- def __init__(self, db, col_id, parent=None):
- Base.__init__(self, db, col_id)
+ def setup_ui(self, parent):
self.widgets = [QLabel('&'+self.col_metadata['name']+':', parent),
QDateEdit(parent)]
w = self.widgets[1]
@@ -153,8 +151,7 @@ class DateTime(Base):
class Comments(Base):
- def __init__(self, db, col_id, parent=None):
- Base.__init__(self, db, col_id)
+ def setup_ui(self, parent):
self._box = QGroupBox(parent)
self._box.setTitle('&'+self.col_metadata['name'])
self._layout = QVBoxLayout()
@@ -178,9 +175,8 @@ class Comments(Base):
class Text(Base):
- def __init__(self, db, col_id, parent=None):
- Base.__init__(self, db, col_id)
- values = self.all_values = list(self.db.all_custom(num=col_id))
+ def setup_ui(self, parent):
+ values = self.all_values = list(self.db.all_custom(num=self.col_id))
values.sort(cmp = lambda x,y: cmp(x.lower(), y.lower()))
if self.col_metadata['is_multiple']:
w = TagsLineEdit(parent, values)
@@ -238,16 +234,16 @@ widgets = {
'comments': Comments,
}
+def field_sort(y, z, x=None):
+ m1, m2 = x[y], x[z]
+ n1 = 'zzzzz' if m1['datatype'] == 'comments' else m1['name']
+ n2 = 'zzzzz' if m2['datatype'] == 'comments' else m2['name']
+ return cmp(n1.lower(), n2.lower())
+
def populate_single_metadata_page(left, right, db, book_id, parent=None):
x = db.custom_column_num_map
cols = list(x)
- def field_sort(y, z):
- m1, m2 = x[y], x[z]
- n1 = 'zzzzz' if m1['datatype'] == 'comments' else m1['name']
- n2 = 'zzzzz' if m2['datatype'] == 'comments' else m2['name']
- return cmp(n1.lower(), n2.lower())
-
- cols.sort(cmp=field_sort)
+ cols.sort(cmp=partial(field_sort, x=x))
ans = []
for i, col in enumerate(cols):
w = widgets[x[col]['datatype']](db, col, parent)
@@ -275,6 +271,91 @@ def populate_single_metadata_page(left, right, db, book_id, parent=None):
return ans, items
-def populate_bulk_metadata_page(left, right, db, book_id, parent=None):
+class BulkBase(Base):
+
+ def get_initial_value(self, book_ids):
+ values = set([])
+ for book_id in book_ids:
+ val = self.db.get_custom(book_id, num=self.col_id, index_is_id=True)
+ if isinstance(val, list):
+ val = frozenset(val)
+ values.add(val)
+ if len(values) > 1:
+ break
+ ans = None
+ if len(values) == 1:
+ ans = iter(values).next()
+ if isinstance(ans, frozenset):
+ ans = list(ans)
+ return ans
+
+ def initialize(self, book_ids):
+ self.initial_val = val = self.get_initial_value(book_ids)
+ val = self.normalize_db_val(val)
+ self.setter(val)
+
+ def commit(self, book_ids, notify=False):
+ val = self.getter()
+ val = self.normalize_ui_val(val)
+ if val != self.initial_val:
+ for book_id in book_ids:
+ self.db.set_custom(book_id, val, num=self.col_id, notify=notify)
+
+class BulkBool(BulkBase, Bool):
pass
+class BulkRating(BulkBase, Rating):
+ pass
+
+class BulkInt(BulkBase, Int):
+ pass
+
+class BulkFloat(BulkBase, Float):
+ pass
+
+class BulkRating(BulkBase, Rating):
+ pass
+
+class BulkDateTime(BulkBase, DateTime):
+ pass
+
+class BulkText(BulkBase, Text):
+ pass
+
+bulk_widgets = {
+ 'bool' : BulkBool,
+ 'rating' : BulkRating,
+ 'int': BulkInt,
+ 'float': BulkFloat,
+ 'datetime': BulkDateTime,
+ 'text' : BulkText,
+}
+
+def populate_bulk_metadata_page(layout, db, book_ids, parent=None):
+ x = db.custom_column_num_map
+ cols = list(x)
+ cols.sort(cmp=partial(field_sort, x=x))
+ ans = []
+ for i, col in enumerate(cols):
+ dt = x[col]['datatype']
+ if dt == 'comments':
+ continue
+ w = bulk_widgets[dt](db, col, parent)
+ ans.append(w)
+ w.initialize(book_ids)
+ row = layout.rowCount()
+ if len(w.widgets) == 1:
+ layout.addWidget(w.widgets[0], row, 0, 1, -1)
+ else:
+ w.widgets[0].setBuddy(w.widgets[1])
+ for c, widget in enumerate(w.widgets):
+ layout.addWidget(widget, row, c)
+ items = []
+ if len(ans) > 0:
+ items.append(QSpacerItem(10, 10, QSizePolicy.Minimum,
+ QSizePolicy.Expanding))
+ layout.addItem(items[-1], layout.rowCount(), 0, 1, 1)
+ layout.setRowStretch(layout.rowCount()-1, 100)
+
+ return ans, items
+
diff --git a/src/calibre/gui2/dialogs/metadata_bulk.py b/src/calibre/gui2/dialogs/metadata_bulk.py
index 3e2f98af71..10c7387423 100644
--- a/src/calibre/gui2/dialogs/metadata_bulk.py
+++ b/src/calibre/gui2/dialogs/metadata_bulk.py
@@ -3,8 +3,9 @@ __copyright__ = '2008, Kovid Goyal '
'''Dialog to edit metadata in bulk'''
+import sip
from PyQt4.QtCore import SIGNAL, QObject
-from PyQt4.QtGui import QDialog
+from PyQt4.QtGui import QDialog, QGridLayout
from calibre.gui2.dialogs.metadata_bulk_ui import Ui_MetadataBulkDialog
from calibre.gui2.dialogs.tag_editor import TagEditor
@@ -48,7 +49,17 @@ class MetadataBulkDialog(QDialog, Ui_MetadataBulkDialog):
self.exec_()
def create_custom_column_editors(self):
- pass
+ w = self.central_widget.widget(1)
+ layout = QGridLayout()
+
+ self.custom_column_widgets, self.__cc_spacers = populate_bulk_metadata_page(
+ layout, self.db, self.ids, w)
+ #sip.delete(w.layout())
+ w.setLayout(layout)
+ self.__custom_col_layouts = [layout]
+ ans = self.custom_column_widgets
+ for i in range(len(ans)-1):
+ w.setTabOrder(ans[i].widgets[-1], ans[i+1].widgets[-1])
def initialize_combos(self):
self.initalize_authors()
From 02d79e48cfef2c0ff62ebf00cd7e849822f188d6 Mon Sep 17 00:00:00 2001
From: Charles Haley <>
Date: Wed, 5 May 2010 21:23:17 +0100
Subject: [PATCH 046/324] 1) remove unrated items from rating 2) make
rating:false (or somecustomcol:false) match unrated items 3) make rating:true
match rated items
---
src/calibre/library/caches.py | 17 +++++++++++++----
src/calibre/library/database2.py | 3 ++-
2 files changed, 15 insertions(+), 5 deletions(-)
diff --git a/src/calibre/library/caches.py b/src/calibre/library/caches.py
index b161e8ec02..d792f693d2 100644
--- a/src/calibre/library/caches.py
+++ b/src/calibre/library/caches.py
@@ -281,6 +281,10 @@ class ResultCache(SearchQueryParser):
matches = set([])
if len(query) == 0:
return matches
+ if query == 'false':
+ query = '0'
+ elif query == 'true':
+ query = '>0'
relop = None
for k in self.rating_search_relops.keys():
if query.startswith(k):
@@ -298,8 +302,13 @@ class ResultCache(SearchQueryParser):
loc = self.FIELD_MAP['rating']
for item in self._data:
- if item is None or item[loc] is None: continue
- if relop(item[loc]/2, r):
+ if item is None:
+ continue
+ if not item[loc]:
+ i = 0
+ else:
+ i = item[loc]/2
+ if relop(i, r):
matches.add(item[0])
return matches
@@ -312,13 +321,13 @@ class ResultCache(SearchQueryParser):
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_dates_matches(location, query)
+ return self.get_dates_matches(location, query.lower())
### take care of ratings special case
if location == 'rating' or \
((location in self.custom_column_label_map) and \
self.custom_column_label_map[location]['datatype'] == 'rating'):
- return self.get_ratings_matches(location, query)
+ return self.get_ratings_matches(location, query.lower())
### everything else
matchkind = CONTAINS_MATCH
diff --git a/src/calibre/library/database2.py b/src/calibre/library/database2.py
index 64314d306f..931841b8bf 100644
--- a/src/calibre/library/database2.py
+++ b/src/calibre/library/database2.py
@@ -636,7 +636,8 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
tooltip = self.custom_column_label_map[category]['name']
formatter = self.tag_browser_formatters.get(tn, lambda x: x)
categories[category] = [Tag(formatter(r[1]), count=r[2], id=r[0], icon=icon, tooltip = tooltip)
- for r in data if r[2] > 0]
+ for r in data
+ if r[2] > 0 and (category != 'rating' or len(formatter(r[1])) > 0)]
categories['format'] = []
for fmt in self.conn.get('SELECT DISTINCT format FROM data'):
fmt = fmt[0]
From 58b59eb7e41a37ded0beab4567488a422dcf70dc Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Wed, 5 May 2010 14:45:48 -0600
Subject: [PATCH 047/324] ...
---
src/calibre/gui2/custom_column_widgets.py | 18 +++++++++++++++++-
src/calibre/gui2/dialogs/metadata_bulk.py | 5 +++--
2 files changed, 20 insertions(+), 3 deletions(-)
diff --git a/src/calibre/gui2/custom_column_widgets.py b/src/calibre/gui2/custom_column_widgets.py
index 2c5b274d26..35d9f04e1f 100644
--- a/src/calibre/gui2/custom_column_widgets.py
+++ b/src/calibre/gui2/custom_column_widgets.py
@@ -320,7 +320,23 @@ class BulkDateTime(BulkBase, DateTime):
pass
class BulkText(BulkBase, Text):
- pass
+
+ def initialize(self, book_ids):
+ val = self.get_initial_value(book_ids)
+ self.initial_val = val = self.normalize_db_val(val)
+ if self.col_metadata['is_multiple']:
+ self.setter(val)
+ self.widgets[1].update_tags_cache(self.all_values)
+ else:
+ idx = None
+ for i, c in enumerate(self.all_values):
+ if c == val:
+ idx = i
+ self.widgets[1].addItem(c)
+ self.widgets[1].setEditText('')
+ if idx is not None:
+ self.widgets[1].setCurrentIndex(idx)
+
bulk_widgets = {
'bool' : BulkBool,
diff --git a/src/calibre/gui2/dialogs/metadata_bulk.py b/src/calibre/gui2/dialogs/metadata_bulk.py
index 10c7387423..788c8681a6 100644
--- a/src/calibre/gui2/dialogs/metadata_bulk.py
+++ b/src/calibre/gui2/dialogs/metadata_bulk.py
@@ -3,7 +3,6 @@ __copyright__ = '2008, Kovid Goyal '
'''Dialog to edit metadata in bulk'''
-import sip
from PyQt4.QtCore import SIGNAL, QObject
from PyQt4.QtGui import QDialog, QGridLayout
@@ -54,7 +53,6 @@ class MetadataBulkDialog(QDialog, Ui_MetadataBulkDialog):
self.custom_column_widgets, self.__cc_spacers = populate_bulk_metadata_page(
layout, self.db, self.ids, w)
- #sip.delete(w.layout())
w.setLayout(layout)
self.__custom_col_layouts = [layout]
ans = self.custom_column_widgets
@@ -154,6 +152,9 @@ class MetadataBulkDialog(QDialog, Ui_MetadataBulkDialog):
self.db.set_authors(id, new_authors, notify=False)
self.changed = True
+ for w in getattr(self, 'custom_column_widgets', []):
+ w.commit(self.ids)
+
def series_changed(self):
self.write_series = True
From f6a4ec5c57984567f348f9671df11332ecd9757c Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Wed, 5 May 2010 15:05:34 -0600
Subject: [PATCH 048/324] Bulk metadata editing working
---
src/calibre/gui2/custom_column_widgets.py | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/src/calibre/gui2/custom_column_widgets.py b/src/calibre/gui2/custom_column_widgets.py
index 35d9f04e1f..611590f2c7 100644
--- a/src/calibre/gui2/custom_column_widgets.py
+++ b/src/calibre/gui2/custom_column_widgets.py
@@ -218,7 +218,10 @@ class Text(Base):
def getter(self):
if self.col_metadata['is_multiple']:
val = unicode(self.widgets[1].text()).strip()
- return [x.strip() for x in val.split(',')]
+ ans = [x.strip() for x in val.split(',') if x.strip()]
+ if not ans:
+ ans = None
+ return ans
val = unicode(self.widgets[1].currentText()).strip()
if not val:
val = None
From c8a92fb7c5fdb99c82df495c9c9219280a3ec925 Mon Sep 17 00:00:00 2001
From: Charles Haley <>
Date: Thu, 6 May 2010 12:06:17 +0100
Subject: [PATCH 049/324] 1) change editing directly on library to use spinbox
and value constraints 2) fix tag browser to ignore 0-valued rating custom
columns. Also, refactor to save datatype in a map and use this datatype to
find the tag formatter 3) fix tag browser and user category editor to display
author names with comma instead of vertical bar
---
src/calibre/gui2/dialogs/tag_categories.py | 2 +-
src/calibre/gui2/library.py | 16 ++++++++++-----
src/calibre/gui2/tag_view.py | 17 ++++++++++++----
src/calibre/library/custom_columns.py | 4 +---
src/calibre/library/database2.py | 23 ++++++++++++++++------
5 files changed, 43 insertions(+), 19 deletions(-)
diff --git a/src/calibre/gui2/dialogs/tag_categories.py b/src/calibre/gui2/dialogs/tag_categories.py
index e7884bfe75..0e15c06828 100644
--- a/src/calibre/gui2/dialogs/tag_categories.py
+++ b/src/calibre/gui2/dialogs/tag_categories.py
@@ -39,7 +39,7 @@ class TagCategories(QDialog, Ui_TagCategories):
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.replace('|', ',') 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()
diff --git a/src/calibre/gui2/library.py b/src/calibre/gui2/library.py
index 8b47de78bc..9ba58963c4 100644
--- a/src/calibre/gui2/library.py
+++ b/src/calibre/gui2/library.py
@@ -1,7 +1,7 @@
__license__ = 'GPL v3'
__copyright__ = '2008, Kovid Goyal '
-import os, textwrap, traceback, re, shutil, functools
+import os, textwrap, traceback, re, shutil, functools, sys
from operator import attrgetter
from math import cos, sin, pi
@@ -10,7 +10,7 @@ from contextlib import closing
from PyQt4.QtGui import QTableView, QAbstractItemView, QColor, \
QPainterPath, QLinearGradient, QBrush, \
QPen, QStyle, QPainter, QStyleOptionViewItemV4, \
- QIcon, QImage, QMenu, \
+ QIcon, QImage, QMenu, QSpinBox, QDoubleSpinBox, \
QStyledItemDelegate, QCompleter, QIntValidator, \
QDoubleValidator, QComboBox
from PyQt4.QtCore import QAbstractTableModel, QVariant, Qt, pyqtSignal, \
@@ -186,12 +186,18 @@ class CcTextDelegate(QStyledItemDelegate):
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))
+ editor = QSpinBox(parent)
+ editor.setRange(-100, sys.maxint)
+ editor.setSpecialValueText(_('Undefined'))
+ editor.setSingleStep(1)
elif typ == 'float':
- editor.setValidator(QDoubleValidator(parent))
+ editor = QDoubleSpinBox(parent)
+ editor.setSpecialValueText(_('Undefined'))
+ editor.setRange(-100., float(sys.maxint))
+ editor.setDecimals(2)
else:
+ editor = EnLineEdit(parent)
complete_items = sorted(list(m.db.all_custom(label=col)))
completer = QCompleter(complete_items, self)
completer.setCaseSensitivity(Qt.CaseInsensitive)
diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py
index 6b5285e6cd..5d85dec0cb 100644
--- a/src/calibre/gui2/tag_view.py
+++ b/src/calibre/gui2/tag_view.py
@@ -195,10 +195,10 @@ class TagTreeItem(object):
class TagsModel(QAbstractItemModel):
categories_orig = [_('Authors'), _('Series'), _('Formats'), _('Publishers'),
- _('Ratings'), _('News'), _('All tags')]
+ _('Ratings'), _('News'), _('Tags')]
row_map_orig = ['author', 'series', 'format', 'publisher', 'rating',
'news', 'tag']
- tags_categories_start= 5
+ tags_categories_start= 7
search_keys=['search', _('Searches')]
def __init__(self, db, parent=None):
@@ -231,7 +231,11 @@ class TagsModel(QAbstractItemModel):
self.row_map = []
self.categories = []
# 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)]
+ if self.tags_categories_start < len(self.row_map_orig):
+ self.cat_icon_map = self.cat_icon_map_orig[:self.tags_categories_start-len(self.row_map_orig)]
+ else:
+ self.cat_icon_map = self.cat_icon_map_orig[:]
+
self.user_categories = dict.copy(config['user_categories'])
column_map = config['column_map']
@@ -256,12 +260,17 @@ class TagsModel(QAbstractItemModel):
self.categories.append(self.categories_orig[i])
self.cat_icon_map.append(self.cat_icon_map_orig[i])
+ # Clean up the author's tags, getting rid of the '|' characters
+ if data['author'] is not None:
+ for t in data['author']:
+ t.name = t.name.replace('|', ',')
+
# 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:
- taglist[c] = dict(map(lambda t:(t.name if c != 'author' else t.name.replace('|', ','), t), data[c]))
+ taglist[c] = dict(map(lambda t:(t.name, t), data[c]))
for c in self.user_categories:
l = []
diff --git a/src/calibre/library/custom_columns.py b/src/calibre/library/custom_columns.py
index e721a825c8..a8375c6b5c 100644
--- a/src/calibre/library/custom_columns.py
+++ b/src/calibre/library/custom_columns.py
@@ -145,9 +145,7 @@ class CustomColumns(object):
if v['normalized']:
tn = 'custom_column_{0}'.format(i)
self.tag_browser_categories[tn] = [v['label'], 'value']
- if v['datatype'] == 'rating':
- self.tag_browser_formatters[tn] = lambda x:u'\u2605'*int(round(x/2.))
-
+ self.tag_browser_datatype[v['label']] = v['datatype']
def get_custom(self, idx, label=None, num=None, index_is_id=False):
if label is not None:
diff --git a/src/calibre/library/database2.py b/src/calibre/library/database2.py
index 931841b8bf..9f9a42f700 100644
--- a/src/calibre/library/database2.py
+++ b/src/calibre/library/database2.py
@@ -128,7 +128,16 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
'news' : ['news', 'name'],
'ratings' : ['rating', 'rating']
}
- self.tag_browser_formatters = {'ratings': lambda x:u'\u2605'*int(round(x/2.))}
+ self.tag_browser_datatype = {
+ 'tag' : 'textmult',
+ 'series' : None,
+ 'publisher' : 'text',
+ 'author' : 'text',
+ 'news' : None,
+ 'rating' : 'rating',
+ }
+
+ self.tag_browser_formatters = {'rating': lambda x:u'\u2605'*int(round(x/2.))}
self.connect()
self.is_case_sensitive = not iswindows and not isosx and \
@@ -630,14 +639,16 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
if icon_map:
if category in icon_map:
icon = icon_map[category]
- tooltip = ''
else:
icon = icon_map['*custom']
tooltip = self.custom_column_label_map[category]['name']
- formatter = self.tag_browser_formatters.get(tn, lambda x: x)
- categories[category] = [Tag(formatter(r[1]), count=r[2], id=r[0], icon=icon, tooltip = tooltip)
- for r in data
- if r[2] > 0 and (category != 'rating' or len(formatter(r[1])) > 0)]
+ datatype = self.tag_browser_datatype[category]
+ formatter = self.tag_browser_formatters.get(datatype, lambda x: x)
+ categories[category] = [Tag(formatter(r[1]), count=r[2], id=r[0],
+ icon=icon, tooltip = tooltip)
+ for r in data
+ if r[2] > 0 and
+ (datatype != 'rating' or len(formatter(r[1])) > 0)]
categories['format'] = []
for fmt in self.conn.get('SELECT DISTINCT format FROM data'):
fmt = fmt[0]
From d5aed1fa22aac8435060ba9d1d67947d62f678ad Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Thu, 6 May 2010 07:45:19 -0600
Subject: [PATCH 050/324] Cleanups
---
src/calibre/gui2/custom_column_widgets.py | 7 +++----
1 file changed, 3 insertions(+), 4 deletions(-)
diff --git a/src/calibre/gui2/custom_column_widgets.py b/src/calibre/gui2/custom_column_widgets.py
index 611590f2c7..5af6b36b8a 100644
--- a/src/calibre/gui2/custom_column_widgets.py
+++ b/src/calibre/gui2/custom_column_widgets.py
@@ -99,6 +99,8 @@ class Float(Int):
w = self.widgets[1]
w.setRange(-100., float(sys.maxint))
w.setDecimals(2)
+ w.setSpecialValueText(_('Undefined'))
+ w.setSingleStep(1)
class Rating(Int):
@@ -106,7 +108,7 @@ class Rating(Int):
Int.setup_ui(self, parent)
w = self.widgets[1]
w.setRange(0, 5)
- w.setSuffix(' '+_('stars'))
+ w.setSuffix(' '+_('star(s)'))
w.setSpecialValueText(_('Unrated'))
def setter(self, val):
@@ -307,9 +309,6 @@ class BulkBase(Base):
class BulkBool(BulkBase, Bool):
pass
-class BulkRating(BulkBase, Rating):
- pass
-
class BulkInt(BulkBase, Int):
pass
From 92636156158cea253959c38efe589b0fd4f64808 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Thu, 6 May 2010 08:26:11 -0600
Subject: [PATCH 051/324] Add option to bulk metadata edit to remove stored
conversion settings
---
src/calibre/gui2/dialogs/metadata_bulk.py | 3 +++
src/calibre/gui2/dialogs/metadata_bulk.ui | 14 +++++++++++++-
src/calibre/gui2/library.py | 4 ++--
3 files changed, 18 insertions(+), 3 deletions(-)
diff --git a/src/calibre/gui2/dialogs/metadata_bulk.py b/src/calibre/gui2/dialogs/metadata_bulk.py
index 788c8681a6..2a7db38ee9 100644
--- a/src/calibre/gui2/dialogs/metadata_bulk.py
+++ b/src/calibre/gui2/dialogs/metadata_bulk.py
@@ -151,6 +151,9 @@ class MetadataBulkDialog(QDialog, Ui_MetadataBulkDialog):
new_authors = string_to_authors(title)
self.db.set_authors(id, new_authors, notify=False)
+ if self.remove_conversion_settings.isChecked():
+ self.db.delete_conversion_options(id, 'PIPE')
+
self.changed = True
for w in getattr(self, 'custom_column_widgets', []):
w.commit(self.ids)
diff --git a/src/calibre/gui2/dialogs/metadata_bulk.ui b/src/calibre/gui2/dialogs/metadata_bulk.ui
index a69c02dbc4..f5084fd883 100644
--- a/src/calibre/gui2/dialogs/metadata_bulk.ui
+++ b/src/calibre/gui2/dialogs/metadata_bulk.ui
@@ -253,7 +253,7 @@
-
+ &Swap title and author
@@ -273,6 +273,18 @@ Book A will have series number 1 and Book B series number 2.
+
+
+
+ Remove stored conversion settings for the selected books.
+
+Future conversion of these books will use the default settings.
+
+
+ Remove &stored conversion settings for the selected books
+
+
+
diff --git a/src/calibre/gui2/library.py b/src/calibre/gui2/library.py
index 9ba58963c4..896624c966 100644
--- a/src/calibre/gui2/library.py
+++ b/src/calibre/gui2/library.py
@@ -11,8 +11,8 @@ from PyQt4.QtGui import QTableView, QAbstractItemView, QColor, \
QPainterPath, QLinearGradient, QBrush, \
QPen, QStyle, QPainter, QStyleOptionViewItemV4, \
QIcon, QImage, QMenu, QSpinBox, QDoubleSpinBox, \
- QStyledItemDelegate, QCompleter, QIntValidator, \
- QDoubleValidator, QComboBox
+ QStyledItemDelegate, QCompleter, \
+ QComboBox
from PyQt4.QtCore import QAbstractTableModel, QVariant, Qt, pyqtSignal, \
SIGNAL, QObject, QSize, QModelIndex, QDate
From 887ee0b32c5640948b0572e896ae6d19c0d56e0b Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Thu, 6 May 2010 09:19:06 -0600
Subject: [PATCH 052/324] Optional views remember their sizes when closed and
re-opened
---
src/calibre/gui2/sidebar.py | 13 +++++++++++--
src/calibre/gui2/widgets.py | 18 +++++++++++++++---
2 files changed, 26 insertions(+), 5 deletions(-)
diff --git a/src/calibre/gui2/sidebar.py b/src/calibre/gui2/sidebar.py
index 375aafbaa2..d6b58f165d 100644
--- a/src/calibre/gui2/sidebar.py
+++ b/src/calibre/gui2/sidebar.py
@@ -147,12 +147,21 @@ class SideBar(QToolBar):
tb_state = dynamic.get('tag_browser_state', None)
if tb_state is not None:
self.horizontal_splitter.restoreState(tb_state)
+ tb_last_open_state = dynamic.get('tag_browser_last_open_state', None)
+ if tb_last_open_state is not None and \
+ not self.horizontal_splitter.is_side_index_hidden:
+ self.horizontal_splitter.restoreState(tb_last_open_state)
bi_state = dynamic.get('book_info_state', None)
if bi_state is not None:
self.vertical_splitter.restoreState(bi_state)
- self.horizontal_splitter.initialize()
- self.vertical_splitter.initialize()
+ bi_last_open_state = dynamic.get('book_info_last_open_state', None)
+ if bi_last_open_state is not None and \
+ not self.vertical_splitter.is_side_index_hidden:
+ self.vertical_splitter.restoreState(bi_last_open_state)
+
+ self.horizontal_splitter.initialize(name='tag_browser')
+ self.vertical_splitter.initialize(name='book_info')
self.view_status_changed('book_info', not
self.vertical_splitter.is_side_index_hidden)
self.view_status_changed('tag_browser', not
diff --git a/src/calibre/gui2/widgets.py b/src/calibre/gui2/widgets.py
index 33fff1bfcb..4b61677b12 100644
--- a/src/calibre/gui2/widgets.py
+++ b/src/calibre/gui2/widgets.py
@@ -14,7 +14,7 @@ from PyQt4.Qt import QListView, QIcon, QFont, QLabel, QListWidget, \
QMenu, QStringListModel, QCompleter, QStringList
from calibre.gui2 import human_readable, NONE, TableView, \
- error_dialog, pixmap_to_data
+ error_dialog, pixmap_to_data, dynamic
from calibre.gui2.dialogs.job_view_ui import Ui_Dialog
from calibre.gui2.filename_pattern_ui import Ui_Form
from calibre import fit_image
@@ -991,7 +991,9 @@ class Splitter(QSplitter):
def createHandle(self):
return SplitterHandle(self.orientation(), self)
- def initialize(self):
+ def initialize(self, name=None):
+ if name is not None:
+ self._name = name
for i in range(self.count()):
h = self.handle(i)
if h is not None:
@@ -1014,13 +1016,23 @@ class Splitter(QSplitter):
self.double_clicked(None)
def double_clicked(self, handle):
+ visible = not self.is_side_index_hidden
sizes = list(self.sizes())
if 0 in sizes:
idx = sizes.index(0)
sizes[idx] = 80
else:
sizes[self.side_index] = 0
- self.setSizes(sizes)
+
+ if visible:
+ dynamic.set(self._name + '_last_open_state', str(self.saveState()))
+ self.setSizes(sizes)
+ else:
+ state = dynamic.get(self._name+ '_last_open_state', None)
+ if state is not None:
+ self.restoreState(state)
+ else:
+ self.setSizes(sizes)
self.initialize()
From 9030bb8e80e1060b326ffaecdb3c00c2c8602895 Mon Sep 17 00:00:00 2001
From: Charles Haley <>
Date: Thu, 6 May 2010 16:51:07 +0100
Subject: [PATCH 053/324] Separate add/remove boxes for is_multiple columns in
bulk metadata edit
---
src/calibre/gui2/custom_column_widgets.py | 84 +++++++++++++++++++----
src/calibre/gui2/dialogs/metadata_bulk.py | 2 +-
2 files changed, 71 insertions(+), 15 deletions(-)
diff --git a/src/calibre/gui2/custom_column_widgets.py b/src/calibre/gui2/custom_column_widgets.py
index 5af6b36b8a..89adf7abc8 100644
--- a/src/calibre/gui2/custom_column_widgets.py
+++ b/src/calibre/gui2/custom_column_widgets.py
@@ -294,17 +294,26 @@ class BulkBase(Base):
ans = list(ans)
return ans
+ def process_each_book(self):
+ return False
+
def initialize(self, book_ids):
- self.initial_val = val = self.get_initial_value(book_ids)
- val = self.normalize_db_val(val)
- self.setter(val)
+ if not self.process_each_book():
+ self.initial_val = val = self.get_initial_value(book_ids)
+ val = self.normalize_db_val(val)
+ self.setter(val)
def commit(self, book_ids, notify=False):
- val = self.getter()
- val = self.normalize_ui_val(val)
- if val != self.initial_val:
+ if self.process_each_book():
for book_id in book_ids:
- self.db.set_custom(book_id, val, num=self.col_id, notify=notify)
+ val = self.db.get_custom(book_id, num=self.col_id, index_is_id=True)
+ self.db.set_custom(book_id, self.getter(val), num=self.col_id, notify=notify)
+ else:
+ val = self.getter()
+ val = self.normalize_ui_val(val)
+ if val != self.initial_val:
+ for book_id in book_ids:
+ self.db.set_custom(book_id, val, num=self.col_id, notify=notify)
class BulkBool(BulkBase, Bool):
pass
@@ -321,15 +330,34 @@ class BulkRating(BulkBase, Rating):
class BulkDateTime(BulkBase, DateTime):
pass
-class BulkText(BulkBase, Text):
+class BulkText(BulkBase):
+
+ def setup_ui(self, parent):
+ values = self.all_values = list(self.db.all_custom(num=self.col_id))
+ values.sort(cmp = lambda x,y: cmp(x.lower(), y.lower()))
+ if self.col_metadata['is_multiple']:
+ w = TagsLineEdit(parent, values)
+ w.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Preferred)
+ self.widgets = [QLabel('&'+self.col_metadata['name']+': (tags to add)', parent), w]
+ self.adding_widget = w
+
+ w = TagsLineEdit(parent, values)
+ w.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Preferred)
+ self.widgets.append(QLabel('&'+self.col_metadata['name']+': (tags to remove)', parent))
+ self.widgets.append(w)
+ self.removing_widget = w
+ else:
+ w = EnComboBox(parent)
+ w.setSizeAdjustPolicy(w.AdjustToMinimumContentsLengthWithIcon)
+ w.setMinimumContentsLength(25)
+ self.widgets = [QLabel('&'+self.col_metadata['name']+':', parent), w]
def initialize(self, book_ids):
- val = self.get_initial_value(book_ids)
- self.initial_val = val = self.normalize_db_val(val)
if self.col_metadata['is_multiple']:
- self.setter(val)
self.widgets[1].update_tags_cache(self.all_values)
else:
+ val = self.get_initial_value(book_ids)
+ self.initial_val = val = self.normalize_db_val(val)
idx = None
for i, c in enumerate(self.all_values):
if c == val:
@@ -339,6 +367,30 @@ class BulkText(BulkBase, Text):
if idx is not None:
self.widgets[1].setCurrentIndex(idx)
+ def process_each_book(self):
+ return self.col_metadata['is_multiple']
+
+ def getter(self, original_value = None):
+ if self.col_metadata['is_multiple']:
+ ans = original_value
+ vals = [v.strip() for v in unicode(self.adding_widget.text()).split(',')]
+ for t in vals:
+ print 'adding', t
+ if t not in ans:
+ ans.append(t)
+ print ans
+ vals = [v.strip() for v in unicode(self.removing_widget.text()).split(',')]
+ print 'removing', vals
+ for t in vals:
+ print 'deleting', t
+ if t in ans:
+ ans.remove(t)
+ return ans
+ val = unicode(self.widgets[1].currentText()).strip()
+ if not val:
+ val = None
+ return val
+
bulk_widgets = {
'bool' : BulkBool,
@@ -365,9 +417,13 @@ def populate_bulk_metadata_page(layout, db, book_ids, parent=None):
if len(w.widgets) == 1:
layout.addWidget(w.widgets[0], row, 0, 1, -1)
else:
- w.widgets[0].setBuddy(w.widgets[1])
- for c, widget in enumerate(w.widgets):
- layout.addWidget(widget, row, c)
+ c = 0
+ while c < len(w.widgets):
+ w.widgets[c].setBuddy(w.widgets[c+1])
+ layout.addWidget(w.widgets[c], row, c%2)
+ layout.addWidget(w.widgets[c+1], row, (c+1)%2)
+ c += 2
+ row += 1
items = []
if len(ans) > 0:
items.append(QSpacerItem(10, 10, QSizePolicy.Minimum,
diff --git a/src/calibre/gui2/dialogs/metadata_bulk.py b/src/calibre/gui2/dialogs/metadata_bulk.py
index 788c8681a6..1b8214804e 100644
--- a/src/calibre/gui2/dialogs/metadata_bulk.py
+++ b/src/calibre/gui2/dialogs/metadata_bulk.py
@@ -57,7 +57,7 @@ class MetadataBulkDialog(QDialog, Ui_MetadataBulkDialog):
self.__custom_col_layouts = [layout]
ans = self.custom_column_widgets
for i in range(len(ans)-1):
- w.setTabOrder(ans[i].widgets[-1], ans[i+1].widgets[-1])
+ w.setTabOrder(ans[i].widgets[1], ans[i+1].widgets[1])
def initialize_combos(self):
self.initalize_authors()
From 9b6bcf21ac093f93ae812df0dbece9ee75b0bb00 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Thu, 6 May 2010 10:21:02 -0600
Subject: [PATCH 054/324] Make dd/mm order detection more robust
---
src/calibre/utils/date.py | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
diff --git a/src/calibre/utils/date.py b/src/calibre/utils/date.py
index e48e10d90f..cb1b1fe1ad 100644
--- a/src/calibre/utils/date.py
+++ b/src/calibre/utils/date.py
@@ -25,7 +25,13 @@ class SafeLocalTimeZone(tzlocal):
return False
def compute_locale_info_for_parse_date():
- dt = datetime.strptime('1/5/2000', "%x")
+ try:
+ dt = datetime.strptime('1/5/2000', "%x")
+ except ValueError:
+ try:
+ dt = datetime.strptime('1/5/01', '%x')
+ except:
+ return False
if dt.month == 5:
return True
return False
From 8bbe0f169baaf030f2dea7072cdbd2aa62e414de Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Thu, 6 May 2010 10:46:46 -0600
Subject: [PATCH 055/324] Bump version number for betas
---
src/calibre/constants.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/calibre/constants.py b/src/calibre/constants.py
index d42f3c6d61..7a66fb395d 100644
--- a/src/calibre/constants.py
+++ b/src/calibre/constants.py
@@ -2,7 +2,7 @@ __license__ = 'GPL v3'
__copyright__ = '2008, Kovid Goyal kovid@kovidgoyal.net'
__docformat__ = 'restructuredtext en'
__appname__ = 'calibre'
-__version__ = '0.6.51'
+__version__ = '0.6.90'
__author__ = "Kovid Goyal "
import re
From 35e9d8d38775437ffa1d77159081280867d35cef Mon Sep 17 00:00:00 2001
From: Charles Haley <>
Date: Fri, 7 May 2010 13:48:14 +0100
Subject: [PATCH 056/324] Fixed bug #5477. Also fixed some oddities when
searching while a device is connected.
---
src/calibre/gui2/search_box.py | 5 +++++
src/calibre/gui2/ui.py | 19 +++++++++++--------
2 files changed, 16 insertions(+), 8 deletions(-)
diff --git a/src/calibre/gui2/search_box.py b/src/calibre/gui2/search_box.py
index 8770758eeb..7eb3173972 100644
--- a/src/calibre/gui2/search_box.py
+++ b/src/calibre/gui2/search_box.py
@@ -74,6 +74,7 @@ class SearchBox2(QComboBox):
self.setMaxCount(self.MAX_COUNT)
self.setSizeAdjustPolicy(self.AdjustToMinimumContentsLengthWithIcon)
self.setMinimumContentsLength(25)
+ self._in_a_search = False
def initialize(self, opt_name, colorize=False,
help_text=_('Search')):
@@ -93,6 +94,7 @@ class SearchBox2(QComboBox):
self.help_state = False
def clear_to_help(self):
+ self._in_a_search = False
self.setEditText(self.help_text)
self.line_edit.home(False)
self.help_state = True
@@ -111,6 +113,7 @@ class SearchBox2(QComboBox):
def search_done(self, ok):
if not unicode(self.currentText()).strip():
return self.clear_to_help()
+ self._in_a_search = ok
col = 'rgba(0,255,0,20%)' if ok else 'rgb(255,0,0,20%)'
if not self.colorize:
col = self.normal_background
@@ -184,6 +187,8 @@ class SearchBox2(QComboBox):
def search_as_you_type(self, enabled):
self.as_you_type = enabled
+ def in_a_search(self):
+ return self._in_a_search
class SavedSearchBox(QComboBox):
diff --git a/src/calibre/gui2/ui.py b/src/calibre/gui2/ui.py
index 2eea9f30e5..27b19427ae 100644
--- a/src/calibre/gui2/ui.py
+++ b/src/calibre/gui2/ui.py
@@ -835,20 +835,20 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
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)
+ self.set_number_of_books_shown(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:
+ def set_number_of_books_shown(self, compute_count):
+ if self.current_view() == self.library_view and 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 { border-radius: 8px; background-color: yellow; }')
- else: # No restriction
- if all == 'yes':
+ else: # No restriction or not library view
+ if not self.search.in_a_search():
t = _("(all books)")
else:
t = _("({0} of all)").format(self.current_view().row_count())
@@ -857,18 +857,18 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
self.search_count.setText(t)
def search_box_cleared(self):
- self.set_number_of_books_shown(all='yes', compute_count=True)
+ self.set_number_of_books_shown(compute_count=True)
self.tags_view.clear()
self.saved_search.clear_to_help()
def search_clear(self):
- self.set_number_of_books_shown(all='yes', compute_count=True)
+ self.set_number_of_books_shown(compute_count=True)
self.search.clear()
def search_done(self, view, ok):
if view is self.current_view():
- self.set_number_of_books_shown(all='no', compute_count=False)
self.search.search_done(ok)
+ self.set_number_of_books_shown(compute_count=False)
def sync_cf_to_listview(self, current, previous):
if self.cover_flow_sync_flag and self.cover_flow.isVisible() and \
@@ -2297,6 +2297,7 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
self.view_menu.actions()[1].setEnabled(True)
self.action_open_containing_folder.setEnabled(True)
self.action_sync.setEnabled(True)
+ self.search_restriction.setEnabled(True)
for action in list(self.delete_menu.actions())[1:]:
action.setEnabled(True)
else:
@@ -2306,8 +2307,10 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
self.view_menu.actions()[1].setEnabled(False)
self.action_open_containing_folder.setEnabled(False)
self.action_sync.setEnabled(False)
+ self.search_restriction.setEnabled(False)
for action in list(self.delete_menu.actions())[1:]:
action.setEnabled(False)
+ self.set_number_of_books_shown(compute_count=False)
def device_job_exception(self, job):
From a7a20a5e1c3136ce0267cad9169deb1609810010 Mon Sep 17 00:00:00 2001
From: Charles Haley <>
Date: Fri, 7 May 2010 14:57:46 +0100
Subject: [PATCH 057/324] 1) fix tab order (bug #5480) 2) mark tags to
add/remove strings as translatable 3) add ':' to boolean label 4) refactor
widget creation loop
---
src/calibre/gui2/custom_column_widgets.py | 17 ++++++++---------
src/calibre/gui2/dialogs/metadata_bulk.py | 4 +++-
2 files changed, 11 insertions(+), 10 deletions(-)
diff --git a/src/calibre/gui2/custom_column_widgets.py b/src/calibre/gui2/custom_column_widgets.py
index 0becf4b0b3..cd38be50d2 100644
--- a/src/calibre/gui2/custom_column_widgets.py
+++ b/src/calibre/gui2/custom_column_widgets.py
@@ -46,7 +46,7 @@ class Base(object):
class Bool(Base):
def setup_ui(self, parent):
- self.widgets = [QLabel('&'+self.col_metadata['name'], parent),
+ self.widgets = [QLabel('&'+self.col_metadata['name']+':', parent),
QComboBox(parent)]
w = self.widgets[1]
items = [_('Yes'), _('No'), _('Undefined')]
@@ -57,7 +57,6 @@ class Bool(Base):
for icon, text in zip(icons, items):
w.addItem(QIcon(icon), text)
-
def setter(self, val):
val = {None: 2, False: 1, True: 0}[val]
if tweaks['bool_custom_columns_are_tristate'] == 'no' and val == 2:
@@ -338,12 +337,14 @@ class BulkText(BulkBase):
if self.col_metadata['is_multiple']:
w = TagsLineEdit(parent, values)
w.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Preferred)
- self.widgets = [QLabel('&'+self.col_metadata['name']+': (tags to add)', parent), w]
+ self.widgets = [QLabel('&'+self.col_metadata['name']+': ' +
+ _('tags to add'), parent), w]
self.adding_widget = w
w = TagsLineEdit(parent, values)
w.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Preferred)
- self.widgets.append(QLabel('&'+self.col_metadata['name']+': (tags to remove)', parent))
+ self.widgets.append(QLabel('&'+self.col_metadata['name']+': ' +
+ _('tags to remove'), parent))
self.widgets.append(w)
self.removing_widget = w
else:
@@ -413,12 +414,10 @@ def populate_bulk_metadata_page(layout, db, book_ids, parent=None):
if len(w.widgets) == 1:
layout.addWidget(w.widgets[0], row, 0, 1, -1)
else:
- c = 0
- while c < len(w.widgets):
+ for c in range(0, len(w.widgets), 2):
w.widgets[c].setBuddy(w.widgets[c+1])
- layout.addWidget(w.widgets[c], row, c%2)
- layout.addWidget(w.widgets[c+1], row, (c+1)%2)
- c += 2
+ layout.addWidget(w.widgets[c], row, 0)
+ layout.addWidget(w.widgets[c+1], row, 1)
row += 1
items = []
if len(ans) > 0:
diff --git a/src/calibre/gui2/dialogs/metadata_bulk.py b/src/calibre/gui2/dialogs/metadata_bulk.py
index 0207786e30..eca7fe9c15 100644
--- a/src/calibre/gui2/dialogs/metadata_bulk.py
+++ b/src/calibre/gui2/dialogs/metadata_bulk.py
@@ -57,7 +57,9 @@ class MetadataBulkDialog(QDialog, Ui_MetadataBulkDialog):
self.__custom_col_layouts = [layout]
ans = self.custom_column_widgets
for i in range(len(ans)-1):
- w.setTabOrder(ans[i].widgets[1], ans[i+1].widgets[1])
+ w.setTabOrder(ans[i].widgets[-1], ans[i+1].widgets[1])
+ for c in range(2, len(ans[i].widgets), 2):
+ w.setTabOrder(ans[i].widgets[c-1], ans[i].widgets[c+1])
def initialize_combos(self):
self.initalize_authors()
From f3ee4b2d0d50b3ed2c7867e7c6762627c8916caf Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Fri, 7 May 2010 08:01:54 -0600
Subject: [PATCH 058/324] Fix #5472
---
src/calibre/gui2/dialogs/config/__init__.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/calibre/gui2/dialogs/config/__init__.py b/src/calibre/gui2/dialogs/config/__init__.py
index e9f551af48..dc1ca8111e 100644
--- a/src/calibre/gui2/dialogs/config/__init__.py
+++ b/src/calibre/gui2/dialogs/config/__init__.py
@@ -370,7 +370,7 @@ class ConfigDialog(ResizableDialog, Ui_Dialog):
[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)
+ item = QListWidgetItem(model.orig_headers[col], self.columns)
else:
item = QListWidgetItem(self.custcols[col]['name'], self.columns)
item.setData(Qt.UserRole, QVariant(col))
From 1c1e3bf9a163a4a288b8e2b59cb926bf11b68d98 Mon Sep 17 00:00:00 2001
From: Charles Haley <>
Date: Fri, 7 May 2010 17:36:40 +0100
Subject: [PATCH 059/324] Permit customization of date display. The format
string can be anything accepted by QDate, with a default of dd MMM yyyy (set
in library.py):
d the day as number without a leading zero (1 to 31)
dd the day as number with a leading zero (01 to 31)
ddd the abbreviated localized day name (e.g. 'Mon' to 'Sun').
dddd the long localized day name (e.g. 'Monday' to 'Sunday').
M the month as number without a leading zero (1 to 12)
MM the month as number with a leading zero (01 to 12)
MMM the abbreviated localized month name (e.g. 'Jan' to 'Dec').
MMMM the long localized month name (e.g. 'January' to 'December').
yy the year as two digit number (00 to 99)
yyyy the year as four digit number. If the year is negative, a minus sign is prepended in addition.
---
src/calibre/gui2/dialogs/config/__init__.py | 7 ++-
.../dialogs/config/create_custom_column.py | 17 ++++++--
.../dialogs/config/create_custom_column.ui | 43 ++++++++++++++++++-
src/calibre/gui2/library.py | 31 ++++++++++++-
4 files changed, 90 insertions(+), 8 deletions(-)
diff --git a/src/calibre/gui2/dialogs/config/__init__.py b/src/calibre/gui2/dialogs/config/__init__.py
index dc1ca8111e..cbe53662d9 100644
--- a/src/calibre/gui2/dialogs/config/__init__.py
+++ b/src/calibre/gui2/dialogs/config/__init__.py
@@ -776,14 +776,17 @@ class ConfigDialog(ResizableDialog, Ui_Dialog):
label=c,
name=self.custcols[c]['name'],
datatype=self.custcols[c]['datatype'],
- is_multiple=self.custcols[c]['is_multiple'])
+ is_multiple=self.custcols[c]['is_multiple'],
+ display = self.custcols[c]['display'])
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'])
+ self.db.set_custom_column_metadata(cc['num'], name=cc['name'],
+ label=cc['label'],
+ display = self.custcols[c]['display'])
if '*must_restart' in self.custcols[c]:
must_restart = True
diff --git a/src/calibre/gui2/dialogs/config/create_custom_column.py b/src/calibre/gui2/dialogs/config/create_custom_column.py
index 03f8104223..56ae592378 100644
--- a/src/calibre/gui2/dialogs/config/create_custom_column.py
+++ b/src/calibre/gui2/dialogs/config/create_custom_column.py
@@ -48,9 +48,9 @@ class CreateCustomColumn(QDialog, Ui_QCreateCustomColumn):
self.editing_col = editing
self.standard_colheads = standard_colheads
self.standard_colnames = standard_colnames
+ for t in self.column_types:
+ self.column_type_box.addItem(self.column_types[t]['text'])
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()
@@ -68,7 +68,10 @@ class CreateCustomColumn(QDialog, Ui_QCreateCustomColumn):
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.column_type_box.setCurrentIndex(column_numbers[ct])
+ self.column_type_box.setEnabled(False)
+ if ct == 'datetime':
+ self.date_format_box.setText(c['display'].get('date_format', ''))
self.exec_()
def accept(self):
@@ -105,13 +108,18 @@ class CreateCustomColumn(QDialog, Ui_QCreateCustomColumn):
if ':' in col or ' ' in col or col.lower() != col:
return self.simple_error('', _('The lookup name must be lower case and cannot contain ":"s or spaces'))
+ date_format = None
+ if col_type == 'datetime':
+ if self.date_format_box.text():
+ date_format = {'date_format':unicode(self.date_format_box.text())}
+
if not self.editing_col:
self.parent.custcols[col] = {
'label':col,
'name':col_heading,
'datatype':col_type,
'editable':True,
- 'display':None,
+ 'display':date_format,
'normalized':None,
'num':None,
'is_multiple':is_multiple,
@@ -127,6 +135,7 @@ class CreateCustomColumn(QDialog, Ui_QCreateCustomColumn):
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]['display'] = date_format
self.parent.custcols[self.orig_column_name]['*edited'] = True
self.parent.custcols[self.orig_column_name]['*must_restart'] = True
QDialog.accept(self)
diff --git a/src/calibre/gui2/dialogs/config/create_custom_column.ui b/src/calibre/gui2/dialogs/config/create_custom_column.ui
index 247fbd9537..2079fb4930 100644
--- a/src/calibre/gui2/dialogs/config/create_custom_column.ui
+++ b/src/calibre/gui2/dialogs/config/create_custom_column.ui
@@ -10,7 +10,7 @@
00528
- 165
+ 194
@@ -33,6 +33,9 @@
+
+ 0
+
@@ -102,6 +105,43 @@
+
+
+
+
+
+
+ 0
+ 0
+
+
+
+ Date format. Use 1-4 'd's for day, 1-4 'M's for month, and 1-2 'Y's for year.
+
+
+
+
+
+
+ Use MMM yyyy for month + year, yyyy for year only
+
+
+ Default: dd MMM yyyy.
+
+
+
+
+
+
+
+
+ Format for dates
+
+
+ date_format_box
+
+
+
@@ -138,6 +178,7 @@
column_name_boxcolumn_heading_boxcolumn_type_box
+ date_format_boxbutton_box
diff --git a/src/calibre/gui2/library.py b/src/calibre/gui2/library.py
index 896624c966..d2f99cea06 100644
--- a/src/calibre/gui2/library.py
+++ b/src/calibre/gui2/library.py
@@ -177,6 +177,33 @@ class TagsDelegate(QStyledItemDelegate):
editor = EnLineEdit(parent)
return editor
+class CcDateDelegate(QStyledItemDelegate):
+ '''
+ Delegate for custom columns dates. Because this delegate stores the
+ format as an instance variable, a new instance must be created for each
+ column. This differs from all the other delegates.
+ '''
+
+ def set_format(self, format):
+ if not format:
+ self.format = 'dd MMM yyyy'
+ else:
+ self.format = format
+
+ def displayText(self, val, locale):
+ d = val.toDate()
+ if d == UNDEFINED_DATE:
+ return ''
+ return d.toString(self.format)
+
+ def createEditor(self, parent, option, index):
+ qde = QStyledItemDelegate.createEditor(self, parent, option, index)
+ qde.setDisplayFormat(self.format)
+ qde.setMinimumDate(UNDEFINED_DATE)
+ qde.setSpecialValueText(_('Undefined'))
+ qde.setCalendarPopup(True)
+ return qde
+
class CcTextDelegate(QStyledItemDelegate):
'''
Delegate for text/int/float data.
@@ -989,7 +1016,9 @@ class BooksView(TableView):
continue
cc = self._model.custom_columns[colhead]
if cc['datatype'] == 'datetime':
- self.setItemDelegateForColumn(cm.index(colhead), self.timestamp_delegate)
+ delegate = CcDateDelegate(self)
+ delegate.set_format(cc['display'].get('date_format',''))
+ self.setItemDelegateForColumn(cm.index(colhead), delegate)
elif cc['datatype'] == 'comments':
self.setItemDelegateForColumn(cm.index(colhead), self.cc_comments_delegate)
elif cc['datatype'] == 'text':
From aab34ff1972b7a3ecca95f82f835b67997d0a7d3 Mon Sep 17 00:00:00 2001
From: Charles Haley <>
Date: Fri, 7 May 2010 20:13:12 +0100
Subject: [PATCH 060/324] Implement enhancement #5481, relational searches for
int and float custom columns
---
src/calibre/library/caches.py | 107 +++++++++++++++++++++-------------
1 file changed, 65 insertions(+), 42 deletions(-)
diff --git a/src/calibre/library/caches.py b/src/calibre/library/caches.py
index d792f693d2..55b6a00e99 100644
--- a/src/calibre/library/caches.py
+++ b/src/calibre/library/caches.py
@@ -159,7 +159,20 @@ class ResultCache(SearchQueryParser):
locations=SearchQueryParser.DEFAULT_LOCATIONS +
[c for c in cc_label_map])
self.build_date_relop_dict()
- self.build_rating_relop_dict()
+ self.build_numeric_relop_dict()
+
+ def __getitem__(self, row):
+ return self._data[self._map_filtered[row]]
+
+ def __len__(self):
+ return len(self._map_filtered)
+
+ def __iter__(self):
+ for id in self._map_filtered:
+ yield self._data[id]
+
+ def universal_set(self):
+ return set([i[0] for i in self._data if i is not None])
def build_date_relop_dict(self):
'''
@@ -205,30 +218,14 @@ class ResultCache(SearchQueryParser):
def relop_le(db, query, field_count):
return not relop_gt(db, query, field_count)
- self.date_search_relops = {'=':[1, relop_eq], '>':[1, relop_gt], '<':[1, relop_lt], \
- '!=':[2, relop_ne], '>=':[2, relop_ge], '<=':[2, relop_le]}
-
- def build_rating_relop_dict(self):
- self.rating_search_relops = {
- '=':[1, lambda r, q: r == q],
- '>':[1, lambda r, q: r > q],
- '<':[1, lambda r, q: r < q],
- '!=':[2, lambda r, q: r != q],
- '>=':[2, lambda r, q: r >= q],
- '<=':[2, lambda r, q: r <= q]}
-
- def __getitem__(self, row):
- return self._data[self._map_filtered[row]]
-
- def __len__(self):
- return len(self._map_filtered)
-
- def __iter__(self):
- for id in self._map_filtered:
- yield self._data[id]
-
- def universal_set(self):
- return set([i[0] for i in self._data if i is not None])
+ self.date_search_relops = {
+ '=' :[1, relop_eq],
+ '>' :[1, relop_gt],
+ '<' :[1, relop_lt],
+ '!=':[2, relop_ne],
+ '>=':[2, relop_ge],
+ '<=':[2, relop_le]
+ }
def get_dates_matches(self, location, query):
matches = set([])
@@ -277,7 +274,17 @@ class ResultCache(SearchQueryParser):
matches.add(item[0])
return matches
- def get_ratings_matches(self, location, query):
+ def build_numeric_relop_dict(self):
+ self.numeric_search_relops = {
+ '=':[1, lambda r, q: r == q],
+ '>':[1, lambda r, q: r > q],
+ '<':[1, lambda r, q: r < q],
+ '!=':[2, lambda r, q: r != q],
+ '>=':[2, lambda r, q: r >= q],
+ '<=':[2, lambda r, q: r <= q]
+ }
+
+ def get_numeric_matches(self, location, query):
matches = set([])
if len(query) == 0:
return matches
@@ -286,20 +293,33 @@ class ResultCache(SearchQueryParser):
elif query == 'true':
query = '>0'
relop = None
- for k in self.rating_search_relops.keys():
+ for k in self.numeric_search_relops.keys():
if query.startswith(k):
- (p, relop) = self.rating_search_relops[k]
+ (p, relop) = self.numeric_search_relops[k]
query = query[p:]
if relop is None:
- (p, relop) = self.rating_search_relops['=']
- try:
- r = int(query)
- except:
- return matches
+ (p, relop) = self.numeric_search_relops['=']
if location in self.custom_column_label_map:
loc = self.FIELD_MAP[self.custom_column_label_map[location]['num']]
+ dt = self.custom_column_label_map[location]['datatype']
+ if dt == 'int':
+ cast = (lambda x: int (x))
+ adjust = lambda x: x
+ elif dt == 'rating':
+ cast = (lambda x: int (x))
+ adjust = lambda x: x/2
+ elif dt == 'float':
+ cast = lambda x : float (x)
+ adjust = lambda x: x
else:
loc = self.FIELD_MAP['rating']
+ cast = (lambda x: int (x))
+ adjust = lambda x: x/2
+
+ try:
+ q = cast(query)
+ except:
+ return matches
for item in self._data:
if item is None:
@@ -307,8 +327,8 @@ class ResultCache(SearchQueryParser):
if not item[loc]:
i = 0
else:
- i = item[loc]/2
- if relop(i, r):
+ i = adjust(item[loc])
+ if relop(i, q):
matches.add(item[0])
return matches
@@ -323,11 +343,12 @@ class ResultCache(SearchQueryParser):
self.custom_column_label_map[location]['datatype'] == 'datetime'):
return self.get_dates_matches(location, query.lower())
- ### take care of ratings special case
+ ### take care of numerics special case
if location == 'rating' or \
- ((location in self.custom_column_label_map) and \
- self.custom_column_label_map[location]['datatype'] == 'rating'):
- return self.get_ratings_matches(location, query.lower())
+ (location in self.custom_column_label_map and
+ self.custom_column_label_map[location]['datatype'] in
+ ('rating', 'int', 'float')):
+ return self.get_numeric_matches(location, query.lower())
### everything else
matchkind = CONTAINS_MATCH
@@ -426,14 +447,15 @@ class ResultCache(SearchQueryParser):
matches.add(item[0])
continue
- if IS_CUSTOM[loc] == 'rating':
+ if IS_CUSTOM[loc] == 'rating': # get here if 'all' query
if rating_query and rating_query == int(item[loc]):
matches.add(item[0])
continue
try: # a conversion below might fail
+ # relationals not supported in 'all' queries
if IS_CUSTOM[loc] == 'float':
- if float(query) == item[loc]: # relationals not supported
+ if float(query) == item[loc]:
matches.add(item[0])
continue
if IS_CUSTOM[loc] == 'int':
@@ -441,7 +463,8 @@ class ResultCache(SearchQueryParser):
matches.add(item[0])
continue
except:
- # A conversion threw an exception. Because of the type, no further match possible
+ # A conversion threw an exception. Because of the type,
+ # no further match is possible
continue
if loc not in EXCLUDE_FIELDS:
From 324a651a125deb40521414548fb21466d84ef198 Mon Sep 17 00:00:00 2001
From: Charles Haley <>
Date: Sat, 8 May 2010 11:38:23 +0100
Subject: [PATCH 061/324] Fix for #5483 and #5484.
Editing a None date now defaults to today instead of Unknown. This permits editing in place.
Using the character * in the bulk metadata is_mult delete box will cause all the values for that column to be deleted. In addition, deletes are done before adds.
---
src/calibre/gui2/custom_column_widgets.py | 19 +++++++++----------
.../dialogs/config/create_custom_column.py | 4 +++-
src/calibre/gui2/library.py | 11 ++++++++++-
3 files changed, 22 insertions(+), 12 deletions(-)
diff --git a/src/calibre/gui2/custom_column_widgets.py b/src/calibre/gui2/custom_column_widgets.py
index cd38be50d2..d68bb4d809 100644
--- a/src/calibre/gui2/custom_column_widgets.py
+++ b/src/calibre/gui2/custom_column_widgets.py
@@ -373,16 +373,15 @@ class BulkText(BulkBase):
def getter(self, original_value = None):
if self.col_metadata['is_multiple']:
- ans = original_value
- vals = [v.strip() for v in unicode(self.adding_widget.text()).split(',')]
- for t in vals:
- if t not in ans:
- ans.append(t)
- vals = [v.strip() for v in unicode(self.removing_widget.text()).split(',')]
- for t in vals:
- if t in ans:
- ans.remove(t)
- return ans
+ if self.removing_widget.text() == '*':
+ ans = set()
+ else:
+ ans = set(original_value)
+ ans -= set([v.strip() for v in
+ unicode(self.removing_widget.text()).split(',')])
+ ans |= set([v.strip() for v in
+ unicode(self.adding_widget.text()).split(',')])
+ return ans # returning a set instead of a list works, for now at least.
val = unicode(self.widgets[1].currentText()).strip()
if not val:
val = None
diff --git a/src/calibre/gui2/dialogs/config/create_custom_column.py b/src/calibre/gui2/dialogs/config/create_custom_column.py
index 8cfb01092b..98aa3c99e0 100644
--- a/src/calibre/gui2/dialogs/config/create_custom_column.py
+++ b/src/calibre/gui2/dialogs/config/create_custom_column.py
@@ -127,6 +127,8 @@ class CreateCustomColumn(QDialog, Ui_QCreateCustomColumn):
if col_type == 'datetime':
if self.date_format_box.text():
date_format = {'date_format':unicode(self.date_format_box.text())}
+ else:
+ date_format = {'date_format': None}
if not self.editing_col:
self.parent.custcols[col] = {
@@ -150,7 +152,7 @@ class CreateCustomColumn(QDialog, Ui_QCreateCustomColumn):
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]['display'] = date_format
+ self.parent.custcols[self.orig_column_name]['display'].update(date_format)
self.parent.custcols[self.orig_column_name]['*edited'] = True
self.parent.custcols[self.orig_column_name]['*must_restart'] = True
QDialog.accept(self)
diff --git a/src/calibre/gui2/library.py b/src/calibre/gui2/library.py
index d2f99cea06..85ac5e8aa8 100644
--- a/src/calibre/gui2/library.py
+++ b/src/calibre/gui2/library.py
@@ -25,7 +25,7 @@ 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
-from calibre.utils.date import dt_factory, qt_to_dt, isoformat
+from calibre.utils.date import dt_factory, qt_to_dt, isoformat, now
from calibre.utils.pyparsing import ParseException
from calibre.utils.search_query_parser import SearchQueryParser
@@ -204,6 +204,15 @@ class CcDateDelegate(QStyledItemDelegate):
qde.setCalendarPopup(True)
return qde
+ 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 val is None:
+ val = now()
+ editor.setDate(val)
+
class CcTextDelegate(QStyledItemDelegate):
'''
Delegate for text/int/float data.
From 431b9e15bce47da15ba9f81e900848843fe4de95 Mon Sep 17 00:00:00 2001
From: Charles Haley <>
Date: Sat, 8 May 2010 13:20:03 +0100
Subject: [PATCH 062/324] Fix #5486
Sort now treats None and UNDEFINED_DATE as UNDEFINED_DATE
It is now possible to set a date to None while editing directly on the library screen. To do so, set the date to 1/1/101.
The metadata editors show 'Undefined' for None dates. However, when a date widget gets focus, the display changes to showing the 'real' UNDEFINED_DATE. When the focus leaves, the widget reverts, unless it has been changed.
---
src/calibre/gui2/__init__.py | 3 ++-
src/calibre/gui2/custom_column_widgets.py | 17 ++++++++++---
src/calibre/gui2/library.py | 31 +++++++++++++++--------
src/calibre/library/caches.py | 14 +++++-----
src/calibre/utils/date.py | 2 ++
5 files changed, 45 insertions(+), 22 deletions(-)
diff --git a/src/calibre/gui2/__init__.py b/src/calibre/gui2/__init__.py
index 8ce4e53649..53dc75cc6c 100644
--- a/src/calibre/gui2/__init__.py
+++ b/src/calibre/gui2/__init__.py
@@ -17,11 +17,12 @@ 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
from calibre.ebooks.metadata import MetaInformation
+from calibre.utils.date import UNDEFINED_DATE
gprefs = JSONConfig('gui')
NONE = QVariant() #: Null value to return from the data function of item models
-UNDEFINED_DATE = QDate(101,1,1)
+UNDEFINED_QDATE = QDate(UNDEFINED_DATE)
ALL_COLUMNS = ['title', 'authors', 'size', 'timestamp', 'rating', 'publisher',
'tags', 'series', 'pubdate']
diff --git a/src/calibre/gui2/custom_column_widgets.py b/src/calibre/gui2/custom_column_widgets.py
index d68bb4d809..c25a705f30 100644
--- a/src/calibre/gui2/custom_column_widgets.py
+++ b/src/calibre/gui2/custom_column_widgets.py
@@ -14,7 +14,7 @@ from PyQt4.Qt import QComboBox, QLabel, QSpinBox, QDoubleSpinBox, QDateEdit, \
from calibre.utils.date import qt_to_dt
from calibre.gui2.widgets import TagsLineEdit, EnComboBox
-from calibre.gui2 import UNDEFINED_DATE
+from calibre.gui2 import UNDEFINED_QDATE
from calibre.utils.config import tweaks
class Base(object):
@@ -123,15 +123,24 @@ class Rating(Int):
val *= 2
return val
+class DateEdit(QDateEdit):
+
+ def focusInEvent(self, x):
+ print 'focus in'
+ self.setSpecialValueText('')
+
+ def focusOutEvent(self, x):
+ self.setSpecialValueText(_('Undefined'))
+
class DateTime(Base):
def setup_ui(self, parent):
self.widgets = [QLabel('&'+self.col_metadata['name']+':', parent),
- QDateEdit(parent)]
+ DateEdit(parent)]
w = self.widgets[1]
w.setDisplayFormat('dd MMM yyyy')
w.setCalendarPopup(True)
- w.setMinimumDate(UNDEFINED_DATE)
+ w.setMinimumDate(UNDEFINED_QDATE)
w.setSpecialValueText(_('Undefined'))
def setter(self, val):
@@ -143,7 +152,7 @@ class DateTime(Base):
def getter(self):
val = self.widgets[1].date()
- if val == UNDEFINED_DATE:
+ if val == UNDEFINED_QDATE:
val = None
else:
val = qt_to_dt(val)
diff --git a/src/calibre/gui2/library.py b/src/calibre/gui2/library.py
index 85ac5e8aa8..ba9a5b0b29 100644
--- a/src/calibre/gui2/library.py
+++ b/src/calibre/gui2/library.py
@@ -19,7 +19,7 @@ from PyQt4.QtCore import QAbstractTableModel, QVariant, Qt, pyqtSignal, \
from calibre import strftime
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.gui2 import NONE, TableView, config, error_dialog, UNDEFINED_DATE
+from calibre.gui2 import NONE, TableView, config, error_dialog, UNDEFINED_QDATE
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
@@ -103,7 +103,7 @@ class DateDelegate(QStyledItemDelegate):
def displayText(self, val, locale):
d = val.toDate()
- if d == UNDEFINED_DATE:
+ if d == UNDEFINED_QDATE:
return ''
return d.toString('dd MMM yyyy')
@@ -113,7 +113,7 @@ class DateDelegate(QStyledItemDelegate):
if 'yyyy' not in stdformat:
stdformat = stdformat.replace('yy', 'yyyy')
qde.setDisplayFormat(stdformat)
- qde.setMinimumDate(UNDEFINED_DATE)
+ qde.setMinimumDate(UNDEFINED_QDATE)
qde.setSpecialValueText(_('Undefined'))
qde.setCalendarPopup(True)
return qde
@@ -122,14 +122,14 @@ class PubDateDelegate(QStyledItemDelegate):
def displayText(self, val, locale):
d = val.toDate()
- if d == UNDEFINED_DATE:
+ if d == UNDEFINED_QDATE:
return ''
return d.toString('MMM yyyy')
def createEditor(self, parent, option, index):
qde = QStyledItemDelegate.createEditor(self, parent, option, index)
qde.setDisplayFormat('MM yyyy')
- qde.setMinimumDate(UNDEFINED_DATE)
+ qde.setMinimumDate(UNDEFINED_QDATE)
qde.setSpecialValueText(_('Undefined'))
qde.setCalendarPopup(True)
return qde
@@ -192,14 +192,14 @@ class CcDateDelegate(QStyledItemDelegate):
def displayText(self, val, locale):
d = val.toDate()
- if d == UNDEFINED_DATE:
+ if d == UNDEFINED_QDATE:
return ''
return d.toString(self.format)
def createEditor(self, parent, option, index):
qde = QStyledItemDelegate.createEditor(self, parent, option, index)
qde.setDisplayFormat(self.format)
- qde.setMinimumDate(UNDEFINED_DATE)
+ qde.setMinimumDate(UNDEFINED_QDATE)
qde.setSpecialValueText(_('Undefined'))
qde.setCalendarPopup(True)
return qde
@@ -213,6 +213,12 @@ class CcDateDelegate(QStyledItemDelegate):
val = now()
editor.setDate(val)
+ def setModelData(self, editor, model, index):
+ val = editor.date()
+ if val == UNDEFINED_QDATE:
+ val = None
+ model.setData(index, QVariant(val), Qt.EditRole)
+
class CcTextDelegate(QStyledItemDelegate):
'''
Delegate for text/int/float data.
@@ -748,7 +754,7 @@ class BooksModel(QAbstractTableModel):
if val is not None:
return QVariant(QDate(val))
else:
- return QVariant(UNDEFINED_DATE)
+ return QVariant(UNDEFINED_QDATE)
def bool_type(r, idx=-1):
return None # displayed using a decorator
@@ -883,9 +889,12 @@ class BooksModel(QAbstractTableModel):
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)
+ if val.isNull():
+ val = None
+ else:
+ if 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
diff --git a/src/calibre/library/caches.py b/src/calibre/library/caches.py
index 55b6a00e99..2e7c7c4ca8 100644
--- a/src/calibre/library/caches.py
+++ b/src/calibre/library/caches.py
@@ -14,7 +14,7 @@ from PyQt4.QtCore import QThread, QReadWriteLock
from PyQt4.QtGui import QImage
from calibre.utils.config import tweaks
-from calibre.utils.date import parse_date, now
+from calibre.utils.date import parse_date, now, UNDEFINED_DATE
from calibre.utils.search_query_parser import SearchQueryParser
from calibre.utils.pyparsing import ParseException
@@ -573,11 +573,13 @@ class ResultCache(SearchQueryParser):
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
+ x = self._data[x][loc]
+ if x is None:
+ x = UNDEFINED_DATE
+ y = self._data[y][loc]
+ if y is None:
+ y = UNDEFINED_DATE
+ return cmp(x, y)
if subsort and ans == 0:
return cmp(self._data[x][11].lower(), self._data[y][11].lower())
return ans
diff --git a/src/calibre/utils/date.py b/src/calibre/utils/date.py
index cb1b1fe1ad..a43927c9c5 100644
--- a/src/calibre/utils/date.py
+++ b/src/calibre/utils/date.py
@@ -40,6 +40,8 @@ parse_date_day_first = compute_locale_info_for_parse_date()
utc_tz = _utc_tz = tzutc()
local_tz = _local_tz = SafeLocalTimeZone()
+UNDEFINED_DATE = datetime(101,1,1, tzinfo=utc_tz)
+
def parse_date(date_string, assume_utc=False, as_utc=True, default=None):
'''
Parse a date/time string into a timezone aware datetime object. The timezone
From 7eecfb06680bf44783d9c9d5f7b759dfac9125fa Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Sat, 8 May 2010 11:41:26 -0600
Subject: [PATCH 063/324] More robust creation of dynamic id filters
---
src/calibre/library/database2.py | 4 +++-
src/calibre/library/sqlite.py | 2 ++
2 files changed, 5 insertions(+), 1 deletion(-)
diff --git a/src/calibre/library/database2.py b/src/calibre/library/database2.py
index 724c1bd41a..a7d68896cf 100644
--- a/src/calibre/library/database2.py
+++ b/src/calibre/library/database2.py
@@ -106,6 +106,9 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
self.conn = connect(self.dbpath, self.row_factory)
if self.user_version == 0:
self.initialize_database()
+ # remember to add any filter to the connect method in sqlite.py as well
+ # so that various code taht connects directly will not complain about
+ # missing functions
self.books_list_filter = self.conn.create_dynamic_filter('books_list_filter')
def __init__(self, library_path, row_factory=False):
@@ -1469,7 +1472,6 @@ books_series_link feeds
conn = ndb.conn
conn.execute('create table temp_sequence(id INTEGER PRIMARY KEY AUTOINCREMENT)')
conn.commit()
- conn.create_function(self.books_list_filter.name, 1, lambda x: 1)
conn.executescript(sql)
conn.commit()
conn.execute('pragma user_version=%d'%user_version)
diff --git a/src/calibre/library/sqlite.py b/src/calibre/library/sqlite.py
index 755d8e64b4..236f81da2d 100644
--- a/src/calibre/library/sqlite.py
+++ b/src/calibre/library/sqlite.py
@@ -117,6 +117,8 @@ class DBThread(Thread):
self.conn.create_aggregate('sort_concat', 2, SafeSortedConcatenate)
self.conn.create_function('title_sort', 1, title_sort)
self.conn.create_function('uuid4', 0, lambda : str(uuid.uuid4()))
+ # Dummy functions for dynamically created filters
+ self.conn.create_function('books_list_filter', 1, lambda x: 1)
def run(self):
try:
From a61b71ccb1236f62bd0e490a6073aca4482913e9 Mon Sep 17 00:00:00 2001
From: Charles Haley <>
Date: Sun, 9 May 2010 06:57:15 +0100
Subject: [PATCH 064/324] Commit before merge from trunk
---
src/calibre/gui2/__init__.py | 2 +-
src/calibre/gui2/device.py | 54 +++++++++++++++++++++++++++++++-
src/calibre/gui2/library.py | 27 +++++++++++++---
src/calibre/gui2/ui.py | 20 ++++++++++++
src/calibre/library/caches.py | 3 ++
src/calibre/library/database.py | 3 ++
src/calibre/library/database2.py | 23 ++++++++++++++
7 files changed, 126 insertions(+), 6 deletions(-)
diff --git a/src/calibre/gui2/__init__.py b/src/calibre/gui2/__init__.py
index 53dc75cc6c..c60acb23fc 100644
--- a/src/calibre/gui2/__init__.py
+++ b/src/calibre/gui2/__init__.py
@@ -25,7 +25,7 @@ NONE = QVariant() #: Null value to return from the data function of item models
UNDEFINED_QDATE = QDate(UNDEFINED_DATE)
ALL_COLUMNS = ['title', 'authors', 'size', 'timestamp', 'rating', 'publisher',
- 'tags', 'series', 'pubdate']
+ 'tags', 'series', 'pubdate', 'ondevice']
def _config():
c = Config('gui', 'preferences for the calibre GUI')
diff --git a/src/calibre/gui2/device.py b/src/calibre/gui2/device.py
index b87a5e451b..11f7c91a95 100644
--- a/src/calibre/gui2/device.py
+++ b/src/calibre/gui2/device.py
@@ -1,7 +1,7 @@
from __future__ import with_statement
__license__ = 'GPL v3'
__copyright__ = '2008, Kovid Goyal '
-import os, traceback, Queue, time, socket, cStringIO
+import os, traceback, Queue, time, socket, cStringIO, re
from threading import Thread, RLock
from itertools import repeat
from functools import partial
@@ -978,3 +978,55 @@ class DeviceGUI(object):
getattr(f, 'close', lambda : True)()
if memory and memory[1]:
self.library_view.model().delete_books_by_id(memory[1])
+
+ def book_on_device(self, index, index_is_id=False, format=None):
+ loc = [None, None, None]
+
+ db_title = self.library_view.model().db.title(index, index_is_id).lower()
+ db_title = re.sub('(?u)\W|[_]', '', db_title)
+ au = self.library_view.model().db.authors(index, index_is_id)
+ db_authors = au.lower() if au else ''
+ db_authors = re.sub('(?u)\W|[_]', '', db_authors)
+
+ for i, l in enumerate(self.booklists()):
+ for book in l:
+ book_title = book.title.lower() if book.title else ''
+ book_title = re.sub('(?u)\W|[_]', '', book_title)
+ book_authors = authors_to_string(book.authors).lower()
+ book_authors = re.sub('(?u)\W|[_]', '', book_authors)
+ if book_title == db_title and book_authors == db_authors:
+ loc[i] = True
+ break
+ return loc
+
+ def book_in_library(self, index, oncard=None):
+ '''
+ Used to determine if a book on the device is in the library.
+ Returns the book's id in the library.
+ '''
+ bl = []
+ if oncard == 'carda':
+ bl = self.booklists()[1]
+ elif oncard == 'cardb':
+ bl = self.booklists()[2]
+ else:
+ bl = self.booklists()[0]
+
+ book = bl[index]
+ book_title = book.title.lower() if book.title else ''
+ book_title = re.sub('(?u)\W|[_]', '', book_title)
+ book_authors = authors_to_string(book.authors).lower() if book.authors else ''
+ book_authors = re.sub('(?u)\W|[_]', '', book_authors)
+
+# if getattr(book, 'application_id', None) != None and self.library_view.model().db.has_id(book.application_id):
+# if book.uuid and self.library_view.model().db.uuid(book.application_id, index_is_id=True) == book.uuid:
+# return book.application_id
+ for id, title in self.library_view.model().db.all_titles():
+ title = re.sub('(?u)\W|[_]', '', title.lower())
+ if title == book_title:
+ au = self.library_view.model().db.authors(id, index_is_id=True)
+ authors = au.lower() if au else ''
+ authors = re.sub('(?u)\W|[_]', '', authors)
+ if authors == book_authors:
+ return id
+ return None
diff --git a/src/calibre/gui2/library.py b/src/calibre/gui2/library.py
index ba9a5b0b29..074cb3b00e 100644
--- a/src/calibre/gui2/library.py
+++ b/src/calibre/gui2/library.py
@@ -316,11 +316,13 @@ class BooksModel(QAbstractTableModel):
'publisher' : _("Publisher"),
'tags' : _("Tags"),
'series' : _("Series"),
+ 'ondevice' : _("On Device"),
}
def __init__(self, parent=None, buffer=40):
QAbstractTableModel.__init__(self, parent)
self.db = None
+ self.book_on_device = None
self.editable_cols = ['title', 'authors', 'rating', 'publisher',
'tags', 'series', 'timestamp', 'pubdate']
self.default_image = QImage(I('book.svg'))
@@ -359,6 +361,9 @@ class BooksModel(QAbstractTableModel):
self.reset()
self.emit(SIGNAL('columns_sorted()'))
+ def set_book_on_device_func(self, func):
+ self.book_on_device = func
+
def set_database(self, db):
self.db = db
self.custom_columns = self.db.custom_column_label_map
@@ -799,6 +804,8 @@ class BooksModel(QAbstractTableModel):
'series' : functools.partial(series,
idx=self.db.FIELD_MAP['series'],
siix=self.db.FIELD_MAP['series_index']),
+ 'ondevice' : functools.partial(text_type,
+ idx=self.db.FIELD_MAP['ondevice'], mult=False),
}
self.dc_decorator = {}
@@ -1255,6 +1262,12 @@ class DeviceBooksModel(BooksModel):
self.marked_for_deletion = {}
self.search_engine = OnDeviceSearch(self)
self.editable = True
+ self.book_in_library = None
+ self.loc = None
+
+ def set_book_in_library_func(self, func, loc):
+ self.book_in_library = func
+ self.loc = loc
def mark_for_deletion(self, job, rows):
self.marked_for_deletion[job] = self.indices(rows)
@@ -1342,8 +1355,11 @@ class DeviceBooksModel(BooksModel):
def tagscmp(x, y):
x, y = ','.join(self.db[x].tags), ','.join(self.db[y].tags)
return cmp(x, y)
+ def libcmp(x, y):
+ x, y = self.book_in_library(self.map[x], self.loc), self.book_in_library(self.map[y], self.loc)
+ return cmp(x, y)
fcmp = strcmp('title_sorter') if col == 0 else strcmp('authors') if col == 1 else \
- sizecmp if col == 2 else datecmp if col == 3 else tagscmp
+ sizecmp if col == 2 else datecmp if col == 3 else tagscmp if col == 4 else libcmp
self.map.sort(cmp=fcmp, reverse=descending)
if len(self.map) == len(self.db):
self.sorted_map = list(self.map)
@@ -1357,7 +1373,7 @@ class DeviceBooksModel(BooksModel):
def columnCount(self, parent):
if parent and parent.isValid():
return 0
- return 5
+ return 6
def rowCount(self, parent):
if parent and parent.isValid():
@@ -1398,7 +1414,6 @@ class DeviceBooksModel(BooksModel):
'''
return [ self.map[r.row()] for r in rows]
-
def data(self, index, role):
if role == Qt.DisplayRole or role == Qt.EditRole:
row, col = index.row(), index.column()
@@ -1426,6 +1441,10 @@ class DeviceBooksModel(BooksModel):
tags = self.db[self.map[row]].tags
if tags:
return QVariant(', '.join(tags))
+ elif col == 5:
+ if self.book_in_library:
+ if self.book_in_library(self.map[row], self.loc) != None:
+ return QVariant(_("True"))
elif role == Qt.TextAlignmentRole and index.column() in [2, 3]:
return QVariant(Qt.AlignRight | Qt.AlignVCenter)
elif role == Qt.ToolTipRole and index.isValid():
@@ -1446,6 +1465,7 @@ class DeviceBooksModel(BooksModel):
elif section == 2: text = _("Size (MB)")
elif section == 3: text = _("Date")
elif section == 4: text = _("Tags")
+ elif section == 5: text = _("In Library")
return QVariant(text)
else:
return QVariant(section+1)
@@ -1479,4 +1499,3 @@ class DeviceBooksModel(BooksModel):
def set_search_restriction(self, s):
pass
-
diff --git a/src/calibre/gui2/ui.py b/src/calibre/gui2/ui.py
index 27b19427ae..87c03f7ec1 100644
--- a/src/calibre/gui2/ui.py
+++ b/src/calibre/gui2/ui.py
@@ -543,7 +543,9 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
else:
self.library_path = dir
db = LibraryDatabase2(self.library_path)
+ db.set_book_on_device_func(self.book_on_device)
self.library_view.set_database(db)
+ self.library_view.model().set_book_on_device_func(self.book_on_device)
prefs['library_path'] = self.library_path
self.library_view.restore_sort_at_startup(dynamic.get('sort_history', [('timestamp', Qt.DescendingOrder)]))
if not self.library_view.restore_column_widths():
@@ -978,6 +980,7 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
self.status_bar.reset_info()
self.location_view.setCurrentIndex(self.location_view.model().index(0))
self.eject_action.setEnabled(False)
+ self.refresh_ondevice_info (clear_info = True)
def info_read(self, job):
'''
@@ -1012,10 +1015,13 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
return
mainlist, cardalist, cardblist = job.result
self.memory_view.set_database(mainlist)
+ self.memory_view.model().set_book_in_library_func(self.book_in_library, 'main')
self.memory_view.set_editable(self.device_manager.device.CAN_SET_METADATA)
self.card_a_view.set_database(cardalist)
+ self.card_a_view.model().set_book_in_library_func(self.book_in_library, 'carda')
self.card_a_view.set_editable(self.device_manager.device.CAN_SET_METADATA)
self.card_b_view.set_database(cardblist)
+ self.card_b_view.model().set_book_in_library_func(self.book_in_library, 'cardb')
self.card_b_view.set_editable(self.device_manager.device.CAN_SET_METADATA)
for view in (self.memory_view, self.card_a_view, self.card_b_view):
view.sortByColumn(3, Qt.DescendingOrder)
@@ -1025,6 +1031,18 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
view.resize_on_select = not view.isVisible()
self.sync_news()
self.sync_catalogs()
+ self.refresh_ondevice_info()
+
+ ############################################################################
+ ### Force the library view to refresh, taking into consideration books information
+ def refresh_ondevice_info(self, clear_flags = False):
+# self.library_view.model().db.set_all_ondevice('')
+# if not clear_flags:
+# for id in self.library_view.model().db:
+# self.library_view.model().db.set_book_on_device_string(id, index_is_id=True))
+ self.library_view.model().refresh()
+ ############################################################################
+
############################################################################
######################### Fetch annotations ################################
@@ -2250,7 +2268,9 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
def library_moved(self, newloc):
if newloc is None: return
db = LibraryDatabase2(newloc)
+ db.set_book_on_device_func(self.book_on_device)
self.library_view.set_database(db)
+ self.library_view.model().set_book_on_device_func(self.book_on_device)
self.status_bar.clearMessage()
self.search.clear_to_help()
self.status_bar.reset_info()
diff --git a/src/calibre/library/caches.py b/src/calibre/library/caches.py
index 2e7c7c4ca8..e901613fca 100644
--- a/src/calibre/library/caches.py
+++ b/src/calibre/library/caches.py
@@ -518,6 +518,7 @@ class ResultCache(SearchQueryParser):
try:
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))
+ self._data[id].append(db.book_on_device_string(id, index_is_id=True))
except IndexError:
return None
try:
@@ -533,6 +534,7 @@ class ResultCache(SearchQueryParser):
for id in ids:
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))
+ self._data[id].append(db.book_on_device_string(id, index_is_id=True))
self._map[0:0] = ids
self._map_filtered[0:0] = ids
@@ -553,6 +555,7 @@ class ResultCache(SearchQueryParser):
for item in self._data:
if item is not None:
item.append(db.has_cover(item[0], index_is_id=True))
+ item.append(db.book_on_device_string(item[0], index_is_id=True))
self._map = [i[0] for i in self._data if i is not None]
if field is not None:
self.sort(field, ascending)
diff --git a/src/calibre/library/database.py b/src/calibre/library/database.py
index cfd2213eed..6147101567 100644
--- a/src/calibre/library/database.py
+++ b/src/calibre/library/database.py
@@ -1070,6 +1070,9 @@ ALTER TABLE books ADD COLUMN isbn TEXT DEFAULT "" COLLATE NOCASE;
return [ (i[0], i[1]) for i in \
self.conn.get('SELECT id, name FROM tags')]
+ def all_titles(self):
+ return [ (i[0], i[1]) for i in \
+ self.conn.get('SELECT id, title FROM books')]
def conversion_options(self, id, format):
data = self.conn.get('SELECT data FROM conversion_options WHERE book=? AND format=?', (id, format.upper()), all=False)
diff --git a/src/calibre/library/database2.py b/src/calibre/library/database2.py
index 724c1bd41a..6a29b0f8d8 100644
--- a/src/calibre/library/database2.py
+++ b/src/calibre/library/database2.py
@@ -219,6 +219,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
self.FIELD_MAP[col] = base = base+1
self.FIELD_MAP['cover'] = base+1
+ self.FIELD_MAP['ondevice'] = base+2
script = '''
DROP VIEW IF EXISTS meta2;
@@ -230,6 +231,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
self.conn.executescript(script)
self.conn.commit()
+ self.book_on_device_func = None
self.data = ResultCache(self.FIELD_MAP, self.custom_column_label_map)
self.search = self.data.search
self.refresh = functools.partial(self.data.refresh, self)
@@ -465,6 +467,27 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
im = PILImage.open(f)
im.convert('RGB').save(path, 'JPEG')
+ def book_on_device(self, index, index_is_id=False):
+ if self.book_on_device_func:
+ return self.book_on_device_func(index, index_is_id)
+ return None
+
+ def book_on_device_string(self, index, index_is_id=False):
+ loc = []
+ on = self.book_on_device(index, index_is_id)
+ if on is not None:
+ m, a, b = on
+ if m is not None:
+ loc.append(_('Main'))
+ if a is not None:
+ loc.append(_('Card A'))
+ if b is not None:
+ loc.append(_('Card B'))
+ return ', '.join(loc)
+
+ def set_book_on_device_func(self, func):
+ self.book_on_device_func = func
+
def all_formats(self):
formats = self.conn.get('SELECT DISTINCT format from data')
if not formats:
From afadcc7a1be11eedaf2e64cd2b1118559e69ec5f Mon Sep 17 00:00:00 2001
From: Charles Haley <>
Date: Sun, 9 May 2010 10:38:30 +0100
Subject: [PATCH 065/324] Ondevice works with Sony PRS-300
---
src/calibre/devices/usbms/books.py | 8 +++
src/calibre/gui2/device.py | 82 +++++++++++++-----------
src/calibre/gui2/library.py | 11 ++--
src/calibre/gui2/ui.py | 19 +++---
src/calibre/library/caches.py | 2 +-
src/calibre/utils/search_query_parser.py | 1 +
6 files changed, 71 insertions(+), 52 deletions(-)
diff --git a/src/calibre/devices/usbms/books.py b/src/calibre/devices/usbms/books.py
index 4cde8dbe57..e04498b0c8 100644
--- a/src/calibre/devices/usbms/books.py
+++ b/src/calibre/devices/usbms/books.py
@@ -46,6 +46,14 @@ class Book(object):
return self.title.encode('utf-8') + " by " + \
self.authors.encode('utf-8') + " at " + self.path.encode('utf-8')
+ @dynamic_property
+ def db_id(self):
+ doc = '''The database id in the application database that this file corresponds to'''
+ def fget(self):
+ match = re.search(r'_(\d+)$', self.rpath.rpartition('.')[0])
+ if match:
+ return int(match.group(1))
+ return property(fget=fget, doc=doc)
class BookList(_BookList):
diff --git a/src/calibre/gui2/device.py b/src/calibre/gui2/device.py
index 11f7c91a95..b3c01e0119 100644
--- a/src/calibre/gui2/device.py
+++ b/src/calibre/gui2/device.py
@@ -979,54 +979,62 @@ class DeviceGUI(object):
if memory and memory[1]:
self.library_view.model().delete_books_by_id(memory[1])
- def book_on_device(self, index, index_is_id=False, format=None):
+ def book_on_device(self, index, index_is_id=False, format=None, reset=False):
loc = [None, None, None]
+ if reset:
+ self.book_on_device_cache = None
+ return
+
+ if self.book_on_device_cache is None:
+ self.book_on_device_cache = []
+ for i, l in enumerate(self.booklists()):
+ self.book_on_device_cache.append({})
+ for book in l:
+ book_title = book.title.lower() if book.title else ''
+ book_title = re.sub('(?u)\W|[_]', '', book_title)
+ if book_title not in self.book_on_device_cache[i]:
+ self.book_on_device_cache[i][book_title] = \
+ {'authors':set(), 'db_ids':set()}
+ book_authors = authors_to_string(book.authors).lower()
+ book_authors = re.sub('(?u)\W|[_]', '', book_authors)
+ self.book_on_device_cache[i][book_title]['authors'].add(book_authors)
+ self.book_on_device_cache[i][book_title]['db_ids'].add(book.db_id)
+
db_title = self.library_view.model().db.title(index, index_is_id).lower()
db_title = re.sub('(?u)\W|[_]', '', db_title)
au = self.library_view.model().db.authors(index, index_is_id)
db_authors = au.lower() if au else ''
db_authors = re.sub('(?u)\W|[_]', '', db_authors)
-
for i, l in enumerate(self.booklists()):
- for book in l:
- book_title = book.title.lower() if book.title else ''
- book_title = re.sub('(?u)\W|[_]', '', book_title)
- book_authors = authors_to_string(book.authors).lower()
- book_authors = re.sub('(?u)\W|[_]', '', book_authors)
- if book_title == db_title and book_authors == db_authors:
- loc[i] = True
- break
+ d = self.book_on_device_cache[i].get(db_title, None)
+ if d and (index in d['db_ids'] or db_authors in d['authors']):
+ loc[i] = True
+ break
return loc
- def book_in_library(self, index, oncard=None):
+ def set_books_in_library(self, booklist):
'''
- Used to determine if a book on the device is in the library.
- Returns the book's id in the library.
+ Set the 'in_library' attribute for all books on a device to True if a
+ book on the device is in the library, else False
'''
- bl = []
- if oncard == 'carda':
- bl = self.booklists()[1]
- elif oncard == 'cardb':
- bl = self.booklists()[2]
- else:
- bl = self.booklists()[0]
-
- book = bl[index]
- book_title = book.title.lower() if book.title else ''
- book_title = re.sub('(?u)\W|[_]', '', book_title)
- book_authors = authors_to_string(book.authors).lower() if book.authors else ''
- book_authors = re.sub('(?u)\W|[_]', '', book_authors)
-
-# if getattr(book, 'application_id', None) != None and self.library_view.model().db.has_id(book.application_id):
-# if book.uuid and self.library_view.model().db.uuid(book.application_id, index_is_id=True) == book.uuid:
-# return book.application_id
+ # First build a cache of the library, so the search isn't On**2
+ cache = {}
for id, title in self.library_view.model().db.all_titles():
title = re.sub('(?u)\W|[_]', '', title.lower())
- if title == book_title:
- au = self.library_view.model().db.authors(id, index_is_id=True)
- authors = au.lower() if au else ''
- authors = re.sub('(?u)\W|[_]', '', authors)
- if authors == book_authors:
- return id
- return None
+ au = self.library_view.model().db.authors(id, index_is_id=True)
+ authors = au.lower() if au else ''
+ authors = re.sub('(?u)\W|[_]', '', authors)
+ cache[title+authors] = id
+
+ # Now iterate through all the books on the device, setting the in_library field
+ for book in booklist:
+ book_title = book.title.lower() if book.title else ''
+ book_title = re.sub('(?u)\W|[_]', '', book_title)
+ book_authors = authors_to_string(book.authors).lower() if book.authors else ''
+ book_authors = re.sub('(?u)\W|[_]', '', book_authors)
+
+ if cache.get(book_title + book_authors, None) is not None:
+ book.in_library = True
+ else:
+ book.in_library = False
diff --git a/src/calibre/gui2/library.py b/src/calibre/gui2/library.py
index 074cb3b00e..e133d7a0bd 100644
--- a/src/calibre/gui2/library.py
+++ b/src/calibre/gui2/library.py
@@ -1268,6 +1268,8 @@ class DeviceBooksModel(BooksModel):
def set_book_in_library_func(self, func, loc):
self.book_in_library = func
self.loc = loc
+ # Not convinced that this should be here ...
+ func(self.db)
def mark_for_deletion(self, job, rows):
self.marked_for_deletion[job] = self.indices(rows)
@@ -1356,7 +1358,7 @@ class DeviceBooksModel(BooksModel):
x, y = ','.join(self.db[x].tags), ','.join(self.db[y].tags)
return cmp(x, y)
def libcmp(x, y):
- x, y = self.book_in_library(self.map[x], self.loc), self.book_in_library(self.map[y], self.loc)
+ x, y = self.db[x].in_library, self.db[y].in_library
return cmp(x, y)
fcmp = strcmp('title_sorter') if col == 0 else strcmp('authors') if col == 1 else \
sizecmp if col == 2 else datecmp if col == 3 else tagscmp if col == 4 else libcmp
@@ -1429,7 +1431,7 @@ class DeviceBooksModel(BooksModel):
if role == Qt.EditRole:
return QVariant(au)
authors = string_to_authors(au)
- return QVariant("\n".join(authors))
+ return QVariant(" & ".join(authors))
elif col == 2:
size = self.db[self.map[row]].size
return QVariant(BooksView.human_readable(size))
@@ -1442,9 +1444,8 @@ class DeviceBooksModel(BooksModel):
if tags:
return QVariant(', '.join(tags))
elif col == 5:
- if self.book_in_library:
- if self.book_in_library(self.map[row], self.loc) != None:
- return QVariant(_("True"))
+ return QVariant(_('Yes')) \
+ if self.db[self.map[row]].in_library else QVariant(_('No'))
elif role == Qt.TextAlignmentRole and index.column() in [2, 3]:
return QVariant(Qt.AlignRight | Qt.AlignVCenter)
elif role == Qt.ToolTipRole and index.isValid():
diff --git a/src/calibre/gui2/ui.py b/src/calibre/gui2/ui.py
index 6c74763105..df7c246e76 100644
--- a/src/calibre/gui2/ui.py
+++ b/src/calibre/gui2/ui.py
@@ -520,6 +520,7 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
if self.system_tray_icon.isVisible() and opts.start_in_tray:
self.hide_windows()
self.stack.setCurrentIndex(0)
+ self.book_on_device(None, reset=True)
db.set_book_on_device_func(self.book_on_device)
self.library_view.set_database(db)
self.library_view.model().set_book_on_device_func(self.book_on_device)
@@ -993,13 +994,13 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
return
mainlist, cardalist, cardblist = job.result
self.memory_view.set_database(mainlist)
- self.memory_view.model().set_book_in_library_func(self.book_in_library, 'main')
+ self.memory_view.model().set_book_in_library_func(self.set_books_in_library, 'main')
self.memory_view.set_editable(self.device_manager.device.CAN_SET_METADATA)
self.card_a_view.set_database(cardalist)
- self.card_a_view.model().set_book_in_library_func(self.book_in_library, 'carda')
+ self.card_a_view.model().set_book_in_library_func(self.set_books_in_library, 'carda')
self.card_a_view.set_editable(self.device_manager.device.CAN_SET_METADATA)
self.card_b_view.set_database(cardblist)
- self.card_b_view.model().set_book_in_library_func(self.book_in_library, 'cardb')
+ self.card_b_view.model().set_book_in_library_func(self.set_books_in_library, 'cardb')
self.card_b_view.set_editable(self.device_manager.device.CAN_SET_METADATA)
for view in (self.memory_view, self.card_a_view, self.card_b_view):
view.sortByColumn(3, Qt.DescendingOrder)
@@ -1007,6 +1008,10 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
if not view.restore_column_widths():
view.resizeColumnsToContents()
view.resize_on_select = not view.isVisible()
+ if view.model().rowCount(None) > 1:
+ view.resizeRowToContents(0)
+ height = view.rowHeight(0)
+ view.verticalHeader().setDefaultSectionSize(height)
self.sync_news()
self.sync_catalogs()
self.refresh_ondevice_info()
@@ -1014,15 +1019,10 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
############################################################################
### Force the library view to refresh, taking into consideration books information
def refresh_ondevice_info(self, clear_flags = False):
-# self.library_view.model().db.set_all_ondevice('')
-# if not clear_flags:
-# for id in self.library_view.model().db:
-# self.library_view.model().db.set_book_on_device_string(id, index_is_id=True))
+ self.book_on_device(None, reset=True)
self.library_view.model().refresh()
############################################################################
- ############################################################################
-
######################### Fetch annotations ################################
def fetch_annotations(self, *args):
@@ -2246,6 +2246,7 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
def library_moved(self, newloc):
if newloc is None: return
db = LibraryDatabase2(newloc)
+ self.book_on_device(None, reset=True)
db.set_book_on_device_func(self.book_on_device)
self.library_view.set_database(db)
self.library_view.model().set_book_on_device_func(self.book_on_device)
diff --git a/src/calibre/library/caches.py b/src/calibre/library/caches.py
index e901613fca..8830d0538a 100644
--- a/src/calibre/library/caches.py
+++ b/src/calibre/library/caches.py
@@ -370,7 +370,7 @@ class ResultCache(SearchQueryParser):
location += 's'
all = ('title', 'authors', 'publisher', 'tags', 'comments', 'series',
- 'formats', 'isbn', 'rating', 'cover')
+ 'formats', 'isbn', 'rating', 'cover', 'ondevice')
MAP = {}
for x in all: # get the db columns for the standard searchables
diff --git a/src/calibre/utils/search_query_parser.py b/src/calibre/utils/search_query_parser.py
index 79324e6b8b..11991727b7 100644
--- a/src/calibre/utils/search_query_parser.py
+++ b/src/calibre/utils/search_query_parser.py
@@ -100,6 +100,7 @@ class SearchQueryParser(object):
'search',
'date',
'pubdate',
+ 'ondevice',
'all',
]
From b7116d6e50eca45b1b80eb236d60bfc029b3402f Mon Sep 17 00:00:00 2001
From: Charles Haley <>
Date: Sun, 9 May 2010 17:04:32 +0100
Subject: [PATCH 066/324] Fix #5493 - pubdate gui format tweak
---
resources/default_tweaks.py | 21 ++++++++++++++++++++-
src/calibre/gui2/dialogs/metadata_single.py | 3 +++
src/calibre/gui2/library.py | 5 ++++-
3 files changed, 27 insertions(+), 2 deletions(-)
diff --git a/resources/default_tweaks.py b/resources/default_tweaks.py
index b18789565d..5c15651f9c 100644
--- a/resources/default_tweaks.py
+++ b/resources/default_tweaks.py
@@ -41,4 +41,23 @@ bool_custom_columns_are_tristate = 'yes'
# 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
+sort_columns_at_startup = None
+
+# Format to be used for publication date
+# A string controlling how the publication date is displayed in the GUI
+# d the day as number without a leading zero (1 to 31)
+# dd the day as number with a leading zero (01 to 31)
+# ddd the abbreviated localized day name (e.g. 'Mon' to 'Sun').
+# dddd the long localized day name (e.g. 'Monday' to 'Qt::Sunday').
+# M the month as number without a leading zero (1-12)
+# MM the month as number with a leading zero (01-12)
+# MMM the abbreviated localized month name (e.g. 'Jan' to 'Dec').
+# MMMM the long localized month name (e.g. 'January' to 'December').
+# yy the year as two digit number (00-99)
+# yyyy the year as four digit number
+# For example, given the date of 9 Jan 2010, the following formats show
+# MMM yyyy ==> Jan 2010 yyyy ==> 2010 dd MMM yyyy ==> 09 Jan 2010
+# MM/yyyy ==> 01/2010 d/M/yy ==> 9/1/10 yy ==> 10
+# default if not set: MMM yyyy
+gui_pubdate_display_format = 'MMM yyyy'
+
diff --git a/src/calibre/gui2/dialogs/metadata_single.py b/src/calibre/gui2/dialogs/metadata_single.py
index 95a2102cc1..1ea6743ae2 100644
--- a/src/calibre/gui2/dialogs/metadata_single.py
+++ b/src/calibre/gui2/dialogs/metadata_single.py
@@ -313,6 +313,9 @@ class MetadataSingleDialog(ResizableDialog, Ui_MetadataSingleDialog):
self.cpixmap = None
self.cover.setAcceptDrops(True)
self.pubdate.setMinimumDate(QDate(100,1,1))
+ pubdate_format = tweaks['gui_pubdate_display_format']
+ if pubdate_format is not None:
+ self.pubdate.setDisplayFormat(pubdate_format)
self.date.setMinimumDate(QDate(100,1,1))
self.connect(self.cover, SIGNAL('cover_changed(PyQt_PyObject)'), self.cover_dropped)
diff --git a/src/calibre/gui2/library.py b/src/calibre/gui2/library.py
index ba9a5b0b29..e40403f1f4 100644
--- a/src/calibre/gui2/library.py
+++ b/src/calibre/gui2/library.py
@@ -124,7 +124,10 @@ class PubDateDelegate(QStyledItemDelegate):
d = val.toDate()
if d == UNDEFINED_QDATE:
return ''
- return d.toString('MMM yyyy')
+ format = tweaks['gui_pubdate_display_format']
+ if format is None:
+ format = 'MMM yyyy'
+ return d.toString(format)
def createEditor(self, parent, option, index):
qde = QStyledItemDelegate.createEditor(self, parent, option, index)
From fb9b44267711792d3e2d9e1fd36ba6613eaedd2b Mon Sep 17 00:00:00 2001
From: Charles Haley <>
Date: Sun, 9 May 2010 18:20:45 +0100
Subject: [PATCH 067/324] Fixes #5484. Change the '*' to a checkbox to remove
all tags in bulk metadata edit.
---
src/calibre/gui2/custom_column_widgets.py | 35 +++++++++++++++++++----
1 file changed, 29 insertions(+), 6 deletions(-)
diff --git a/src/calibre/gui2/custom_column_widgets.py b/src/calibre/gui2/custom_column_widgets.py
index 1ddb501677..de9d839684 100644
--- a/src/calibre/gui2/custom_column_widgets.py
+++ b/src/calibre/gui2/custom_column_widgets.py
@@ -10,7 +10,7 @@ from functools import partial
from PyQt4.Qt import QComboBox, QLabel, QSpinBox, QDoubleSpinBox, QDateEdit, \
QDate, QGroupBox, QVBoxLayout, QPlainTextEdit, QSizePolicy, \
- QSpacerItem, QIcon
+ QSpacerItem, QIcon, QCheckBox, QWidget, QHBoxLayout, SIGNAL
from calibre.utils.date import qt_to_dt
from calibre.gui2.widgets import TagsLineEdit, EnComboBox
@@ -337,6 +337,30 @@ class BulkRating(BulkBase, Rating):
class BulkDateTime(BulkBase, DateTime):
pass
+class RemoveTags(QWidget):
+
+ def __init__(self, parent, values):
+ QWidget.__init__(self, parent)
+ layout = QHBoxLayout()
+ layout.setSpacing(5)
+ layout.setContentsMargins(0, 0, 0, 0)
+
+ self.tags_box = TagsLineEdit(parent, values)
+ layout.addWidget(self.tags_box, stretch = 1)
+ # self.tags_box.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Preferred)
+
+ self.checkbox = QCheckBox(_('Remove all tags'), parent)
+ layout.addWidget(self.checkbox)
+ self.setLayout(layout)
+ self.connect(self.checkbox, SIGNAL('stateChanged(int)'), self.box_touched)
+
+ def box_touched(self, state):
+ if state:
+ self.tags_box.setText('')
+ self.tags_box.setEnabled(False)
+ else:
+ self.tags_box.setEnabled(True)
+
class BulkText(BulkBase):
def setup_ui(self, parent):
@@ -349,8 +373,7 @@ class BulkText(BulkBase):
_('tags to add'), parent), w]
self.adding_widget = w
- w = TagsLineEdit(parent, values)
- w.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Preferred)
+ w = RemoveTags(parent, values)
self.widgets.append(QLabel('&'+self.col_metadata['name']+': ' +
_('tags to remove'), parent))
self.widgets.append(w)
@@ -381,14 +404,14 @@ class BulkText(BulkBase):
def getter(self, original_value = None):
if self.col_metadata['is_multiple']:
- if self.removing_widget.text() == '*':
+ if self.removing_widget.checkbox.isChecked():
ans = set()
else:
ans = set(original_value)
ans -= set([v.strip() for v in
- unicode(self.removing_widget.text()).split(',')])
+ unicode(self.removing_widget.tags_box.text()).split(',')])
ans |= set([v.strip() for v in
- unicode(self.adding_widget.text()).split(',')])
+ unicode(self.adding_widget.text()).split(',')])
return ans # returning a set instead of a list works, for now at least.
val = unicode(self.widgets[1].currentText()).strip()
if not val:
From 27635f58f9897369818e81b348d1210044372c7e Mon Sep 17 00:00:00 2001
From: Charles Haley <>
Date: Sun, 9 May 2010 19:21:50 +0100
Subject: [PATCH 068/324] Moved db_id addition to USBMS. Added db_ids to
Jetbook and Kindle overrides. Refactored index_is_id out, since it was always
True. Implemented db_id lookup in the on_library cache.
---
src/calibre/devices/jetbook/driver.py | 8 +++++++-
src/calibre/devices/kindle/driver.py | 5 +++++
src/calibre/devices/prs505/driver.py | 8 --------
src/calibre/devices/usbms/device.py | 8 +++++++-
src/calibre/gui2/device.py | 28 ++++++++++++++++-----------
src/calibre/library/caches.py | 6 +++---
src/calibre/library/database2.py | 8 ++++----
7 files changed, 43 insertions(+), 28 deletions(-)
diff --git a/src/calibre/devices/jetbook/driver.py b/src/calibre/devices/jetbook/driver.py
index e4fd840dc0..71b825f5d8 100644
--- a/src/calibre/devices/jetbook/driver.py
+++ b/src/calibre/devices/jetbook/driver.py
@@ -55,7 +55,13 @@ class JETBOOK(USBMS):
au = mi.format_authors()
if not au:
au = 'Unknown'
- return '%s#%s%s' % (au, title, fileext)
+ suffix = ''
+ if getattr(mi, 'application_id', None) is not None:
+ base = fname.rpartition('.')[0]
+ suffix = '_%s'%mi.application_id
+ if base.endswith(suffix):
+ suffix = ''
+ return '%s#%s%s%s' % (au, title, fileext, suffix)
@classmethod
def metadata_from_path(cls, path):
diff --git a/src/calibre/devices/kindle/driver.py b/src/calibre/devices/kindle/driver.py
index 83eae78de0..c3ae0ef868 100644
--- a/src/calibre/devices/kindle/driver.py
+++ b/src/calibre/devices/kindle/driver.py
@@ -61,6 +61,11 @@ class KINDLE(USBMS):
return mi
def filename_callback(self, fname, mi):
+ if getattr(mi, 'application_id', None) is not None:
+ base = fname.rpartition('.')[0]
+ suffix = '_%s'%mi.application_id
+ if not base.endswith(suffix):
+ fname = base + suffix + '.' + fname.rpartition('.')[-1]
if fname.startswith('.'):
return 'x'+fname[1:]
return fname
diff --git a/src/calibre/devices/prs505/driver.py b/src/calibre/devices/prs505/driver.py
index f4fc4b0d29..e73a341909 100644
--- a/src/calibre/devices/prs505/driver.py
+++ b/src/calibre/devices/prs505/driver.py
@@ -121,14 +121,6 @@ class PRS505(CLI, Device):
self.report_progress(1.0, _('Getting list of books on device...'))
return bl
- def filename_callback(self, fname, mi):
- if getattr(mi, 'application_id', None) is not None:
- base = fname.rpartition('.')[0]
- suffix = '_%s'%mi.application_id
- if not base.endswith(suffix):
- fname = base + suffix + '.' + fname.rpartition('.')[-1]
- return fname
-
def upload_books(self, files, names, on_card=None, end_session=True,
metadata=None):
diff --git a/src/calibre/devices/usbms/device.py b/src/calibre/devices/usbms/device.py
index 897baf82ca..ce6a17d731 100644
--- a/src/calibre/devices/usbms/device.py
+++ b/src/calibre/devices/usbms/device.py
@@ -784,8 +784,14 @@ class Device(DeviceConfig, DevicePlugin):
def filename_callback(self, default, mi):
'''
Callback to allow drivers to change the default file name
- set by :method:`create_upload_path`.
+ set by :method:`create_upload_path`. By default, add the DB_ID
+ to the end of the string. Helps with ondevice doc matching
'''
+ if getattr(mi, 'application_id', None) is not None:
+ base = default.rpartition('.')[0]
+ suffix = '_%s'%mi.application_id
+ if not base.endswith(suffix):
+ default = base + suffix + '.' + default.rpartition('.')[-1]
return default
def sanitize_path_components(self, components):
diff --git a/src/calibre/gui2/device.py b/src/calibre/gui2/device.py
index b3c01e0119..ab98470a22 100644
--- a/src/calibre/gui2/device.py
+++ b/src/calibre/gui2/device.py
@@ -979,7 +979,7 @@ class DeviceGUI(object):
if memory and memory[1]:
self.library_view.model().delete_books_by_id(memory[1])
- def book_on_device(self, index, index_is_id=False, format=None, reset=False):
+ def book_on_device(self, index, format=None, reset=False):
loc = [None, None, None]
if reset:
@@ -1001,9 +1001,9 @@ class DeviceGUI(object):
self.book_on_device_cache[i][book_title]['authors'].add(book_authors)
self.book_on_device_cache[i][book_title]['db_ids'].add(book.db_id)
- db_title = self.library_view.model().db.title(index, index_is_id).lower()
+ db_title = self.library_view.model().db.title(index, index_is_id=True).lower()
db_title = re.sub('(?u)\W|[_]', '', db_title)
- au = self.library_view.model().db.authors(index, index_is_id)
+ au = self.library_view.model().db.authors(index, index_is_id=True)
db_authors = au.lower() if au else ''
db_authors = re.sub('(?u)\W|[_]', '', db_authors)
for i, l in enumerate(self.booklists()):
@@ -1022,19 +1022,25 @@ class DeviceGUI(object):
cache = {}
for id, title in self.library_view.model().db.all_titles():
title = re.sub('(?u)\W|[_]', '', title.lower())
+ if title not in cache:
+ cache[title] = {'authors':set(), 'db_ids':set()}
au = self.library_view.model().db.authors(id, index_is_id=True)
authors = au.lower() if au else ''
authors = re.sub('(?u)\W|[_]', '', authors)
- cache[title+authors] = id
+ cache[title]['authors'].add(authors)
+ cache[title]['db_ids'].add(id)
# Now iterate through all the books on the device, setting the in_library field
for book in booklist:
book_title = book.title.lower() if book.title else ''
book_title = re.sub('(?u)\W|[_]', '', book_title)
- book_authors = authors_to_string(book.authors).lower() if book.authors else ''
- book_authors = re.sub('(?u)\W|[_]', '', book_authors)
-
- if cache.get(book_title + book_authors, None) is not None:
- book.in_library = True
- else:
- book.in_library = False
+ book.in_library = False
+ d = cache.get(book_title, None)
+ if d is not None:
+ if book.db_id in d['db_ids']:
+ book.in_library = True
+ continue
+ book_authors = authors_to_string(book.authors).lower() if book.authors else ''
+ book_authors = re.sub('(?u)\W|[_]', '', book_authors)
+ if book_authors in d['authors']:
+ book.in_library = True
diff --git a/src/calibre/library/caches.py b/src/calibre/library/caches.py
index 8830d0538a..9ed150733a 100644
--- a/src/calibre/library/caches.py
+++ b/src/calibre/library/caches.py
@@ -518,7 +518,7 @@ class ResultCache(SearchQueryParser):
try:
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))
- self._data[id].append(db.book_on_device_string(id, index_is_id=True))
+ self._data[id].append(db.book_on_device_string(id))
except IndexError:
return None
try:
@@ -534,7 +534,7 @@ class ResultCache(SearchQueryParser):
for id in ids:
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))
- self._data[id].append(db.book_on_device_string(id, index_is_id=True))
+ self._data[id].append(db.book_on_device_string(id))
self._map[0:0] = ids
self._map_filtered[0:0] = ids
@@ -555,7 +555,7 @@ class ResultCache(SearchQueryParser):
for item in self._data:
if item is not None:
item.append(db.has_cover(item[0], index_is_id=True))
- item.append(db.book_on_device_string(item[0], index_is_id=True))
+ item.append(db.book_on_device_string(item[0]))
self._map = [i[0] for i in self._data if i is not None]
if field is not None:
self.sort(field, ascending)
diff --git a/src/calibre/library/database2.py b/src/calibre/library/database2.py
index 4bc96d2c20..a50c840930 100644
--- a/src/calibre/library/database2.py
+++ b/src/calibre/library/database2.py
@@ -470,14 +470,14 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
im = PILImage.open(f)
im.convert('RGB').save(path, 'JPEG')
- def book_on_device(self, index, index_is_id=False):
+ def book_on_device(self, index):
if self.book_on_device_func:
- return self.book_on_device_func(index, index_is_id)
+ return self.book_on_device_func(index)
return None
- def book_on_device_string(self, index, index_is_id=False):
+ def book_on_device_string(self, index):
loc = []
- on = self.book_on_device(index, index_is_id)
+ on = self.book_on_device(index)
if on is not None:
m, a, b = on
if m is not None:
From 660668f718d0868fd175fecf3e273408e2e3db21 Mon Sep 17 00:00:00 2001
From: Charles Haley <>
Date: Sun, 9 May 2010 19:37:38 +0100
Subject: [PATCH 069/324] Refactor books_in_library to get rid of call back
that wasn't used, and also to build library titles cache once.
---
src/calibre/gui2/device.py | 24 ++++++++++++------------
src/calibre/gui2/library.py | 7 -------
src/calibre/gui2/ui.py | 7 ++++---
3 files changed, 16 insertions(+), 22 deletions(-)
diff --git a/src/calibre/gui2/device.py b/src/calibre/gui2/device.py
index ab98470a22..855d05ff58 100644
--- a/src/calibre/gui2/device.py
+++ b/src/calibre/gui2/device.py
@@ -1013,29 +1013,29 @@ class DeviceGUI(object):
break
return loc
- def set_books_in_library(self, booklist):
- '''
- Set the 'in_library' attribute for all books on a device to True if a
- book on the device is in the library, else False
- '''
- # First build a cache of the library, so the search isn't On**2
- cache = {}
+ def set_books_in_library(self, booklist, reset = False):
+ if reset:
+ self.book_in_library_cache = None
+ return
+
+ # First build a self.book_in_library_cache of the library, so the search isn't On**2
+ self.book_in_library_cache = {}
for id, title in self.library_view.model().db.all_titles():
title = re.sub('(?u)\W|[_]', '', title.lower())
- if title not in cache:
- cache[title] = {'authors':set(), 'db_ids':set()}
+ if title not in self.book_in_library_cache:
+ self.book_in_library_cache[title] = {'authors':set(), 'db_ids':set()}
au = self.library_view.model().db.authors(id, index_is_id=True)
authors = au.lower() if au else ''
authors = re.sub('(?u)\W|[_]', '', authors)
- cache[title]['authors'].add(authors)
- cache[title]['db_ids'].add(id)
+ self.book_in_library_cache[title]['authors'].add(authors)
+ self.book_in_library_cache[title]['db_ids'].add(id)
# Now iterate through all the books on the device, setting the in_library field
for book in booklist:
book_title = book.title.lower() if book.title else ''
book_title = re.sub('(?u)\W|[_]', '', book_title)
book.in_library = False
- d = cache.get(book_title, None)
+ d = self.book_in_library_cache.get(book_title, None)
if d is not None:
if book.db_id in d['db_ids']:
book.in_library = True
diff --git a/src/calibre/gui2/library.py b/src/calibre/gui2/library.py
index 5d4686b4d2..90dc3eb1ea 100644
--- a/src/calibre/gui2/library.py
+++ b/src/calibre/gui2/library.py
@@ -1266,13 +1266,6 @@ class DeviceBooksModel(BooksModel):
self.search_engine = OnDeviceSearch(self)
self.editable = True
self.book_in_library = None
- self.loc = None
-
- def set_book_in_library_func(self, func, loc):
- self.book_in_library = func
- self.loc = loc
- # Not convinced that this should be here ...
- func(self.db)
def mark_for_deletion(self, job, rows):
self.marked_for_deletion[job] = self.indices(rows)
diff --git a/src/calibre/gui2/ui.py b/src/calibre/gui2/ui.py
index df7c246e76..4f5e71174c 100644
--- a/src/calibre/gui2/ui.py
+++ b/src/calibre/gui2/ui.py
@@ -992,15 +992,16 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
else:
self.device_job_exception(job)
return
+ self.set_books_in_library(None, reset=True)
mainlist, cardalist, cardblist = job.result
self.memory_view.set_database(mainlist)
- self.memory_view.model().set_book_in_library_func(self.set_books_in_library, 'main')
+ self.set_books_in_library(mainlist)
self.memory_view.set_editable(self.device_manager.device.CAN_SET_METADATA)
self.card_a_view.set_database(cardalist)
- self.card_a_view.model().set_book_in_library_func(self.set_books_in_library, 'carda')
+ self.set_books_in_library(cardalist)
self.card_a_view.set_editable(self.device_manager.device.CAN_SET_METADATA)
self.card_b_view.set_database(cardblist)
- self.card_b_view.model().set_book_in_library_func(self.set_books_in_library, 'cardb')
+ self.set_books_in_library(cardblist)
self.card_b_view.set_editable(self.device_manager.device.CAN_SET_METADATA)
for view in (self.memory_view, self.card_a_view, self.card_b_view):
view.sortByColumn(3, Qt.DescendingOrder)
From 06f6f8cbebf9dddea51f8377bac079a033f2dc67 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Sun, 9 May 2010 15:20:51 -0600
Subject: [PATCH 070/324] Add icon to In Device column and fix a couple of
minor typos
---
src/calibre/devices/usbms/books.py | 2 +-
src/calibre/gui2/library.py | 12 +++++++++++-
src/calibre/gui2/ui.py | 4 ++--
3 files changed, 14 insertions(+), 4 deletions(-)
diff --git a/src/calibre/devices/usbms/books.py b/src/calibre/devices/usbms/books.py
index 1ddc00729f..50756ef3ee 100644
--- a/src/calibre/devices/usbms/books.py
+++ b/src/calibre/devices/usbms/books.py
@@ -49,7 +49,7 @@ class Book(object):
@property
def db_id(self):
'''The database id in the application database that this file corresponds to'''
- match = re.search(r'_(\d+)$', self.rpath.rpartition('.')[0])
+ match = re.search(r'_(\d+)$', self.path.rpartition('.')[0])
if match:
return int(match.group(1))
diff --git a/src/calibre/gui2/library.py b/src/calibre/gui2/library.py
index 90dc3eb1ea..0ee5f36a59 100644
--- a/src/calibre/gui2/library.py
+++ b/src/calibre/gui2/library.py
@@ -778,6 +778,12 @@ class BooksModel(QAbstractTableModel):
return self.bool_blank_icon
return self.bool_no_icon
+ def ondevice_decorator(r, idx=-1):
+ text = self.db.data[r][idx]
+ if text:
+ return self.bool_yes_icon
+ return self.bool_blank_icon
+
def text_type(r, mult=False, idx=-1):
text = self.db.data[r][idx]
if text and mult:
@@ -810,7 +816,11 @@ class BooksModel(QAbstractTableModel):
'ondevice' : functools.partial(text_type,
idx=self.db.FIELD_MAP['ondevice'], mult=False),
}
- self.dc_decorator = {}
+
+ self.dc_decorator = {
+ 'ondevice':functools.partial(ondevice_decorator,
+ idx=self.db.FIELD_MAP['ondevice']),
+ }
# Add the custom columns to the data converters
for col in self.custom_columns:
diff --git a/src/calibre/gui2/ui.py b/src/calibre/gui2/ui.py
index 4f5e71174c..e97665909f 100644
--- a/src/calibre/gui2/ui.py
+++ b/src/calibre/gui2/ui.py
@@ -959,7 +959,7 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
self.status_bar.reset_info()
self.location_view.setCurrentIndex(self.location_view.model().index(0))
self.eject_action.setEnabled(False)
- self.refresh_ondevice_info (clear_info = True)
+ self.refresh_ondevice_info(clear_flags=True)
def info_read(self, job):
'''
@@ -1019,7 +1019,7 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
############################################################################
### Force the library view to refresh, taking into consideration books information
- def refresh_ondevice_info(self, clear_flags = False):
+ def refresh_ondevice_info(self, clear_flags=False):
self.book_on_device(None, reset=True)
self.library_view.model().refresh()
############################################################################
From a2bf9e3696c33d7c26ec89551ed41f85b79f8710 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Sun, 9 May 2010 15:30:25 -0600
Subject: [PATCH 071/324] Use icons for the In Library column
---
src/calibre/gui2/library.py | 11 ++++++-----
1 file changed, 6 insertions(+), 5 deletions(-)
diff --git a/src/calibre/gui2/library.py b/src/calibre/gui2/library.py
index 0ee5f36a59..75b1d672cd 100644
--- a/src/calibre/gui2/library.py
+++ b/src/calibre/gui2/library.py
@@ -1420,11 +1420,11 @@ class DeviceBooksModel(BooksModel):
'''
Return indices into underlying database from rows
'''
- return [ self.map[r.row()] for r in rows]
+ return [self.map[r.row()] for r in rows]
def data(self, index, role):
+ row, col = index.row(), index.column()
if role == Qt.DisplayRole or role == Qt.EditRole:
- row, col = index.row(), index.column()
if col == 0:
text = self.db[self.map[row]].title
if not text:
@@ -1449,9 +1449,6 @@ class DeviceBooksModel(BooksModel):
tags = self.db[self.map[row]].tags
if tags:
return QVariant(', '.join(tags))
- elif col == 5:
- return QVariant(_('Yes')) \
- if self.db[self.map[row]].in_library else QVariant(_('No'))
elif role == Qt.TextAlignmentRole and index.column() in [2, 3]:
return QVariant(Qt.AlignRight | Qt.AlignVCenter)
elif role == Qt.ToolTipRole and index.isValid():
@@ -1460,6 +1457,10 @@ class DeviceBooksModel(BooksModel):
col = index.column()
if col in [0, 1] or (col == 4 and self.db.supports_tags()):
return QVariant(_("Double click to edit me
"))
+ elif role == Qt.DecorationRole and col == 5:
+ if self.db[self.map[row]].in_library:
+ return QVariant(self.bool_yes_icon)
+
return NONE
def headerData(self, section, orientation, role):
From 5f2fe8fc54ac25ee37c58a61c571a2b1f41e7d3a Mon Sep 17 00:00:00 2001
From: Charles Haley <>
Date: Sun, 9 May 2010 22:39:50 +0100
Subject: [PATCH 072/324] Make ondevice column appear and disappear when device
is connected or disconnected
---
src/calibre/gui2/library.py | 12 +++++++++++-
src/calibre/gui2/ui.py | 9 +++++----
2 files changed, 16 insertions(+), 5 deletions(-)
diff --git a/src/calibre/gui2/library.py b/src/calibre/gui2/library.py
index 90dc3eb1ea..ef0070a91c 100644
--- a/src/calibre/gui2/library.py
+++ b/src/calibre/gui2/library.py
@@ -339,6 +339,7 @@ class BooksModel(QAbstractTableModel):
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'))
+ self.device_connected = False
def is_custom_column(self, cc_label):
return cc_label in self.custom_columns
@@ -353,7 +354,10 @@ class BooksModel(QAbstractTableModel):
self.headers = {}
self.column_map = []
for col in cmap: # take out any columns no longer in the db
- if col in self.orig_headers or col in self.custom_columns:
+ if col == 'ondevice':
+ if self.device_connected:
+ self.column_map.append(col)
+ elif col in self.orig_headers or col in self.custom_columns:
self.column_map.append(col)
for col in self.column_map:
if col in self.orig_headers:
@@ -364,6 +368,12 @@ class BooksModel(QAbstractTableModel):
self.reset()
self.emit(SIGNAL('columns_sorted()'))
+ def set_device_connected(self, is_connected):
+ self.device_connected = is_connected
+ self.read_config()
+ self.refresh(reset=True)
+ self.database_changed.emit(self.db)
+
def set_book_on_device_func(self, func):
self.book_on_device = func
diff --git a/src/calibre/gui2/ui.py b/src/calibre/gui2/ui.py
index 4f5e71174c..3ac93064c8 100644
--- a/src/calibre/gui2/ui.py
+++ b/src/calibre/gui2/ui.py
@@ -947,6 +947,7 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
self.device_manager.device)
self.location_view.model().device_connected(self.device_manager.device)
self.eject_action.setEnabled(True)
+ self.refresh_ondevice_info (device_connected = True)
else:
self.save_device_view_settings()
self.device_connected = False
@@ -959,7 +960,7 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
self.status_bar.reset_info()
self.location_view.setCurrentIndex(self.location_view.model().index(0))
self.eject_action.setEnabled(False)
- self.refresh_ondevice_info (clear_info = True)
+ self.refresh_ondevice_info (device_connected = False)
def info_read(self, job):
'''
@@ -1015,13 +1016,13 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
view.verticalHeader().setDefaultSectionSize(height)
self.sync_news()
self.sync_catalogs()
- self.refresh_ondevice_info()
+ self.refresh_ondevice_info(device_connected = True)
############################################################################
### Force the library view to refresh, taking into consideration books information
- def refresh_ondevice_info(self, clear_flags = False):
+ def refresh_ondevice_info(self, device_connected):
self.book_on_device(None, reset=True)
- self.library_view.model().refresh()
+ self.library_view.model().set_device_connected(device_connected)
############################################################################
######################### Fetch annotations ################################
From 07282e7a799c62a2f7467c020ff85a3869938c5e Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Sun, 9 May 2010 16:35:45 -0600
Subject: [PATCH 073/324] Update on device and in library columns when sending
books to device
---
src/calibre/gui2/device.py | 74 ++++++++++++++++++++++---------------
src/calibre/gui2/library.py | 5 ++-
src/calibre/gui2/ui.py | 5 +--
3 files changed, 48 insertions(+), 36 deletions(-)
diff --git a/src/calibre/gui2/device.py b/src/calibre/gui2/device.py
index 855d05ff58..b051b2e937 100644
--- a/src/calibre/gui2/device.py
+++ b/src/calibre/gui2/device.py
@@ -971,13 +971,29 @@ class DeviceGUI(object):
self.upload_booklists()
+ books_to_be_deleted = []
+ if memory and memory[1]:
+ books_to_be_deleted = memory[1]
+ self.library_view.model().delete_books_by_id(books_to_be_deleted)
+
+ self.set_books_in_library(self.booklists(),
+ reset=bool(books_to_be_deleted))
+
view = self.card_a_view if on_card == 'carda' else self.card_b_view if on_card == 'cardb' else self.memory_view
view.model().resort(reset=False)
view.model().research()
for f in files:
getattr(f, 'close', lambda : True)()
- if memory and memory[1]:
- self.library_view.model().delete_books_by_id(memory[1])
+
+ self.book_on_device(None, reset=True)
+ if metadata:
+ changed = set([])
+ for mi in metadata:
+ id_ = getattr(mi, 'application_id', None)
+ if id_ is not None:
+ changed.add(id_)
+ if changed:
+ self.library_view.model().refresh_ids(list(changed))
def book_on_device(self, index, format=None, reset=False):
loc = [None, None, None]
@@ -1013,34 +1029,32 @@ class DeviceGUI(object):
break
return loc
- def set_books_in_library(self, booklist, reset = False):
+ def set_books_in_library(self, booklists, reset=False):
if reset:
- self.book_in_library_cache = None
- return
-
- # First build a self.book_in_library_cache of the library, so the search isn't On**2
- self.book_in_library_cache = {}
- for id, title in self.library_view.model().db.all_titles():
- title = re.sub('(?u)\W|[_]', '', title.lower())
- if title not in self.book_in_library_cache:
- self.book_in_library_cache[title] = {'authors':set(), 'db_ids':set()}
- au = self.library_view.model().db.authors(id, index_is_id=True)
- authors = au.lower() if au else ''
- authors = re.sub('(?u)\W|[_]', '', authors)
- self.book_in_library_cache[title]['authors'].add(authors)
- self.book_in_library_cache[title]['db_ids'].add(id)
+ # First build a self.book_in_library_cache of the library, so the search isn't On**2
+ self.book_in_library_cache = {}
+ for id, title in self.library_view.model().db.all_titles():
+ title = re.sub('(?u)\W|[_]', '', title.lower())
+ if title not in self.book_in_library_cache:
+ self.book_in_library_cache[title] = {'authors':set(), 'db_ids':set()}
+ au = self.library_view.model().db.authors(id, index_is_id=True)
+ authors = au.lower() if au else ''
+ authors = re.sub('(?u)\W|[_]', '', authors)
+ self.book_in_library_cache[title]['authors'].add(authors)
+ self.book_in_library_cache[title]['db_ids'].add(id)
# Now iterate through all the books on the device, setting the in_library field
- for book in booklist:
- book_title = book.title.lower() if book.title else ''
- book_title = re.sub('(?u)\W|[_]', '', book_title)
- book.in_library = False
- d = self.book_in_library_cache.get(book_title, None)
- if d is not None:
- if book.db_id in d['db_ids']:
- book.in_library = True
- continue
- book_authors = authors_to_string(book.authors).lower() if book.authors else ''
- book_authors = re.sub('(?u)\W|[_]', '', book_authors)
- if book_authors in d['authors']:
- book.in_library = True
+ for booklist in booklists:
+ for book in booklist:
+ book_title = book.title.lower() if book.title else ''
+ book_title = re.sub('(?u)\W|[_]', '', book_title)
+ book.in_library = False
+ d = self.book_in_library_cache.get(book_title, None)
+ if d is not None:
+ if book.db_id in d['db_ids']:
+ book.in_library = True
+ continue
+ book_authors = authors_to_string(book.authors).lower() if book.authors else ''
+ book_authors = re.sub('(?u)\W|[_]', '', book_authors)
+ if book_authors in d['authors']:
+ book.in_library = True
diff --git a/src/calibre/gui2/library.py b/src/calibre/gui2/library.py
index 75b1d672cd..6b02ef6843 100644
--- a/src/calibre/gui2/library.py
+++ b/src/calibre/gui2/library.py
@@ -390,8 +390,8 @@ class BooksModel(QAbstractTableModel):
if row == current_row:
self.emit(SIGNAL('new_bookdisplay_data(PyQt_PyObject)'),
self.get_book_display_info(row))
- self.emit(SIGNAL('dataChanged(QModelIndex,QModelIndex)'),
- self.index(row, 0), self.index(row, self.columnCount(QModelIndex())-1))
+ self.dataChanged.emit(self.index(row, 0), self.index(row,
+ self.columnCount(QModelIndex())-1))
def close(self):
self.db.close()
@@ -724,6 +724,7 @@ class BooksModel(QAbstractTableModel):
img = self.default_image
return img
+
def build_data_convertors(self):
def authors(r, idx=-1):
au = self.db.data[r][idx]
diff --git a/src/calibre/gui2/ui.py b/src/calibre/gui2/ui.py
index e97665909f..bc03f0c025 100644
--- a/src/calibre/gui2/ui.py
+++ b/src/calibre/gui2/ui.py
@@ -992,16 +992,13 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
else:
self.device_job_exception(job)
return
- self.set_books_in_library(None, reset=True)
+ self.set_books_in_library(job.result, reset=True)
mainlist, cardalist, cardblist = job.result
self.memory_view.set_database(mainlist)
- self.set_books_in_library(mainlist)
self.memory_view.set_editable(self.device_manager.device.CAN_SET_METADATA)
self.card_a_view.set_database(cardalist)
- self.set_books_in_library(cardalist)
self.card_a_view.set_editable(self.device_manager.device.CAN_SET_METADATA)
self.card_b_view.set_database(cardblist)
- self.set_books_in_library(cardblist)
self.card_b_view.set_editable(self.device_manager.device.CAN_SET_METADATA)
for view in (self.memory_view, self.card_a_view, self.card_b_view):
view.sortByColumn(3, Qt.DescendingOrder)
From 973ed28a3727f2dfaa662f9af8fa7e14a657624f Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Sun, 9 May 2010 16:53:35 -0600
Subject: [PATCH 074/324] Move on device column to ne second column by default
---
src/calibre/gui2/__init__.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/calibre/gui2/__init__.py b/src/calibre/gui2/__init__.py
index 1eff46aca1..5f0cf2e1ae 100644
--- a/src/calibre/gui2/__init__.py
+++ b/src/calibre/gui2/__init__.py
@@ -24,8 +24,8 @@ gprefs = JSONConfig('gui')
NONE = QVariant() #: Null value to return from the data function of item models
UNDEFINED_QDATE = QDate(UNDEFINED_DATE)
-ALL_COLUMNS = ['title', 'authors', 'size', 'timestamp', 'rating', 'publisher',
- 'tags', 'series', 'pubdate', 'ondevice']
+ALL_COLUMNS = ['title', 'ondevice', 'authors', 'size', 'timestamp', 'rating', 'publisher',
+ 'tags', 'series', 'pubdate']
def _config():
c = Config('gui', 'preferences for the calibre GUI')
From 580b5538378bcdde4b050b11719dea13f45453a1 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Sun, 9 May 2010 17:56:12 -0600
Subject: [PATCH 075/324] version 0.6.92
---
src/calibre/constants.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/calibre/constants.py b/src/calibre/constants.py
index ad34e8bc6f..2617603e25 100644
--- a/src/calibre/constants.py
+++ b/src/calibre/constants.py
@@ -2,7 +2,7 @@ __license__ = 'GPL v3'
__copyright__ = '2008, Kovid Goyal kovid@kovidgoyal.net'
__docformat__ = 'restructuredtext en'
__appname__ = 'calibre'
-__version__ = '0.6.91'
+__version__ = '0.6.92'
__author__ = "Kovid Goyal "
import re
From f788e468eb2cff72bbc56d47b568f35a78e86846 Mon Sep 17 00:00:00 2001
From: Charles Haley <>
Date: Mon, 10 May 2010 12:36:54 +0100
Subject: [PATCH 076/324] Save column widths with column names so that columns
keep their widths as they move around
---
src/calibre/gui2/__init__.py | 17 ++++++++++++-----
src/calibre/gui2/library.py | 3 ++-
src/calibre/gui2/ui.py | 5 +++++
3 files changed, 19 insertions(+), 6 deletions(-)
diff --git a/src/calibre/gui2/__init__.py b/src/calibre/gui2/__init__.py
index 5f0cf2e1ae..774825d90f 100644
--- a/src/calibre/gui2/__init__.py
+++ b/src/calibre/gui2/__init__.py
@@ -303,17 +303,24 @@ class TableView(QTableView):
self.read_settings()
def read_settings(self):
- self.cw = dynamic[self.__class__.__name__+'column widths']
+ self.cw = dynamic[self.__class__.__name__+'column width map']
def write_settings(self):
- dynamic[self.__class__.__name__+'column widths'] = \
- tuple([int(self.columnWidth(i)) for i in range(self.model().columnCount(None))])
+ m = dynamic[self.__class__.__name__+'column width map']
+ if m is None:
+ m = {}
+ for i,c in enumerate(self.model().column_map):
+ m[c] = self.columnWidth(i)
+ dynamic[self.__class__.__name__+'column width map'] = m
+ self.cw = m
def restore_column_widths(self):
if self.cw and len(self.cw):
- for i in range(len(self.cw)):
- self.setColumnWidth(i, self.cw[i])
+ for i,c in enumerate(self.model().column_map):
+ if c in self.cw:
+ self.setColumnWidth(i, self.cw[c])
return True
+ return False
class FileIconProvider(QFileIconProvider):
diff --git a/src/calibre/gui2/library.py b/src/calibre/gui2/library.py
index 865617489f..304b909df9 100644
--- a/src/calibre/gui2/library.py
+++ b/src/calibre/gui2/library.py
@@ -1081,6 +1081,8 @@ class BooksView(TableView):
self.setItemDelegateForColumn(cm.index(colhead), self.cc_bool_delegate)
elif cc['datatype'] == 'rating':
self.setItemDelegateForColumn(cm.index(colhead), self.rating_delegate)
+ if not self.restore_column_widths():
+ self.resizeColumnsToContents()
def set_context_menu(self, edit_metadata, send_to_device, convert, view,
save, open_folder, book_details, delete, similar_menu=None):
@@ -1186,7 +1188,6 @@ class BooksView(TableView):
def row_count(self):
return self._model.count()
-
class DeviceBooksView(BooksView):
def __init__(self, parent):
diff --git a/src/calibre/gui2/ui.py b/src/calibre/gui2/ui.py
index 1bf128bc49..48e22f8903 100644
--- a/src/calibre/gui2/ui.py
+++ b/src/calibre/gui2/ui.py
@@ -1018,6 +1018,8 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
############################################################################
### Force the library view to refresh, taking into consideration books information
def refresh_ondevice_info(self, device_connected):
+ # Save current column widths because we might be turning on OnDevice
+ self.library_view.write_settings()
self.book_on_device(None, reset=True)
self.library_view.model().set_device_connected(device_connected)
############################################################################
@@ -2218,6 +2220,9 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
return
d = ConfigDialog(self, self.library_view.model(),
server=self.content_server)
+ # Save current column widths in case columns are turned on or off
+ self.library_view.write_settings()
+
d.exec_()
self.content_server = d.server
if d.result() == d.Accepted:
From e9402eb98aeea6cd17907ecbc3d182e24b6922fa Mon Sep 17 00:00:00 2001
From: Charles Haley <>
Date: Tue, 11 May 2010 04:46:12 +0100
Subject: [PATCH 077/324] Fix #5515 - job manager exception
---
src/calibre/gui2/__init__.py | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/src/calibre/gui2/__init__.py b/src/calibre/gui2/__init__.py
index 774825d90f..f8d03c95d0 100644
--- a/src/calibre/gui2/__init__.py
+++ b/src/calibre/gui2/__init__.py
@@ -309,8 +309,10 @@ class TableView(QTableView):
m = dynamic[self.__class__.__name__+'column width map']
if m is None:
m = {}
- for i,c in enumerate(self.model().column_map):
- m[c] = self.columnWidth(i)
+ cmap = getattr(self.model(), 'column_map', None)
+ if cmap is not None:
+ for i,c in enumerate(cmap):
+ m[c] = self.columnWidth(i)
dynamic[self.__class__.__name__+'column width map'] = m
self.cw = m
From 93bcff6a8376059ba072adb0f83b039bf8452a95 Mon Sep 17 00:00:00 2001
From: Charles Haley <>
Date: Tue, 11 May 2010 16:25:30 +0100
Subject: [PATCH 078/324] Metadata caching, removed db_id from file names.
---
src/calibre/customize/builtins.py | 2 +
src/calibre/devices/htc_td2/__init__.py | 10 ++
src/calibre/devices/htc_td2/driver.py | 46 +++++++
src/calibre/devices/jetbook/driver.py | 8 +-
src/calibre/devices/prs505/books.py | 2 +-
src/calibre/devices/prs505/driver.py | 8 ++
src/calibre/devices/usbms/books.py | 100 +++++++++++----
src/calibre/devices/usbms/device.py | 8 +-
src/calibre/devices/usbms/driver.py | 162 ++++++++++++++++++------
src/calibre/ebooks/metadata/__init__.py | 10 ++
src/calibre/gui2/device.py | 35 +++--
src/calibre/gui2/library.py | 7 +-
12 files changed, 309 insertions(+), 89 deletions(-)
create mode 100644 src/calibre/devices/htc_td2/__init__.py
create mode 100644 src/calibre/devices/htc_td2/driver.py
diff --git a/src/calibre/customize/builtins.py b/src/calibre/customize/builtins.py
index 93d5283b4e..baffbf2db9 100644
--- a/src/calibre/customize/builtins.py
+++ b/src/calibre/customize/builtins.py
@@ -455,6 +455,7 @@ from calibre.devices.edge.driver import EDGE
from calibre.devices.teclast.driver import TECLAST_K3
from calibre.devices.sne.driver import SNE
from calibre.devices.misc import PALMPRE, KOBO
+from calibre.devices.htc_td2.driver import HTC_TD2
from calibre.ebooks.metadata.fetch import GoogleBooks, ISBNDB, Amazon
from calibre.library.catalog import CSV_XML, EPUB_MOBI
@@ -539,6 +540,7 @@ plugins += [
PALMPRE,
KOBO,
AZBOOKA,
+ HTC_TD2
]
plugins += [x for x in list(locals().values()) if isinstance(x, type) and \
x.__name__.endswith('MetadataReader')]
diff --git a/src/calibre/devices/htc_td2/__init__.py b/src/calibre/devices/htc_td2/__init__.py
new file mode 100644
index 0000000000..3d1a86922e
--- /dev/null
+++ b/src/calibre/devices/htc_td2/__init__.py
@@ -0,0 +1,10 @@
+#!/usr/bin/env python
+# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai
+from __future__ import with_statement
+
+__license__ = 'GPL v3'
+__copyright__ = '2009, Kovid Goyal '
+__docformat__ = 'restructuredtext en'
+
+
+
diff --git a/src/calibre/devices/htc_td2/driver.py b/src/calibre/devices/htc_td2/driver.py
new file mode 100644
index 0000000000..fc3a0d1839
--- /dev/null
+++ b/src/calibre/devices/htc_td2/driver.py
@@ -0,0 +1,46 @@
+# -*- coding: utf-8 -*-
+
+__license__ = 'GPL v3'
+__copyright__ = '2009, Kovid Goyal '
+__docformat__ = 'restructuredtext en'
+
+import os
+from calibre.devices.usbms.driver import USBMS
+
+class HTC_TD2(USBMS):
+
+ name = 'HTC TD2 Phone driver'
+ gui_name = 'HTC TD2'
+ description = _('Communicate with HTC TD2 phones.')
+ author = 'Charles Haley'
+ supported_platforms = ['windows']
+
+ # Ordered list of supported formats
+ FORMATS = ['epub', 'pdf']
+
+ VENDOR_ID = {
+ # HTC
+ 0x0bb4 : { 0x0c30 : [0x000]},
+ }
+ EBOOK_DIR_MAIN = ['EBooks']
+ EXTRA_CUSTOMIZATION_MESSAGE = _('Comma separated list of directories to '
+ 'send e-books to on the device. The first one that exists will '
+ 'be used')
+ EXTRA_CUSTOMIZATION_DEFAULT = ', '.join(EBOOK_DIR_MAIN)
+
+ VENDOR_NAME = ['']
+ WINDOWS_MAIN_MEM = ['']
+
+# OSX_MAIN_MEM = 'HTC TD2 Phone Media'
+# MAIN_MEMORY_VOLUME_LABEL = 'HTC Phone Internal Memory'
+
+ SUPPORTS_SUB_DIRS = True
+
+ def post_open_callback(self):
+ opts = self.settings()
+ dirs = opts.extra_customization
+ if not dirs:
+ dirs = self.EBOOK_DIR_MAIN
+ else:
+ dirs = [x.strip() for x in dirs.split(',')]
+ self.EBOOK_DIR_MAIN = dirs
diff --git a/src/calibre/devices/jetbook/driver.py b/src/calibre/devices/jetbook/driver.py
index 71b825f5d8..e4fd840dc0 100644
--- a/src/calibre/devices/jetbook/driver.py
+++ b/src/calibre/devices/jetbook/driver.py
@@ -55,13 +55,7 @@ class JETBOOK(USBMS):
au = mi.format_authors()
if not au:
au = 'Unknown'
- suffix = ''
- if getattr(mi, 'application_id', None) is not None:
- base = fname.rpartition('.')[0]
- suffix = '_%s'%mi.application_id
- if base.endswith(suffix):
- suffix = ''
- return '%s#%s%s%s' % (au, title, fileext, suffix)
+ return '%s#%s%s' % (au, title, fileext)
@classmethod
def metadata_from_path(cls, path):
diff --git a/src/calibre/devices/prs505/books.py b/src/calibre/devices/prs505/books.py
index cb6f4df7c5..66f24b97a0 100644
--- a/src/calibre/devices/prs505/books.py
+++ b/src/calibre/devices/prs505/books.py
@@ -55,7 +55,7 @@ class Book(object):
title = book_metadata_field("title")
authors = book_metadata_field("author", \
- formatter=lambda x: x if x and x.strip() else _('Unknown'))
+ formatter=lambda x: [x if x and x.strip() else _('Unknown')])
mime = book_metadata_field("mime")
rpath = book_metadata_field("path")
id = book_metadata_field("id", formatter=int)
diff --git a/src/calibre/devices/prs505/driver.py b/src/calibre/devices/prs505/driver.py
index e73a341909..f4fc4b0d29 100644
--- a/src/calibre/devices/prs505/driver.py
+++ b/src/calibre/devices/prs505/driver.py
@@ -121,6 +121,14 @@ class PRS505(CLI, Device):
self.report_progress(1.0, _('Getting list of books on device...'))
return bl
+ def filename_callback(self, fname, mi):
+ if getattr(mi, 'application_id', None) is not None:
+ base = fname.rpartition('.')[0]
+ suffix = '_%s'%mi.application_id
+ if not base.endswith(suffix):
+ fname = base + suffix + '.' + fname.rpartition('.')[-1]
+ return fname
+
def upload_books(self, files, names, on_card=None, end_session=True,
metadata=None):
diff --git a/src/calibre/devices/usbms/books.py b/src/calibre/devices/usbms/books.py
index 50756ef3ee..6c88f7247d 100644
--- a/src/calibre/devices/usbms/books.py
+++ b/src/calibre/devices/usbms/books.py
@@ -8,25 +8,62 @@ import os
import re
import time
+from calibre.ebooks.metadata import MetaInformation
+from calibre.devices.mime import mime_type_ext
from calibre.devices.interface import BookList as _BookList
-class Book(object):
+class Book(MetaInformation):
- def __init__(self, path, title, authors, mime):
- self.title = title
- self.authors = authors
- self.mime = mime
- self.size = os.path.getsize(path)
+ BOOK_ATTRS = ['lpath', 'size', 'mime']
+
+ JSON_ATTRS = [
+ 'lpath', 'title', 'authors', 'mime', 'size', 'tags', 'author_sort',
+ 'title_sort', 'comments', 'category', 'publisher', 'series',
+ 'series_index', 'rating', 'isbn', 'language', 'application_id',
+ 'book_producer', 'lccn', 'lcc', 'ddc', 'rights', 'publication_type',
+ 'uuid'
+ ]
+
+ def __init__(self, prefix, lpath, size=None, other=None):
+ from calibre.ebooks.metadata.meta import path_to_ext
+
+ MetaInformation.__init__(self, '')
+
+ self.path = os.path.join(prefix, lpath)
+ self.lpath = lpath
+ self.mime = mime_type_ext(path_to_ext(lpath))
+ self.size = os.stat(self.path).st_size if size == None else size
+ self.db_id = None
try:
- self.datetime = time.gmtime(os.path.getctime(path))
+ self.datetime = time.gmtime(os.path.getctime(self.path))
except ValueError:
self.datetime = time.gmtime()
- self.path = path
- self.thumbnail = None
- self.tags = []
+
+ if other:
+ self.smart_update(other)
def __eq__(self, other):
- return self.path == other.path
+ spath = self.path
+ opath = other.path
+
+ if not isinstance(self.path, unicode):
+ try:
+ spath = unicode(self.path)
+ except:
+ try:
+ spath = self.path.decode('utf-8')
+ except:
+ spath = self.path
+ if not isinstance(other.path, unicode):
+ try:
+ opath = unicode(other.path)
+ except:
+ try:
+ opath = other.path.decode('utf-8')
+ except:
+ opath = other.path
+
+ return spath == opath
@dynamic_property
def title_sorter(self):
@@ -39,24 +76,37 @@ class Book(object):
def thumbnail(self):
return None
- def __str__(self):
- '''
- Return a utf-8 encoded string with title author and path information
- '''
- return self.title.encode('utf-8') + " by " + \
- self.authors.encode('utf-8') + " at " + self.path.encode('utf-8')
+# def __str__(self):
+# '''
+# Return a utf-8 encoded string with title author and path information
+# '''
+# return self.title.encode('utf-8') + " by " + \
+# self.authors.encode('utf-8') + " at " + self.path.encode('utf-8')
- @property
- def db_id(self):
- '''The database id in the application database that this file corresponds to'''
- match = re.search(r'_(\d+)$', self.path.rpartition('.')[0])
- if match:
- return int(match.group(1))
+ def smart_update(self, other):
+ '''
+ Merge the information in C{other} into self. In case of conflicts, the information
+ in C{other} takes precedence, unless the information in C{other} is NULL.
+ '''
+
+ MetaInformation.smart_update(self, other)
+
+ for attr in self.BOOK_ATTRS:
+ if hasattr(other, attr):
+ val = getattr(other, attr, None)
+ setattr(self, attr, val)
+
+ def to_json(self):
+ json = {}
+ for attr in self.JSON_ATTRS:
+ json[attr] = getattr(self, attr)
+ return json
class BookList(_BookList):
def supports_tags(self):
- return False
+ return True
def set_tags(self, book, tags):
- pass
+ book.tags = tags
+
diff --git a/src/calibre/devices/usbms/device.py b/src/calibre/devices/usbms/device.py
index fbc61afc9f..1b048d1bb6 100644
--- a/src/calibre/devices/usbms/device.py
+++ b/src/calibre/devices/usbms/device.py
@@ -784,14 +784,8 @@ class Device(DeviceConfig, DevicePlugin):
def filename_callback(self, default, mi):
'''
Callback to allow drivers to change the default file name
- set by :method:`create_upload_path`. By default, add the DB_ID
- to the end of the string. Helps with ondevice doc matching
+ set by :method:`create_upload_path`.
'''
- if getattr(mi, 'application_id', None) is not None:
- base = default.rpartition('.')[0]
- suffix = '_%s'%mi.application_id
- if not base.endswith(suffix):
- default = base + suffix + '.' + default.rpartition('.')[-1]
return default
def sanitize_path_components(self, components):
diff --git a/src/calibre/devices/usbms/driver.py b/src/calibre/devices/usbms/driver.py
index b66f01cbcd..8cb70f410b 100644
--- a/src/calibre/devices/usbms/driver.py
+++ b/src/calibre/devices/usbms/driver.py
@@ -11,15 +11,14 @@ for a particular device.
'''
import os
-import fnmatch
import re
+import json
from itertools import cycle
+from calibre.utils.date import now
-from calibre.ebooks.metadata import authors_to_string
from calibre.devices.usbms.cli import CLI
from calibre.devices.usbms.device import Device
from calibre.devices.usbms.books import BookList, Book
-from calibre.devices.mime import mime_type_ext
# CLI must come before Device as it implements the CLI functions that
# are inherited from the device interface in Device.
@@ -30,7 +29,8 @@ class USBMS(CLI, Device):
supported_platforms = ['windows', 'osx', 'linux']
FORMATS = []
- CAN_SET_METADATA = False
+ CAN_SET_METADATA = True
+ METADATA_CACHE = 'metadata.calibre'
def get_device_information(self, end_session=True):
self.report_progress(1.0, _('Get device information...'))
@@ -38,7 +38,10 @@ class USBMS(CLI, Device):
def books(self, oncard=None, end_session=True):
from calibre.ebooks.metadata.meta import path_to_ext
+ start_time = now()
bl = BookList()
+ metadata = BookList()
+ need_sync = False
if oncard == 'carda' and not self._card_a_prefix:
self.report_progress(1.0, _('Getting list of books on device...'))
@@ -55,6 +58,37 @@ class USBMS(CLI, Device):
self.EBOOK_DIR_CARD_B if oncard == 'cardb' else \
self.get_main_ebook_dir()
+ #print 'after booklist get', now() - start_time
+ bl, need_sync = self.parse_metadata_cache(prefix, self.METADATA_CACHE)
+ #print 'after parse_metadata_cache', now() - start_time
+
+ # make a dict cache of paths so the lookup in the loop below is faster.
+ bl_cache = {}
+ for idx,b in enumerate(bl):
+ bl_cache[b.path] = idx
+ self.count_found_in_bl = 0
+ #print 'after make cache', now() - start_time
+
+ def update_booklist(filename, path, prefix):
+ changed = False
+ if path_to_ext(filename) in self.FORMATS:
+ try:
+ lpath = os.path.join(path, filename).partition(prefix)[2]
+ if lpath.startswith(os.sep):
+ lpath = lpath[len(os.sep):]
+ p = os.path.join(prefix, lpath)
+ if p in bl_cache:
+ item, changed = self.__class__.update_metadata_item(bl[bl_cache[p]])
+ self.count_found_in_bl += 1
+ else:
+ item = self.__class__.book_from_path(prefix, lpath)
+ changed = True
+ metadata.append(item)
+ except: # Probably a filename encoding error
+ import traceback
+ traceback.print_exc()
+ return changed
+
if isinstance(ebook_dirs, basestring):
ebook_dirs = [ebook_dirs]
for ebook_dir in ebook_dirs:
@@ -63,32 +97,33 @@ class USBMS(CLI, Device):
# Get all books in the ebook_dir directory
if self.SUPPORTS_SUB_DIRS:
for path, dirs, files in os.walk(ebook_dir):
- # Filter out anything that isn't in the list of supported ebook types
- for book_type in self.FORMATS:
- match = fnmatch.filter(files, '*.%s' % (book_type))
- for i, filename in enumerate(match):
- self.report_progress((i+1) / float(len(match)), _('Getting list of books on device...'))
- try:
- bl.append(self.__class__.book_from_path(os.path.join(path, filename)))
- except: # Probably a filename encoding error
- import traceback
- traceback.print_exc()
- continue
+ for filename in files:
+ self.report_progress(50.0, _('Getting list of books on device...'))
+ changed = update_booklist(filename, path, prefix)
+ if changed:
+ need_sync = True
else:
paths = os.listdir(ebook_dir)
for i, filename in enumerate(paths):
self.report_progress((i+1) / float(len(paths)), _('Getting list of books on device...'))
- if path_to_ext(filename) in self.FORMATS:
- try:
- bl.append(self.__class__.book_from_path(os.path.join(ebook_dir, filename)))
- except: # Probably a file name encoding error
- import traceback
- traceback.print_exc()
- continue
+ changed = update_booklist(filename, ebook_dir, prefix)
+ if changed:
+ need_sync = True
+
+ # if count != len(bl) then there were items in it that we did not
+ # find on the device. If need_sync is True then there were either items
+ # on the device that were not in bl or some of the items were changed.
+ if self.count_found_in_bl != len(bl) or need_sync:
+ if oncard == 'cardb':
+ self.sync_booklists((None, None, metadata))
+ elif oncard == 'carda':
+ self.sync_booklists((None, metadata, None))
+ else:
+ self.sync_booklists((metadata, None, None))
self.report_progress(1.0, _('Getting list of books on device...'))
-
- return bl
+ #print 'at return', now() - start_time
+ return metadata
def upload_books(self, files, names, on_card=None, end_session=True,
metadata=None):
@@ -128,15 +163,28 @@ class USBMS(CLI, Device):
pass
def add_books_to_metadata(self, locations, metadata, booklists):
+ metadata = iter(metadata)
for i, location in enumerate(locations):
self.report_progress((i+1) / float(len(locations)), _('Adding books to device metadata listing...'))
+ info = metadata.next()
path = location[0]
blist = 2 if location[1] == 'cardb' else 1 if location[1] == 'carda' else 0
- book = self.book_from_path(path)
+ if self._main_prefix:
+ prefix = self._main_prefix if path.startswith(self._main_prefix) else None
+ if not prefix and self._card_a_prefix:
+ prefix = self._card_a_prefix if path.startswith(self._card_a_prefix) else None
+ if not prefix and self._card_b_prefix:
+ prefix = self._card_b_prefix if path.startswith(self._card_b_prefix) else None
+ lpath = path.partition(prefix)[2]
+ if lpath.startswith(os.sep):
+ lpath = lpath[len(os.sep):]
- if not book in booklists[blist]:
+ book = Book(prefix, lpath, other=info)
+
+ if book not in booklists[blist]:
booklists[blist].append(book)
+
self.report_progress(1.0, _('Adding books to device metadata listing...'))
def delete_books(self, paths, end_session=True):
@@ -170,13 +218,59 @@ class USBMS(CLI, Device):
self.report_progress(1.0, _('Removing books from device metadata listing...'))
def sync_booklists(self, booklists, end_session=True):
- # There is no meta data on the device to update. The device is treated
- # as a mass storage device and does not use a meta data xml file like
- # the Sony Readers.
+ print 'in sync_booklists'
+ if not os.path.exists(self._main_prefix):
+ os.makedirs(self._main_prefix)
+
+ def write_prefix(prefix, listid):
+ if prefix is not None and isinstance(booklists[listid], BookList):
+ if not os.path.exists(prefix):
+ os.makedirs(prefix)
+ js = [item.to_json() for item in booklists[listid]]
+ with open(os.path.join(prefix, self.METADATA_CACHE), 'wb') as f:
+ json.dump(js, f, indent=2, encoding='utf-8')
+ write_prefix(self._main_prefix, 0)
+ write_prefix(self._card_a_prefix, 1)
+ write_prefix(self._card_b_prefix, 2)
+
self.report_progress(1.0, _('Sending metadata to device...'))
+ @classmethod
+ def parse_metadata_cache(cls, prefix, name):
+ js = []
+ bl = BookList()
+ need_sync = False
+ try:
+ with open(os.path.join(prefix, name), 'rb') as f:
+ js = json.load(f, encoding='utf-8')
+ for item in js:
+ lpath = item.get('lpath', None)
+ if not lpath or not os.path.exists(os.path.join(prefix, lpath)):
+ need_sync = True
+ continue
+ book = Book(prefix, lpath)
+ for key in item.keys():
+ setattr(book, key, item[key])
+ bl.append(book)
+ except:
+ import traceback
+ traceback.print_exc()
+ bl = BookList()
+ return bl, need_sync
+
+ @classmethod
+ def update_metadata_item(cls, item):
+ changed = False
+ size = os.stat(item.path).st_size
+ if size != item.size:
+ changed = True
+ mi = cls.metadata_from_path(item.path)
+ item.smart_update(mi)
+ return item, changed
+
@classmethod
def metadata_from_path(cls, path):
+ print 'here'
return cls.metadata_from_formats([path])
@classmethod
@@ -187,13 +281,11 @@ class USBMS(CLI, Device):
return metadata_from_formats(fmts)
@classmethod
- def book_from_path(cls, path):
- from calibre.ebooks.metadata.meta import path_to_ext
+ def book_from_path(cls, prefix, path):
from calibre.ebooks.metadata import MetaInformation
- mime = mime_type_ext(path_to_ext(path))
if cls.settings().read_metadata or cls.MUST_READ_METADATA:
- mi = cls.metadata_from_path(path)
+ mi = cls.metadata_from_path(os.path.join(prefix, path))
else:
from calibre.ebooks.metadata.meta import metadata_from_filename
mi = metadata_from_filename(os.path.basename(path),
@@ -203,7 +295,5 @@ class USBMS(CLI, Device):
mi = MetaInformation(os.path.splitext(os.path.basename(path))[0],
[_('Unknown')])
- authors = authors_to_string(mi.authors)
-
- book = Book(path, mi.title, authors, mime)
+ book = Book(prefix, path, other=mi)
return book
diff --git a/src/calibre/ebooks/metadata/__init__.py b/src/calibre/ebooks/metadata/__init__.py
index 5e8edc0c81..c6f08b6f0f 100644
--- a/src/calibre/ebooks/metadata/__init__.py
+++ b/src/calibre/ebooks/metadata/__init__.py
@@ -253,6 +253,16 @@ class MetaInformation(object):
):
setattr(self, x, getattr(mi, x, None))
+ def print_all_attributes(self):
+ print 'here'
+ for x in ('author_sort', 'title_sort', 'comments', 'category', 'publisher',
+ 'series', 'series_index', 'rating', 'isbn', 'language',
+ 'application_id', 'manifest', 'toc', 'spine', 'guide', 'cover',
+ 'book_producer', 'timestamp', 'lccn', 'lcc', 'ddc', 'pubdate',
+ 'rights', 'publication_type', 'uuid',
+ ):
+ print x, getattr(self, x, 'None')
+
def smart_update(self, mi):
'''
Merge the information in C{mi} into self. In case of conflicts, the information
diff --git a/src/calibre/gui2/device.py b/src/calibre/gui2/device.py
index b051b2e937..f890515aa5 100644
--- a/src/calibre/gui2/device.py
+++ b/src/calibre/gui2/device.py
@@ -1011,22 +1011,34 @@ class DeviceGUI(object):
book_title = re.sub('(?u)\W|[_]', '', book_title)
if book_title not in self.book_on_device_cache[i]:
self.book_on_device_cache[i][book_title] = \
- {'authors':set(), 'db_ids':set()}
+ {'authors':set(), 'db_ids':set(), 'uuids':set()}
book_authors = authors_to_string(book.authors).lower()
book_authors = re.sub('(?u)\W|[_]', '', book_authors)
self.book_on_device_cache[i][book_title]['authors'].add(book_authors)
- self.book_on_device_cache[i][book_title]['db_ids'].add(book.db_id)
+ id = getattr(book, 'application_id', None)
+ if id is None:
+ id = book.db_id
+ if id is not None:
+ self.book_on_device_cache[i][book_title]['db_ids'].add(id)
+ uuid = getattr(book, 'uuid', None)
+ if uuid is None:
+ self.book_on_device_cache[i][book_title]['uuids'].add(uuid)
- db_title = self.library_view.model().db.title(index, index_is_id=True).lower()
+ db = self.library_view.model().db
+ db_title = db.title(index, index_is_id=True).lower()
db_title = re.sub('(?u)\W|[_]', '', db_title)
- au = self.library_view.model().db.authors(index, index_is_id=True)
- db_authors = au.lower() if au else ''
+ db_authors = db.authors(index, index_is_id=True)
+ db_authors = db_authors.lower() if db_authors else ''
db_authors = re.sub('(?u)\W|[_]', '', db_authors)
+ db_uuid = db.uuid(index, index_is_id=True)
for i, l in enumerate(self.booklists()):
d = self.book_on_device_cache[i].get(db_title, None)
- if d and (index in d['db_ids'] or db_authors in d['authors']):
- loc[i] = True
- break
+ if d:
+ if db_uuid in d['uuids'] or \
+ index in d['db_ids'] or \
+ db_authors in d['authors']:
+ loc[i] = True
+ break
return loc
def set_books_in_library(self, booklists, reset=False):
@@ -1036,12 +1048,13 @@ class DeviceGUI(object):
for id, title in self.library_view.model().db.all_titles():
title = re.sub('(?u)\W|[_]', '', title.lower())
if title not in self.book_in_library_cache:
- self.book_in_library_cache[title] = {'authors':set(), 'db_ids':set()}
+ self.book_in_library_cache[title] = {'authors':set(), 'db_ids':set(), 'uuids':set()}
au = self.library_view.model().db.authors(id, index_is_id=True)
authors = au.lower() if au else ''
authors = re.sub('(?u)\W|[_]', '', authors)
self.book_in_library_cache[title]['authors'].add(authors)
self.book_in_library_cache[title]['db_ids'].add(id)
+ self.book_in_library_cache[title]['uuids'].add(self.library_view.model().db.uuid(id, index_is_id=True))
# Now iterate through all the books on the device, setting the in_library field
for booklist in booklists:
@@ -1051,6 +1064,10 @@ class DeviceGUI(object):
book.in_library = False
d = self.book_in_library_cache.get(book_title, None)
if d is not None:
+ if getattr(book, 'uuid', None) in d['uuids'] or \
+ getattr(book, 'application_id', None) in d['db_ids']:
+ book.in_library = True
+ continue
if book.db_id in d['db_ids']:
book.in_library = True
continue
diff --git a/src/calibre/gui2/library.py b/src/calibre/gui2/library.py
index 304b909df9..9fa77a02ba 100644
--- a/src/calibre/gui2/library.py
+++ b/src/calibre/gui2/library.py
@@ -1248,7 +1248,7 @@ class OnDeviceSearch(SearchQueryParser):
locations = ['title', 'author', 'tag', 'format'] if location == 'all' else [location]
q = {
'title' : lambda x : getattr(x, 'title').lower(),
- 'author': lambda x: getattr(x, 'authors').lower(),
+ 'author': lambda x: ' & '.join(getattr(x, 'authors')).lower(),
'tag':lambda x: ','.join(getattr(x, 'tags')).lower(),
'format':lambda x: os.path.splitext(x.path)[1].lower()
}
@@ -1447,9 +1447,8 @@ class DeviceBooksModel(BooksModel):
if not au:
au = self.unknown
if role == Qt.EditRole:
- return QVariant(au)
- authors = string_to_authors(au)
- return QVariant(" & ".join(authors))
+ return QVariant(authors_to_string(au))
+ return QVariant(" & ".join(au))
elif col == 2:
size = self.db[self.map[row]].size
return QVariant(BooksView.human_readable(size))
From 9b7815acf1bca13063cd70c4ceb9bedfd0410b4f Mon Sep 17 00:00:00 2001
From: Charles Haley <>
Date: Wed, 12 May 2010 15:40:01 +0100
Subject: [PATCH 079/324] Part way through normalization of path names in
caching, and also performance improvements to ondevice matching
---
src/calibre/devices/usbms/driver.py | 12 ++++++----
src/calibre/gui2/device.py | 36 ++++++++++++++++++-----------
2 files changed, 29 insertions(+), 19 deletions(-)
diff --git a/src/calibre/devices/usbms/driver.py b/src/calibre/devices/usbms/driver.py
index 0d3779a309..3d65dfba35 100644
--- a/src/calibre/devices/usbms/driver.py
+++ b/src/calibre/devices/usbms/driver.py
@@ -10,6 +10,7 @@ driver. It is intended to be subclassed with the relevant parts implemented
for a particular device.
'''
+import posixpath
import os
import re
import json
@@ -61,7 +62,7 @@ class USBMS(CLI, Device):
# make a dict cache of paths so the lookup in the loop below is faster.
bl_cache = {}
for idx,b in enumerate(bl):
- bl_cache[b.path] = idx
+ bl_cache[b.lpath] = idx
self.count_found_in_bl = 0
def update_booklist(filename, path, prefix):
@@ -71,9 +72,9 @@ class USBMS(CLI, Device):
lpath = os.path.join(path, filename).partition(prefix)[2]
if lpath.startswith(os.sep):
lpath = lpath[len(os.sep):]
- p = os.path.join(prefix, lpath)
- if p in bl_cache:
- item, changed = self.__class__.update_metadata_item(bl[bl_cache[p]])
+ idx = bl_cache.get(lpath.replace('\\', '/'), None)
+ if idx is not None:
+ item, changed = self.__class__.update_metadata_item(bl[idx])
self.count_found_in_bl += 1
else:
item = self.__class__.book_from_path(prefix, lpath)
@@ -109,6 +110,7 @@ class USBMS(CLI, Device):
# find on the device. If need_sync is True then there were either items
# on the device that were not in bl or some of the items were changed.
if self.count_found_in_bl != len(bl) or need_sync:
+ print 'resync'
if oncard == 'cardb':
self.sync_booklists((None, None, metadata))
elif oncard == 'carda':
@@ -173,7 +175,7 @@ class USBMS(CLI, Device):
lpath = path.partition(prefix)[2]
if lpath.startswith(os.sep):
lpath = lpath[len(os.sep):]
-
+ lpath = lpath.replace('\\', '/')
book = Book(prefix, lpath, other=info)
if book not in booklists[blist]:
diff --git a/src/calibre/gui2/device.py b/src/calibre/gui2/device.py
index f890515aa5..9511e1c752 100644
--- a/src/calibre/gui2/device.py
+++ b/src/calibre/gui2/device.py
@@ -1043,29 +1043,37 @@ class DeviceGUI(object):
def set_books_in_library(self, booklists, reset=False):
if reset:
- # First build a self.book_in_library_cache of the library, so the search isn't On**2
- self.book_in_library_cache = {}
- for id, title in self.library_view.model().db.all_titles():
- title = re.sub('(?u)\W|[_]', '', title.lower())
- if title not in self.book_in_library_cache:
- self.book_in_library_cache[title] = {'authors':set(), 'db_ids':set(), 'uuids':set()}
- au = self.library_view.model().db.authors(id, index_is_id=True)
- authors = au.lower() if au else ''
+ # First build a cache of the library, so the search isn't On**2
+ self.db_book_title_cache = {}
+ self.db_book_uuid_cache = set()
+ for idx in range(self.library_view.model().db.count()):
+ mi = self.library_view.model().db.get_metadata(idx, index_is_id=False)
+ title = re.sub('(?u)\W|[_]', '', mi.title.lower())
+ if title not in self.db_book_title_cache:
+ self.db_book_title_cache[title] = {'authors':set(), 'db_ids':set()}
+ authors = authors_to_string(mi.authors).lower() if mi.authors else ''
authors = re.sub('(?u)\W|[_]', '', authors)
- self.book_in_library_cache[title]['authors'].add(authors)
- self.book_in_library_cache[title]['db_ids'].add(id)
- self.book_in_library_cache[title]['uuids'].add(self.library_view.model().db.uuid(id, index_is_id=True))
+ self.db_book_title_cache[title]['authors'].add(authors)
+ self.db_book_title_cache[title]['db_ids'].add(id)
+ self.db_book_uuid_cache.add(mi.uuid)
# Now iterate through all the books on the device, setting the in_library field
+ # Fastest and most accurate key is the uuid. Second is the application_id, which
+ # is really the db key, but as this can accidentally match across libraries we
+ # also verify the title. The db_id exists on Sony devices. Fallback is title
+ # and author match
for booklist in booklists:
for book in booklist:
+ if getattr(book, 'uuid', None) in self.db_book_uuid_cache:
+ self.book_in_library = True
+ continue
+
book_title = book.title.lower() if book.title else ''
book_title = re.sub('(?u)\W|[_]', '', book_title)
book.in_library = False
- d = self.book_in_library_cache.get(book_title, None)
+ d = self.db_book_title_cache.get(book_title, None)
if d is not None:
- if getattr(book, 'uuid', None) in d['uuids'] or \
- getattr(book, 'application_id', None) in d['db_ids']:
+ if getattr(book, 'application_id', None) in d['db_ids']:
book.in_library = True
continue
if book.db_id in d['db_ids']:
From cd6c46dba5bea1bc8a6de2aa5f29dcaee9020ef0 Mon Sep 17 00:00:00 2001
From: Charles Haley <>
Date: Thu, 13 May 2010 09:56:41 +0100
Subject: [PATCH 080/324] Normalized paths and performance improvements done
---
src/calibre/devices/usbms/books.py | 2 ++
src/calibre/devices/usbms/driver.py | 1 -
src/calibre/gui2/device.py | 51 +++++++++++++++--------------
3 files changed, 29 insertions(+), 25 deletions(-)
diff --git a/src/calibre/devices/usbms/books.py b/src/calibre/devices/usbms/books.py
index eca9a27096..990b335a6d 100644
--- a/src/calibre/devices/usbms/books.py
+++ b/src/calibre/devices/usbms/books.py
@@ -31,6 +31,8 @@ class Book(MetaInformation):
MetaInformation.__init__(self, '')
self.path = os.path.join(prefix, lpath)
+ if os.sep == '\\':
+ self.path = self.path.replace('/', '\\')
self.lpath = lpath
self.mime = mime_type_ext(path_to_ext(lpath))
self.size = os.stat(self.path).st_size if size == None else size
diff --git a/src/calibre/devices/usbms/driver.py b/src/calibre/devices/usbms/driver.py
index 3d65dfba35..63d28f5457 100644
--- a/src/calibre/devices/usbms/driver.py
+++ b/src/calibre/devices/usbms/driver.py
@@ -10,7 +10,6 @@ driver. It is intended to be subclassed with the relevant parts implemented
for a particular device.
'''
-import posixpath
import os
import re
import json
diff --git a/src/calibre/gui2/device.py b/src/calibre/gui2/device.py
index 9511e1c752..df355f0ef5 100644
--- a/src/calibre/gui2/device.py
+++ b/src/calibre/gui2/device.py
@@ -999,44 +999,47 @@ class DeviceGUI(object):
loc = [None, None, None]
if reset:
- self.book_on_device_cache = None
+ self.book_db_title_cache = None
+ self.book_db_uuid_cache = None
return
- if self.book_on_device_cache is None:
- self.book_on_device_cache = []
+ if self.book_db_title_cache is None:
+ self.book_db_title_cache = []
+ self.book_db_uuid_cache = []
for i, l in enumerate(self.booklists()):
- self.book_on_device_cache.append({})
+ self.book_db_title_cache.append({})
+ self.book_db_uuid_cache.append(set())
for book in l:
book_title = book.title.lower() if book.title else ''
book_title = re.sub('(?u)\W|[_]', '', book_title)
- if book_title not in self.book_on_device_cache[i]:
- self.book_on_device_cache[i][book_title] = \
+ if book_title not in self.book_db_title_cache[i]:
+ self.book_db_title_cache[i][book_title] = \
{'authors':set(), 'db_ids':set(), 'uuids':set()}
book_authors = authors_to_string(book.authors).lower()
book_authors = re.sub('(?u)\W|[_]', '', book_authors)
- self.book_on_device_cache[i][book_title]['authors'].add(book_authors)
+ self.book_db_title_cache[i][book_title]['authors'].add(book_authors)
id = getattr(book, 'application_id', None)
if id is None:
id = book.db_id
if id is not None:
- self.book_on_device_cache[i][book_title]['db_ids'].add(id)
+ self.book_db_title_cache[i][book_title]['db_ids'].add(id)
uuid = getattr(book, 'uuid', None)
- if uuid is None:
- self.book_on_device_cache[i][book_title]['uuids'].add(uuid)
+ if uuid is not None:
+ self.book_db_uuid_cache[i].add(uuid)
- db = self.library_view.model().db
- db_title = db.title(index, index_is_id=True).lower()
- db_title = re.sub('(?u)\W|[_]', '', db_title)
- db_authors = db.authors(index, index_is_id=True)
- db_authors = db_authors.lower() if db_authors else ''
- db_authors = re.sub('(?u)\W|[_]', '', db_authors)
- db_uuid = db.uuid(index, index_is_id=True)
+ mi = self.library_view.model().db.get_metadata(index, index_is_id=True)
for i, l in enumerate(self.booklists()):
- d = self.book_on_device_cache[i].get(db_title, None)
- if d:
- if db_uuid in d['uuids'] or \
- index in d['db_ids'] or \
- db_authors in d['authors']:
+ if mi.uuid in self.book_db_uuid_cache[i]:
+ loc[i] = True
+ continue
+ db_title = re.sub('(?u)\W|[_]', '', mi.title.lower())
+ cache = self.book_db_title_cache[i].get(db_title, None)
+ if cache:
+ if index in cache['db_ids']:
+ loc[i] = True
+ break
+ if mi.authors and \
+ re.sub('(?u)\W|[_]', '', mi.authors.lower()) in cache['authors']:
loc[i] = True
break
return loc
@@ -1054,7 +1057,7 @@ class DeviceGUI(object):
authors = authors_to_string(mi.authors).lower() if mi.authors else ''
authors = re.sub('(?u)\W|[_]', '', authors)
self.db_book_title_cache[title]['authors'].add(authors)
- self.db_book_title_cache[title]['db_ids'].add(id)
+ self.db_book_title_cache[title]['db_ids'].add(mi.application_id)
self.db_book_uuid_cache.add(mi.uuid)
# Now iterate through all the books on the device, setting the in_library field
@@ -1065,7 +1068,7 @@ class DeviceGUI(object):
for booklist in booklists:
for book in booklist:
if getattr(book, 'uuid', None) in self.db_book_uuid_cache:
- self.book_in_library = True
+ book.in_library = True
continue
book_title = book.title.lower() if book.title else ''
From d1c040d5464798e79c865fa8f355eac6601c8d0d Mon Sep 17 00:00:00 2001
From: Charles Haley <>
Date: Thu, 13 May 2010 21:57:54 +0100
Subject: [PATCH 081/324] Working JSON metadata along side Sony metadata
---
src/calibre/devices/interface.py | 17 ++
src/calibre/devices/prs505/__init__.py | 4 +
src/calibre/devices/prs505/books.py | 205 ++++++++----------------
src/calibre/devices/prs505/driver.py | 157 ++----------------
src/calibre/devices/usbms/books.py | 4 +-
src/calibre/devices/usbms/driver.py | 37 +++--
src/calibre/ebooks/metadata/__init__.py | 8 +-
src/calibre/gui2/device.py | 14 +-
src/calibre/gui2/library.py | 15 +-
9 files changed, 142 insertions(+), 319 deletions(-)
diff --git a/src/calibre/devices/interface.py b/src/calibre/devices/interface.py
index 98421959cc..6247e29e15 100644
--- a/src/calibre/devices/interface.py
+++ b/src/calibre/devices/interface.py
@@ -387,6 +387,9 @@ class BookList(list):
__getslice__ = None
__setslice__ = None
+ def __init__(self, oncard, prefix):
+ pass
+
def supports_tags(self):
''' Return True if the the device supports tags (collections) for this book list. '''
raise NotImplementedError()
@@ -399,3 +402,17 @@ class BookList(list):
'''
raise NotImplementedError()
+ def add_book(self, book, collections=None):
+ '''
+ Add the book to the booklist. Intent is to maintain any device-internal
+ metadata
+ '''
+ if book not in self:
+ self.append(book)
+
+ def remove_book(self, book):
+ '''
+ Remove a book from the booklist. Correct any device metadata at the
+ same time
+ '''
+ self.remove(book)
diff --git a/src/calibre/devices/prs505/__init__.py b/src/calibre/devices/prs505/__init__.py
index f832dbb7fc..20f3b8d49b 100644
--- a/src/calibre/devices/prs505/__init__.py
+++ b/src/calibre/devices/prs505/__init__.py
@@ -1,2 +1,6 @@
__license__ = 'GPL v3'
__copyright__ = '2008, Kovid Goyal '
+
+MEDIA_XML = 'database/cache/media.xml'
+
+CACHE_XML = 'Sony Reader/database/cache.xml'
diff --git a/src/calibre/devices/prs505/books.py b/src/calibre/devices/prs505/books.py
index 66f24b97a0..82bc977bcd 100644
--- a/src/calibre/devices/prs505/books.py
+++ b/src/calibre/devices/prs505/books.py
@@ -5,13 +5,14 @@ __copyright__ = '2008, Kovid Goyal '
import re, time, functools
from uuid import uuid4 as _uuid
import xml.dom.minidom as dom
-from base64 import b64decode as decode
from base64 import b64encode as encode
from calibre.devices.interface import BookList as _BookList
from calibre.devices import strftime as _strftime
-from calibre.devices import strptime
+from calibre.devices.usbms.books import Book as _Book
+from calibre.devices.prs505 import MEDIA_XML
+from calibre.devices.prs505 import CACHE_XML
strftime = functools.partial(_strftime, zone=time.gmtime)
@@ -50,62 +51,7 @@ class book_metadata_field(object):
obj.elem.setAttribute(self.attr, val)
-class Book(object):
- """ Provides a view onto the XML element that represents a book """
-
- title = book_metadata_field("title")
- authors = book_metadata_field("author", \
- formatter=lambda x: [x if x and x.strip() else _('Unknown')])
- mime = book_metadata_field("mime")
- rpath = book_metadata_field("path")
- id = book_metadata_field("id", formatter=int)
- sourceid = book_metadata_field("sourceid", formatter=int)
- size = book_metadata_field("size", formatter=lambda x : int(float(x)))
- # When setting this attribute you must use an epoch
- datetime = book_metadata_field("date", formatter=strptime, setter=strftime)
-
- @dynamic_property
- def title_sorter(self):
- doc = '''String to sort the title. If absent, title is returned'''
- def fget(self):
- src = self.elem.getAttribute('titleSorter').strip()
- if not src:
- src = self.title
- return src
- def fset(self, val):
- self.elem.setAttribute('titleSorter', sortable_title(unicode(val)))
- return property(doc=doc, fget=fget, fset=fset)
-
- @dynamic_property
- def thumbnail(self):
- doc = \
- """
- The thumbnail. Should be a height 68 image.
- Setting is not supported.
- """
- def fget(self):
- th = self.elem.getElementsByTagName(self.prefix + "thumbnail")
- if not len(th):
- th = self.elem.getElementsByTagName("cache:thumbnail")
- if len(th):
- for n in th[0].childNodes:
- if n.nodeType == n.ELEMENT_NODE:
- th = n
- break
- rc = ""
- for node in th.childNodes:
- if node.nodeType == node.TEXT_NODE:
- rc += node.data
- return decode(rc)
- return property(fget=fget, doc=doc)
-
- @dynamic_property
- def path(self):
- doc = """ Absolute path to book on device. Setting not supported. """
- def fget(self):
- return self.mountpath + self.rpath
- return property(fget=fget, doc=doc)
-
+class Book(_Book):
@dynamic_property
def db_id(self):
doc = '''The database id in the application database that this file corresponds to'''
@@ -115,42 +61,26 @@ class Book(object):
return int(match.group(1))
return property(fget=fget, doc=doc)
- def __init__(self, node, mountpath, tags, prefix=""):
- self.elem = node
- self.prefix = prefix
- self.tags = tags
- self.mountpath = mountpath
-
- def __str__(self):
- """ Return a utf-8 encoded string with title author and path information """
- return self.title.encode('utf-8') + " by " + \
- self.authors.encode('utf-8') + " at " + self.path.encode('utf-8')
-
-
class BookList(_BookList):
- def __init__(self, xml_file, mountpath, report_progress=None):
- _BookList.__init__(self)
+ def __init__(self, oncard, prefix):
+ _BookList.__init__(self, oncard, prefix)
+ if prefix is None:
+ return
+ db = CACHE_XML if oncard else MEDIA_XML
+ xml_file = open(prefix + db, 'rb')
xml_file.seek(0)
self.document = dom.parse(xml_file)
self.root_element = self.document.documentElement
- self.mountpath = mountpath
+ self.mountpath = prefix
records = self.root_element.getElementsByTagName('records')
- self.tag_order = {}
if records:
self.prefix = 'xs1:'
self.root_element = records[0]
else:
self.prefix = ''
-
- nodes = self.root_element.childNodes
- for i, book in enumerate(nodes):
- if report_progress:
- report_progress((i+1) / float(len(nodes)), _('Getting list of books on device...'))
- if hasattr(book, 'tagName') and book.tagName.endswith('text'):
- tags = [i.getAttribute('title') for i in self.get_playlists(book.getAttribute('id'))]
- self.append(Book(book, mountpath, tags, prefix=self.prefix))
+ self.tag_order = {}
def max_id(self):
max = 0
@@ -180,32 +110,32 @@ class BookList(_BookList):
return child
return None
- def add_book(self, mi, name, collections, size, ctime):
+ def add_book(self, book, collections):
+ if book in self:
+ return
""" Add a node into the DOM tree, representing a book """
- book = self.book_by_path(name)
- if book is not None:
- self.remove_book(name)
-
node = self.document.createElement(self.prefix + "text")
- mime = MIME_MAP.get(name.rpartition('.')[-1].lower(), MIME_MAP['epub'])
+ mime = MIME_MAP.get(book.lpath.rpartition('.')[-1].lower(), MIME_MAP['epub'])
cid = self.max_id()+1
+ book.sony_id = cid
+ self.append(book)
try:
sourceid = str(self[0].sourceid) if len(self) else '1'
except:
sourceid = '1'
attrs = {
- "title" : mi.title,
- 'titleSorter' : sortable_title(mi.title),
- "author" : mi.format_authors() if mi.format_authors() else _('Unknown'),
+ "title" : book.title,
+ 'titleSorter' : sortable_title(book.title),
+ "author" : book.format_authors() if book.format_authors() else _('Unknown'),
"page":"0", "part":"0", "scale":"0", \
"sourceid":sourceid, "id":str(cid), "date":"", \
- "mime":mime, "path":name, "size":str(size)
+ "mime":mime, "path":book.lpath, "size":str(book.size)
}
for attr in attrs.keys():
node.setAttributeNode(self.document.createAttribute(attr))
node.setAttribute(attr, attrs[attr])
try:
- w, h, data = mi.thumbnail
+ w, h, data = book.thumbnail
except:
w, h, data = None, None, None
@@ -218,14 +148,11 @@ class BookList(_BookList):
th.appendChild(jpeg)
node.appendChild(th)
self.root_element.appendChild(node)
- book = Book(node, self.mountpath, [], prefix=self.prefix)
- book.datetime = ctime
- self.append(book)
tags = []
for item in collections:
item = item.strip()
- mitem = getattr(mi, item, None)
+ mitem = getattr(book, item, None)
titems = []
if mitem:
if isinstance(mitem, list):
@@ -241,37 +168,34 @@ class BookList(_BookList):
tags.extend(titems)
if tags:
tags = list(set(tags))
- if hasattr(mi, 'tag_order'):
- self.tag_order.update(mi.tag_order)
- self.set_tags(book, tags)
+ if hasattr(book, 'tag_order'):
+ self.tag_order.update(book.tag_order)
+ self.set_playlists(cid, tags)
- def _delete_book(self, node):
+ def _delete_node(self, node):
nid = node.getAttribute('id')
self.remove_from_playlists(nid)
node.parentNode.removeChild(node)
node.unlink()
- def delete_book(self, cid):
+ def delete_node(self, lpath):
'''
- Remove DOM node corresponding to book with C{id == cid}.
+ Remove DOM node corresponding to book with lpath.
Also remove book from any collections it is part of.
'''
- for book in self:
- if str(book.id) == str(cid):
- self.remove(book)
- self._delete_book(book.elem)
- break
+ for child in self.root_element.childNodes:
+ if child.nodeType == child.ELEMENT_NODE and child.hasAttribute("id"):
+ if child.getAttribute('path') == lpath:
+ self._delete_node(child)
+ break
- def remove_book(self, path):
+ def remove_book(self, book):
'''
Remove DOM node corresponding to book with C{path == path}.
Also remove book from any collections it is part of.
'''
- for book in self:
- if path.endswith(book.rpath):
- self.remove(book)
- self._delete_book(book.elem)
- break
+ self.remove(book)
+ self.delete_node(book.lpath)
def playlists(self):
ans = []
@@ -343,11 +267,6 @@ class BookList(_BookList):
pli.parentNode.removeChild(pli)
pli.unlink()
- def set_tags(self, book, tags):
- tags = [t for t in tags if t]
- book.tags = tags
- self.set_playlists(book.id, tags)
-
def set_playlists(self, id, collections):
self.remove_from_playlists(id)
for collection in set(collections):
@@ -358,15 +277,6 @@ class BookList(_BookList):
item.setAttribute('id', str(id))
coll.appendChild(item)
- def get_playlists(self, bookid):
- ans = []
- for pl in self.playlists():
- for item in pl.childNodes:
- if hasattr(item, 'tagName') and item.tagName.endswith('item'):
- if item.getAttribute('id') == str(bookid):
- ans.append(pl)
- return ans
-
def next_id(self):
return self.document.documentElement.getAttribute('nextID')
@@ -378,27 +288,41 @@ class BookList(_BookList):
src = self.document.toxml('utf-8') + '\n'
stream.write(src.replace("'", '''))
- def book_by_id(self, id):
- for book in self:
- if str(book.id) == str(id):
- return book
-
def reorder_playlists(self):
+ sony_id_cache = {}
+ for child in self.root_element.childNodes:
+ if child.nodeType == child.ELEMENT_NODE and child.hasAttribute("id"):
+ sony_id_cache[child.getAttribute('id')] = child.getAttribute('path')
+
+ books_lpath_cache = {}
+ for book in self:
+ books_lpath_cache[book.lpath] = book
+
for title in self.tag_order.keys():
pl = self.playlist_by_title(title)
if not pl:
continue
- db_ids = [i.getAttribute('id') for i in pl.childNodes if hasattr(i, 'getAttribute')]
- pl_book_ids = [getattr(self.book_by_id(i), 'db_id', None) for i in db_ids]
+ # make a list of the ids
+ sony_ids = [id.getAttribute('id') \
+ for id in pl.childNodes if hasattr(id, 'getAttribute')]
+ # convert IDs in playlist to a list of lpaths
+ sony_paths = [sony_id_cache[id] for id in sony_ids]
+ # create list of books containing lpaths
+ books = [books_lpath_cache.get(p, None) for p in sony_paths]
+ # create dict of db_id -> sony_id
imap = {}
- for i, j in zip(pl_book_ids, db_ids):
- imap[i] = j
- pl_book_ids = [i for i in pl_book_ids if i is not None]
- ordered_ids = [i for i in self.tag_order[title] if i in pl_book_ids]
+ for book, sony_id in zip(books, sony_ids):
+ if book is not None:
+ imap[book.application_id] = sony_id
+ # filter the list, removing books not on device but on playlist
+ books = [i for i in books if i is not None]
+ # filter the order specification to the books we have
+ ordered_ids = [db_id for db_id in self.tag_order[title] if db_id in imap]
+ # rewrite the playlist in the correct order
if len(ordered_ids) < len(pl.childNodes):
continue
- children = [i for i in pl.childNodes if hasattr(i, 'getAttribute')]
+ children = [i for i in pl.childNodes if hasattr(i, 'getAttribute')]
for child in children:
pl.removeChild(child)
child.unlink()
@@ -439,7 +363,6 @@ def fix_ids(main, carda, cardb):
except KeyError:
item.parentNode.removeChild(item)
item.unlink()
-
db.reorder_playlists()
regen_ids(main)
diff --git a/src/calibre/devices/prs505/driver.py b/src/calibre/devices/prs505/driver.py
index f4fc4b0d29..3e1ee67faa 100644
--- a/src/calibre/devices/prs505/driver.py
+++ b/src/calibre/devices/prs505/driver.py
@@ -11,15 +11,14 @@ Device driver for the SONY PRS-505
import os
import re
-import time
-from itertools import cycle
-from calibre.devices.usbms.cli import CLI
-from calibre.devices.usbms.device import Device
+from calibre.devices.usbms.driver import USBMS
from calibre.devices.prs505.books import BookList, fix_ids
+from calibre.devices.prs505 import MEDIA_XML
+from calibre.devices.prs505 import CACHE_XML
from calibre import __appname__
-class PRS505(CLI, Device):
+class PRS505(USBMS):
name = 'PRS-300/505 Device Interface'
gui_name = 'SONY Reader'
@@ -46,9 +45,6 @@ class PRS505(CLI, Device):
MAIN_MEMORY_VOLUME_LABEL = 'Sony Reader Main Memory'
STORAGE_CARD_VOLUME_LABEL = 'Sony Reader Storage Card'
- MEDIA_XML = 'database/cache/media.xml'
- CACHE_XML = 'Sony Reader/database/cache.xml'
-
CARD_PATH_PREFIX = __appname__
SUPPORTS_SUB_DIRS = True
@@ -60,67 +56,18 @@ class PRS505(CLI, Device):
'series, tags, authors'
EXTRA_CUSTOMIZATION_DEFAULT = ', '.join(['series', 'tags'])
+ METADATA_CACHE = "database/cache/metadata.calibre"
+
+ def initialize(self):
+ USBMS.initialize(self)
+ self.booklist_class = BookList
+
def windows_filter_pnp_id(self, pnp_id):
return '_LAUNCHER' in pnp_id
- def open(self):
- self.report_progress = lambda x, y: x
- Device.open(self)
-
- def write_cache(prefix):
- try:
- cachep = os.path.join(prefix, *(self.CACHE_XML.split('/')))
- if not os.path.exists(cachep):
- dname = os.path.dirname(cachep)
- if not os.path.exists(dname):
- try:
- os.makedirs(dname, mode=0777)
- except:
- time.sleep(5)
- os.makedirs(dname, mode=0777)
- with open(cachep, 'wb') as f:
- f.write(u'''
-
-
- '''.encode('utf8'))
- return True
- except:
- import traceback
- traceback.print_exc()
- return False
-
- if self._card_a_prefix is not None:
- if not write_cache(self._card_a_prefix):
- self._card_a_prefix = None
- if self._card_b_prefix is not None:
- if not write_cache(self._card_b_prefix):
- self._card_b_prefix = None
-
def get_device_information(self, end_session=True):
return (self.gui_name, '', '', '')
- def books(self, oncard=None, end_session=True):
- if oncard == 'carda' and not self._card_a_prefix:
- self.report_progress(1.0, _('Getting list of books on device...'))
- return []
- elif oncard == 'cardb' and not self._card_b_prefix:
- self.report_progress(1.0, _('Getting list of books on device...'))
- return []
- elif oncard and oncard != 'carda' and oncard != 'cardb':
- self.report_progress(1.0, _('Getting list of books on device...'))
- return []
-
- db = self.__class__.CACHE_XML if oncard else self.__class__.MEDIA_XML
- prefix = self._card_a_prefix if oncard == 'carda' else self._card_b_prefix if oncard == 'cardb' else self._main_prefix
- bl = BookList(open(prefix + db, 'rb'), prefix, self.report_progress)
- paths = bl.purge_corrupted_files()
- for path in paths:
- path = os.path.join(prefix, path)
- if os.path.exists(path):
- os.unlink(path)
- self.report_progress(1.0, _('Getting list of books on device...'))
- return bl
-
def filename_callback(self, fname, mi):
if getattr(mi, 'application_id', None) is not None:
base = fname.rpartition('.')[0]
@@ -129,90 +76,17 @@ class PRS505(CLI, Device):
fname = base + suffix + '.' + fname.rpartition('.')[-1]
return fname
- def upload_books(self, files, names, on_card=None, end_session=True,
- metadata=None):
-
- path = self._sanity_check(on_card, files)
-
- paths, ctimes, sizes = [], [], []
- names = iter(names)
- metadata = iter(metadata)
- for i, infile in enumerate(files):
- mdata, fname = metadata.next(), names.next()
- filepath = self.create_upload_path(path, mdata, fname)
-
- paths.append(filepath)
- self.put_file(infile, paths[-1], replace_file=True)
- ctimes.append(os.path.getctime(paths[-1]))
- sizes.append(os.stat(paths[-1]).st_size)
-
- self.report_progress((i+1) / float(len(files)), _('Transferring books to device...'))
-
- self.report_progress(1.0, _('Transferring books to device...'))
-
- return zip(paths, sizes, ctimes, cycle([on_card]))
-
- def add_books_to_metadata(self, locations, metadata, booklists):
- if not locations or not metadata:
- return
-
- metadata = iter(metadata)
- for location in locations:
- info = metadata.next()
- path = location[0]
- oncard = location[3]
- blist = 2 if oncard == 'cardb' else 1 if oncard == 'carda' else 0
-
- if self._main_prefix and path.startswith(self._main_prefix):
- name = path.replace(self._main_prefix, '')
- elif self._card_a_prefix and path.startswith(self._card_a_prefix):
- name = path.replace(self._card_a_prefix, '')
- elif self._card_b_prefix and path.startswith(self._card_b_prefix):
- name = path.replace(self._card_b_prefix, '')
-
- name = name.replace('\\', '/')
- name = name.replace('//', '/')
- if name.startswith('/'):
- name = name[1:]
-
- opts = self.settings()
- collections = opts.extra_customization.split(',') if opts.extra_customization else []
- booklist = booklists[blist]
- if not hasattr(booklist, 'add_book'):
- raise ValueError(('Incorrect upload location %s. Did you choose the'
- ' correct card A or B, to send books to?')%oncard)
- booklist.add_book(info, name, collections, *location[1:-1])
- fix_ids(*booklists)
-
- def delete_books(self, paths, end_session=True):
- for i, path in enumerate(paths):
- self.report_progress((i+1) / float(len(paths)), _('Removing books from device...'))
- if os.path.exists(path):
- os.unlink(path)
- try:
- os.removedirs(os.path.dirname(path))
- except:
- pass
- self.report_progress(1.0, _('Removing books from device...'))
-
- @classmethod
- def remove_books_from_metadata(cls, paths, booklists):
- for path in paths:
- for bl in booklists:
- if hasattr(bl, 'remove_book'):
- bl.remove_book(path)
- fix_ids(*booklists)
-
def sync_booklists(self, booklists, end_session=True):
+ print 'in sync_booklists'
fix_ids(*booklists)
if not os.path.exists(self._main_prefix):
os.makedirs(self._main_prefix)
- with open(self._main_prefix + self.__class__.MEDIA_XML, 'wb') as f:
+ with open(self._main_prefix + MEDIA_XML, 'wb') as f:
booklists[0].write(f)
def write_card_prefix(prefix, listid):
if prefix is not None and hasattr(booklists[listid], 'write'):
- tgt = os.path.join(prefix, *(self.CACHE_XML.split('/')))
+ tgt = os.path.join(prefix, *(CACHE_XML.split('/')))
base = os.path.dirname(tgt)
if not os.path.exists(base):
os.makedirs(base)
@@ -221,8 +95,7 @@ class PRS505(CLI, Device):
write_card_prefix(self._card_a_prefix, 1)
write_card_prefix(self._card_b_prefix, 2)
- self.report_progress(1.0, _('Sending metadata to device...'))
-
+ USBMS.sync_booklists(self, booklists, end_session)
class PRS700(PRS505):
@@ -241,5 +114,3 @@ class PRS700(PRS505):
OSX_MAIN_MEM = re.compile(r'Sony PRS-((700/[^:]+)|((6|9)00)) Media')
OSX_CARD_A_MEM = re.compile(r'Sony PRS-((700/[^:]+:)|((6|9)00 ))MS Media')
OSX_CARD_B_MEM = re.compile(r'Sony PRS-((700/[^:]+:)|((6|9)00 ))SD Media')
-
-
diff --git a/src/calibre/devices/usbms/books.py b/src/calibre/devices/usbms/books.py
index 990b335a6d..bc6003de27 100644
--- a/src/calibre/devices/usbms/books.py
+++ b/src/calibre/devices/usbms/books.py
@@ -33,7 +33,9 @@ class Book(MetaInformation):
self.path = os.path.join(prefix, lpath)
if os.sep == '\\':
self.path = self.path.replace('/', '\\')
- self.lpath = lpath
+ self.lpath = lpath.replace('\\', '/')
+ else:
+ self.lpath = lpath
self.mime = mime_type_ext(path_to_ext(lpath))
self.size = os.stat(self.path).st_size if size == None else size
self.db_id = None
diff --git a/src/calibre/devices/usbms/driver.py b/src/calibre/devices/usbms/driver.py
index 63d28f5457..1a5b7461ed 100644
--- a/src/calibre/devices/usbms/driver.py
+++ b/src/calibre/devices/usbms/driver.py
@@ -31,32 +31,36 @@ class USBMS(CLI, Device):
CAN_SET_METADATA = True
METADATA_CACHE = 'metadata.calibre'
+ def initialize(self):
+ Device.initialize(self)
+ self.booklist_class = BookList
+
def get_device_information(self, end_session=True):
self.report_progress(1.0, _('Get device information...'))
return (self.get_gui_name(), '', '', '')
def books(self, oncard=None, end_session=True):
from calibre.ebooks.metadata.meta import path_to_ext
- bl = BookList()
- metadata = BookList()
- need_sync = False
if oncard == 'carda' and not self._card_a_prefix:
self.report_progress(1.0, _('Getting list of books on device...'))
- return bl
+ return []
elif oncard == 'cardb' and not self._card_b_prefix:
self.report_progress(1.0, _('Getting list of books on device...'))
- return bl
+ return []
elif oncard and oncard != 'carda' and oncard != 'cardb':
self.report_progress(1.0, _('Getting list of books on device...'))
- return bl
+ return []
prefix = self._card_a_prefix if oncard == 'carda' else self._card_b_prefix if oncard == 'cardb' else self._main_prefix
+ metadata = self.booklist_class(oncard, prefix)
+
ebook_dirs = self.EBOOK_DIR_CARD_A if oncard == 'carda' else \
self.EBOOK_DIR_CARD_B if oncard == 'cardb' else \
self.get_main_ebook_dir()
- bl, need_sync = self.parse_metadata_cache(prefix, self.METADATA_CACHE)
+ bl, need_sync = self.parse_metadata_cache(prefix, self.METADATA_CACHE,
+ self.booklist_class(oncard, prefix))
# make a dict cache of paths so the lookup in the loop below is faster.
bl_cache = {}
@@ -109,7 +113,6 @@ class USBMS(CLI, Device):
# find on the device. If need_sync is True then there were either items
# on the device that were not in bl or some of the items were changed.
if self.count_found_in_bl != len(bl) or need_sync:
- print 'resync'
if oncard == 'cardb':
self.sync_booklists((None, None, metadata))
elif oncard == 'carda':
@@ -122,7 +125,6 @@ class USBMS(CLI, Device):
def upload_books(self, files, names, on_card=None, end_session=True,
metadata=None):
-
path = self._sanity_check(on_card, files)
paths = []
@@ -145,7 +147,6 @@ class USBMS(CLI, Device):
self.report_progress((i+1) / float(len(files)), _('Transferring books to device...'))
self.report_progress(1.0, _('Transferring books to device...'))
-
return zip(paths, cycle([on_card]))
def upload_cover(self, path, filename, metadata):
@@ -174,11 +175,10 @@ class USBMS(CLI, Device):
lpath = path.partition(prefix)[2]
if lpath.startswith(os.sep):
lpath = lpath[len(os.sep):]
- lpath = lpath.replace('\\', '/')
book = Book(prefix, lpath, other=info)
-
- if book not in booklists[blist]:
- booklists[blist].append(book)
+ opts = self.settings()
+ collections = opts.extra_customization.split(',') if opts.extra_customization else []
+ booklists[blist].add_book(book, collections, *location[1:-1])
self.report_progress(1.0, _('Adding books to device metadata listing...'))
@@ -209,7 +209,7 @@ class USBMS(CLI, Device):
for bl in booklists:
for book in bl:
if path.endswith(book.path):
- bl.remove(book)
+ bl.remove_book(book)
self.report_progress(1.0, _('Removing books from device metadata listing...'))
def sync_booklists(self, booklists, end_session=True):
@@ -217,7 +217,7 @@ class USBMS(CLI, Device):
os.makedirs(self._main_prefix)
def write_prefix(prefix, listid):
- if prefix is not None and isinstance(booklists[listid], BookList):
+ if prefix is not None and isinstance(booklists[listid], self.booklist_class):
if not os.path.exists(prefix):
os.makedirs(prefix)
js = [item.to_json() for item in booklists[listid]]
@@ -230,9 +230,8 @@ class USBMS(CLI, Device):
self.report_progress(1.0, _('Sending metadata to device...'))
@classmethod
- def parse_metadata_cache(cls, prefix, name):
+ def parse_metadata_cache(cls, prefix, name, bl):
js = []
- bl = BookList()
need_sync = False
try:
with open(os.path.join(prefix, name), 'rb') as f:
@@ -249,7 +248,7 @@ class USBMS(CLI, Device):
except:
import traceback
traceback.print_exc()
- bl = BookList()
+ bl = []
return bl, need_sync
@classmethod
diff --git a/src/calibre/ebooks/metadata/__init__.py b/src/calibre/ebooks/metadata/__init__.py
index 60dffc0cf7..a1c29be337 100644
--- a/src/calibre/ebooks/metadata/__init__.py
+++ b/src/calibre/ebooks/metadata/__init__.py
@@ -254,11 +254,11 @@ class MetaInformation(object):
setattr(self, x, getattr(mi, x, None))
def print_all_attributes(self):
- for x in ('author_sort', 'title_sort', 'comments', 'category', 'publisher',
- 'series', 'series_index', 'rating', 'isbn', 'language',
+ for x in ('author', 'author_sort', 'title_sort', 'comments', 'category', 'publisher',
+ 'series', 'series_index', 'tags', 'rating', 'isbn', 'language',
'application_id', 'manifest', 'toc', 'spine', 'guide', 'cover',
'book_producer', 'timestamp', 'lccn', 'lcc', 'ddc', 'pubdate',
- 'rights', 'publication_type', 'uuid',
+ 'rights', 'publication_type', 'uuid', 'tag_order',
):
prints(x, getattr(self, x, 'None'))
@@ -278,7 +278,7 @@ class MetaInformation(object):
'isbn', 'application_id', 'manifest', 'spine', 'toc',
'cover', 'language', 'guide', 'book_producer',
'timestamp', 'lccn', 'lcc', 'ddc', 'pubdate', 'rights',
- 'publication_type', 'uuid',):
+ 'publication_type', 'uuid', 'tag_order'):
if hasattr(mi, attr):
val = getattr(mi, attr)
if val is not None:
diff --git a/src/calibre/gui2/device.py b/src/calibre/gui2/device.py
index df355f0ef5..828756e2c8 100644
--- a/src/calibre/gui2/device.py
+++ b/src/calibre/gui2/device.py
@@ -821,7 +821,9 @@ class DeviceGUI(object):
def sync_to_device(self, on_card, delete_from_library,
specific_format=None, send_ids=None, do_auto_convert=True):
- ids = [self.library_view.model().id(r) for r in self.library_view.selectionModel().selectedRows()] if send_ids is None else send_ids
+ ids = [self.library_view.model().id(r) \
+ for r in self.library_view.selectionModel().selectedRows()] \
+ if send_ids is None else send_ids
if not self.device_manager or not ids or len(ids) == 0:
return
@@ -842,8 +844,7 @@ class DeviceGUI(object):
ids = iter(ids)
for mi in metadata:
if mi.cover and os.access(mi.cover, os.R_OK):
- mi.thumbnail = self.cover_to_thumbnail(open(mi.cover,
- 'rb').read())
+ mi.thumbnail = self.cover_to_thumbnail(open(mi.cover, 'rb').read())
imetadata = iter(metadata)
files = [getattr(f, 'name', None) for f in _files]
@@ -890,7 +891,9 @@ class DeviceGUI(object):
bad.append(self.library_view.model().db.title(id, index_is_id=True))
if auto != []:
- format = specific_format if specific_format in list(set(settings.format_map).intersection(set(available_output_formats()))) else None
+ format = specific_format if specific_format in \
+ list(set(settings.format_map).intersection(set(available_output_formats()))) \
+ else None
if not format:
for fmt in settings.format_map:
if fmt in list(set(settings.format_map).intersection(set(available_output_formats()))):
@@ -1039,7 +1042,8 @@ class DeviceGUI(object):
loc[i] = True
break
if mi.authors and \
- re.sub('(?u)\W|[_]', '', mi.authors.lower()) in cache['authors']:
+ re.sub('(?u)\W|[_]', '', authors_to_string(mi.authors).lower()) \
+ in cache['authors']:
loc[i] = True
break
return loc
diff --git a/src/calibre/gui2/library.py b/src/calibre/gui2/library.py
index cdebf65489..e116e39397 100644
--- a/src/calibre/gui2/library.py
+++ b/src/calibre/gui2/library.py
@@ -17,7 +17,7 @@ from PyQt4.QtCore import QAbstractTableModel, QVariant, Qt, pyqtSignal, \
SIGNAL, QObject, QSize, QModelIndex, QDate
from calibre import strftime
-from calibre.ebooks.metadata import fmt_sidx, authors_to_string
+from calibre.ebooks.metadata import fmt_sidx, authors_to_string, string_to_authors
from calibre.ebooks.metadata.meta import set_metadata as _set_metadata
from calibre.gui2 import NONE, TableView, config, error_dialog, UNDEFINED_QDATE
from calibre.gui2.dialogs.comments_dialog import CommentsDialog
@@ -1378,7 +1378,10 @@ class DeviceBooksModel(BooksModel):
def libcmp(x, y):
x, y = self.db[x].in_library, self.db[y].in_library
return cmp(x, y)
- fcmp = strcmp('title_sorter') if col == 0 else strcmp('authors') if col == 1 else \
+ def authorcmp(x, y):
+ x, y = authors_to_string(self.db[x].authors), authors_to_string(self.db[y].authors)
+ return cmp(x, y)
+ fcmp = strcmp('title_sorter') if col == 0 else authorcmp if col == 1 else \
sizecmp if col == 2 else datecmp if col == 3 else tagscmp if col == 4 else libcmp
self.map.sort(cmp=fcmp, reverse=descending)
if len(self.map) == len(self.db):
@@ -1446,9 +1449,9 @@ class DeviceBooksModel(BooksModel):
au = self.db[self.map[row]].authors
if not au:
au = self.unknown
- if role == Qt.EditRole:
- return QVariant(authors_to_string(au))
- return QVariant(" & ".join(au))
+# if role == Qt.EditRole:
+# return QVariant(au)
+ return QVariant(authors_to_string(au))
elif col == 2:
size = self.db[self.map[row]].size
return QVariant(BooksView.human_readable(size))
@@ -1501,7 +1504,7 @@ class DeviceBooksModel(BooksModel):
self.db[idx].title = val
self.db[idx].title_sorter = val
elif col == 1:
- self.db[idx].authors = val
+ self.db[idx].authors = string_to_authors(val)
elif col == 4:
tags = [i.strip() for i in val.split(',')]
tags = [t for t in tags if t]
From 5c9e2ae267c4f178047c45d7b473a51f77001d4c Mon Sep 17 00:00:00 2001
From: Charles Haley <>
Date: Thu, 13 May 2010 22:17:52 +0100
Subject: [PATCH 082/324] After pylint
---
src/calibre/devices/prs505/books.py | 7 ++++++-
src/calibre/devices/prs505/driver.py | 1 -
2 files changed, 6 insertions(+), 2 deletions(-)
diff --git a/src/calibre/devices/prs505/books.py b/src/calibre/devices/prs505/books.py
index 82bc977bcd..ba3605530e 100644
--- a/src/calibre/devices/prs505/books.py
+++ b/src/calibre/devices/prs505/books.py
@@ -110,7 +110,7 @@ class BookList(_BookList):
return child
return None
- def add_book(self, book, collections):
+ def add_book(self, book, collections=None):
if book in self:
return
""" Add a node into the DOM tree, representing a book """
@@ -267,6 +267,11 @@ class BookList(_BookList):
pli.parentNode.removeChild(pli)
pli.unlink()
+ def set_tags(self, book, tags):
+ tags = [t for t in tags if t]
+ book.tags = tags
+ self.set_playlists(book.id, tags)
+
def set_playlists(self, id, collections):
self.remove_from_playlists(id)
for collection in set(collections):
diff --git a/src/calibre/devices/prs505/driver.py b/src/calibre/devices/prs505/driver.py
index 3e1ee67faa..0b41894a18 100644
--- a/src/calibre/devices/prs505/driver.py
+++ b/src/calibre/devices/prs505/driver.py
@@ -77,7 +77,6 @@ class PRS505(USBMS):
return fname
def sync_booklists(self, booklists, end_session=True):
- print 'in sync_booklists'
fix_ids(*booklists)
if not os.path.exists(self._main_prefix):
os.makedirs(self._main_prefix)
From 72fbd67c1764f1c1fc9e7dad00ffa144fab2bf04 Mon Sep 17 00:00:00 2001
From: Charles Haley <>
Date: Fri, 14 May 2010 12:17:14 +0100
Subject: [PATCH 083/324] More testing of: 1) initial condition: cache does not
exist on book 2) adding and removing books 3) subsequent conditions: cache
exists
In addition:
1) added metadata correction for books matched with something other than UUID.
2) Refactored changes to BookList, to move the additional methods into USMBS from Interface.
3) Made classmethods in USBMS into normal methods.
---
src/calibre/devices/interface.py | 14 --------
src/calibre/devices/prs505/books.py | 39 ++++------------------
src/calibre/devices/prs505/driver.py | 4 ++-
src/calibre/devices/usbms/books.py | 39 ++++++++++++++--------
src/calibre/devices/usbms/driver.py | 50 +++++++++++++---------------
src/calibre/gui2/device.py | 25 ++++++++++----
src/calibre/library/database2.py | 8 ++---
7 files changed, 82 insertions(+), 97 deletions(-)
diff --git a/src/calibre/devices/interface.py b/src/calibre/devices/interface.py
index 6247e29e15..b38b62e20c 100644
--- a/src/calibre/devices/interface.py
+++ b/src/calibre/devices/interface.py
@@ -402,17 +402,3 @@ class BookList(list):
'''
raise NotImplementedError()
- def add_book(self, book, collections=None):
- '''
- Add the book to the booklist. Intent is to maintain any device-internal
- metadata
- '''
- if book not in self:
- self.append(book)
-
- def remove_book(self, book):
- '''
- Remove a book from the booklist. Correct any device metadata at the
- same time
- '''
- self.remove(book)
diff --git a/src/calibre/devices/prs505/books.py b/src/calibre/devices/prs505/books.py
index ba3605530e..855d8d5cd3 100644
--- a/src/calibre/devices/prs505/books.py
+++ b/src/calibre/devices/prs505/books.py
@@ -8,7 +8,7 @@ import xml.dom.minidom as dom
from base64 import b64encode as encode
-from calibre.devices.interface import BookList as _BookList
+from calibre.devices.usbms.books import BookList as _BookList
from calibre.devices import strftime as _strftime
from calibre.devices.usbms.books import Book as _Book
from calibre.devices.prs505 import MEDIA_XML
@@ -31,36 +31,6 @@ def uuid():
def sortable_title(title):
return re.sub('^\s*A\s+|^\s*The\s+|^\s*An\s+', '', title).rstrip()
-class book_metadata_field(object):
- """ Represents metadata stored as an attribute """
- def __init__(self, attr, formatter=None, setter=None):
- self.attr = attr
- self.formatter = formatter
- self.setter = setter
-
- def __get__(self, obj, typ=None):
- """ Return a string. String may be empty if self.attr is absent """
- return self.formatter(obj.elem.getAttribute(self.attr)) if \
- self.formatter else obj.elem.getAttribute(self.attr).strip()
-
- def __set__(self, obj, val):
- """ Set the attribute """
- val = self.setter(val) if self.setter else val
- if not isinstance(val, unicode):
- val = unicode(val, 'utf8', 'replace')
- obj.elem.setAttribute(self.attr, val)
-
-
-class Book(_Book):
- @dynamic_property
- def db_id(self):
- doc = '''The database id in the application database that this file corresponds to'''
- def fget(self):
- match = re.search(r'_(\d+)$', self.rpath.rpartition('.')[0])
- if match:
- return int(match.group(1))
- return property(fget=fget, doc=doc)
-
class BookList(_BookList):
def __init__(self, oncard, prefix):
@@ -318,7 +288,12 @@ class BookList(_BookList):
imap = {}
for book, sony_id in zip(books, sony_ids):
if book is not None:
- imap[book.application_id] = sony_id
+ db_id = book.application_id
+ if db_id is None:
+ db_id = book.db_id
+ print 'here', db_id
+ if db_id is not None:
+ imap[book.application_id] = sony_id
# filter the list, removing books not on device but on playlist
books = [i for i in books if i is not None]
# filter the order specification to the books we have
diff --git a/src/calibre/devices/prs505/driver.py b/src/calibre/devices/prs505/driver.py
index 0b41894a18..1d403cb75d 100644
--- a/src/calibre/devices/prs505/driver.py
+++ b/src/calibre/devices/prs505/driver.py
@@ -13,6 +13,7 @@ import os
import re
from calibre.devices.usbms.driver import USBMS
+from calibre.devices.usbms.books import Book
from calibre.devices.prs505.books import BookList, fix_ids
from calibre.devices.prs505 import MEDIA_XML
from calibre.devices.prs505 import CACHE_XML
@@ -59,8 +60,9 @@ class PRS505(USBMS):
METADATA_CACHE = "database/cache/metadata.calibre"
def initialize(self):
- USBMS.initialize(self)
+ USBMS.initialize(self) # Must be first, so _class vars are set right
self.booklist_class = BookList
+ self.book_class = Book
def windows_filter_pnp_id(self, pnp_id):
return '_LAUNCHER' in pnp_id
diff --git a/src/calibre/devices/usbms/books.py b/src/calibre/devices/usbms/books.py
index bc6003de27..ce74db6f54 100644
--- a/src/calibre/devices/usbms/books.py
+++ b/src/calibre/devices/usbms/books.py
@@ -37,12 +37,8 @@ class Book(MetaInformation):
else:
self.lpath = lpath
self.mime = mime_type_ext(path_to_ext(lpath))
- self.size = os.stat(self.path).st_size if size == None else size
- self.db_id = None
- try:
- self.datetime = time.gmtime(os.path.getctime(self.path))
- except ValueError:
- self.datetime = time.gmtime()
+ self.size = None # will be set later
+ self.datetime = time.gmtime()
if other:
self.smart_update(other)
@@ -70,6 +66,16 @@ class Book(MetaInformation):
return spath == opath
+ @dynamic_property
+ def db_id(self):
+ doc = '''The database id in the application database that this file corresponds to'''
+ def fget(self):
+ match = re.search(r'_(\d+)$', self.lpath.rpartition('.')[0])
+ if match:
+ return int(match.group(1))
+ return None
+ return property(fget=fget, doc=doc)
+
@dynamic_property
def title_sorter(self):
doc = '''String to sort the title. If absent, title is returned'''
@@ -81,13 +87,6 @@ class Book(MetaInformation):
def thumbnail(self):
return None
-# def __str__(self):
-# '''
-# Return a utf-8 encoded string with title author and path information
-# '''
-# return self.title.encode('utf-8') + " by " + \
-# self.authors.encode('utf-8') + " at " + self.path.encode('utf-8')
-
def smart_update(self, other):
'''
Merge the information in C{other} into self. In case of conflicts, the information
@@ -115,3 +114,17 @@ class BookList(_BookList):
def set_tags(self, book, tags):
book.tags = tags
+ def add_book(self, book, collections=None):
+ '''
+ Add the book to the booklist. Intent is to maintain any device-internal
+ metadata
+ '''
+ if book not in self:
+ self.append(book)
+
+ def remove_book(self, book):
+ '''
+ Remove a book from the booklist. Correct any device metadata at the
+ same time
+ '''
+ self.remove(book)
diff --git a/src/calibre/devices/usbms/driver.py b/src/calibre/devices/usbms/driver.py
index 1a5b7461ed..361f7ea1bf 100644
--- a/src/calibre/devices/usbms/driver.py
+++ b/src/calibre/devices/usbms/driver.py
@@ -34,6 +34,7 @@ class USBMS(CLI, Device):
def initialize(self):
Device.initialize(self)
self.booklist_class = BookList
+ self.book_class = Book
def get_device_information(self, end_session=True):
self.report_progress(1.0, _('Get device information...'))
@@ -52,7 +53,9 @@ class USBMS(CLI, Device):
self.report_progress(1.0, _('Getting list of books on device...'))
return []
- prefix = self._card_a_prefix if oncard == 'carda' else self._card_b_prefix if oncard == 'cardb' else self._main_prefix
+ prefix = self._card_a_prefix if oncard == 'carda' else \
+ self._card_b_prefix if oncard == 'cardb' \
+ else self._main_prefix
metadata = self.booklist_class(oncard, prefix)
ebook_dirs = self.EBOOK_DIR_CARD_A if oncard == 'carda' else \
@@ -61,7 +64,6 @@ class USBMS(CLI, Device):
bl, need_sync = self.parse_metadata_cache(prefix, self.METADATA_CACHE,
self.booklist_class(oncard, prefix))
-
# make a dict cache of paths so the lookup in the loop below is faster.
bl_cache = {}
for idx,b in enumerate(bl):
@@ -77,10 +79,10 @@ class USBMS(CLI, Device):
lpath = lpath[len(os.sep):]
idx = bl_cache.get(lpath.replace('\\', '/'), None)
if idx is not None:
- item, changed = self.__class__.update_metadata_item(bl[idx])
+ item, changed = self.update_metadata_item(bl[idx])
self.count_found_in_bl += 1
else:
- item = self.__class__.book_from_path(prefix, lpath)
+ item = self.book_from_path(prefix, lpath)
changed = True
metadata.append(item)
except: # Probably a filename encoding error
@@ -175,7 +177,10 @@ class USBMS(CLI, Device):
lpath = path.partition(prefix)[2]
if lpath.startswith(os.sep):
lpath = lpath[len(os.sep):]
- book = Book(prefix, lpath, other=info)
+ book = self.book_class(prefix, lpath, other=info)
+ if book.size is None:
+ book.size = os.stat(path).st_size
+
opts = self.settings()
collections = opts.extra_customization.split(',') if opts.extra_customization else []
booklists[blist].add_book(book, collections, *location[1:-1])
@@ -229,19 +234,14 @@ class USBMS(CLI, Device):
self.report_progress(1.0, _('Sending metadata to device...'))
- @classmethod
- def parse_metadata_cache(cls, prefix, name, bl):
+ def parse_metadata_cache(self, prefix, name, bl):
js = []
need_sync = False
try:
with open(os.path.join(prefix, name), 'rb') as f:
js = json.load(f, encoding='utf-8')
for item in js:
- lpath = item.get('lpath', None)
- if not lpath or not os.path.exists(os.path.join(prefix, lpath)):
- need_sync = True
- continue
- book = Book(prefix, lpath)
+ book = self.book_class(prefix, item.get('lpath', None))
for key in item.keys():
setattr(book, key, item[key])
bl.append(book)
@@ -249,35 +249,33 @@ class USBMS(CLI, Device):
import traceback
traceback.print_exc()
bl = []
+ need_sync = True
return bl, need_sync
- @classmethod
- def update_metadata_item(cls, item):
+ def update_metadata_item(self, item):
changed = False
size = os.stat(item.path).st_size
if size != item.size:
changed = True
- mi = cls.metadata_from_path(item.path)
+ mi = self.metadata_from_path(item.path)
item.smart_update(mi)
+ item.size = size
return item, changed
- @classmethod
- def metadata_from_path(cls, path):
- return cls.metadata_from_formats([path])
+ def metadata_from_path(self, path):
+ return self.metadata_from_formats([path])
- @classmethod
- def metadata_from_formats(cls, fmts):
+ def metadata_from_formats(self, fmts):
from calibre.ebooks.metadata.meta import metadata_from_formats
from calibre.customize.ui import quick_metadata
with quick_metadata:
return metadata_from_formats(fmts)
- @classmethod
- def book_from_path(cls, prefix, path):
+ def book_from_path(self, prefix, path):
from calibre.ebooks.metadata import MetaInformation
- if cls.settings().read_metadata or cls.MUST_READ_METADATA:
- mi = cls.metadata_from_path(os.path.join(prefix, path))
+ if self.settings().read_metadata or self.MUST_READ_METADATA:
+ mi = self.metadata_from_path(os.path.join(prefix, path))
else:
from calibre.ebooks.metadata.meta import metadata_from_filename
mi = metadata_from_filename(os.path.basename(path),
@@ -286,6 +284,6 @@ class USBMS(CLI, Device):
if mi is None:
mi = MetaInformation(os.path.splitext(os.path.basename(path))[0],
[_('Unknown')])
-
- book = Book(prefix, path, other=mi)
+ mi.size = os.stat(os.path.join(prefix, path)).st_size
+ book = self.book_class(prefix, path, other=mi)
return book
diff --git a/src/calibre/gui2/device.py b/src/calibre/gui2/device.py
index 828756e2c8..a9f69bbca5 100644
--- a/src/calibre/gui2/device.py
+++ b/src/calibre/gui2/device.py
@@ -523,7 +523,8 @@ class DeviceGUI(object):
d = ChooseFormatDialog(self, _('Choose format to send to device'),
self.device_manager.device.settings().format_map)
d.exec_()
- fmt = d.format().lower()
+ if d.format():
+ fmt = d.format().lower()
dest, sub_dest = dest.split(':')
if dest in ('main', 'carda', 'cardb'):
if not self.device_connected or not self.device_manager:
@@ -998,7 +999,7 @@ class DeviceGUI(object):
if changed:
self.library_view.model().refresh_ids(list(changed))
- def book_on_device(self, index, format=None, reset=False):
+ def book_on_device(self, id, format=None, reset=False):
loc = [None, None, None]
if reset:
@@ -1030,7 +1031,7 @@ class DeviceGUI(object):
if uuid is not None:
self.book_db_uuid_cache[i].add(uuid)
- mi = self.library_view.model().db.get_metadata(index, index_is_id=True)
+ mi = self.library_view.model().db.get_metadata(id, index_is_id=True)
for i, l in enumerate(self.booklists()):
if mi.uuid in self.book_db_uuid_cache[i]:
loc[i] = True
@@ -1038,7 +1039,7 @@ class DeviceGUI(object):
db_title = re.sub('(?u)\W|[_]', '', mi.title.lower())
cache = self.book_db_title_cache[i].get(db_title, None)
if cache:
- if index in cache['db_ids']:
+ if id in cache['db_ids']:
loc[i] = True
break
if mi.authors and \
@@ -1057,11 +1058,11 @@ class DeviceGUI(object):
mi = self.library_view.model().db.get_metadata(idx, index_is_id=False)
title = re.sub('(?u)\W|[_]', '', mi.title.lower())
if title not in self.db_book_title_cache:
- self.db_book_title_cache[title] = {'authors':set(), 'db_ids':set()}
+ self.db_book_title_cache[title] = {'authors':{}, 'db_ids':{}}
authors = authors_to_string(mi.authors).lower() if mi.authors else ''
authors = re.sub('(?u)\W|[_]', '', authors)
- self.db_book_title_cache[title]['authors'].add(authors)
- self.db_book_title_cache[title]['db_ids'].add(mi.application_id)
+ self.db_book_title_cache[title]['authors'][authors] = mi
+ self.db_book_title_cache[title]['db_ids'][mi.application_id] = mi
self.db_book_uuid_cache.add(mi.uuid)
# Now iterate through all the books on the device, setting the in_library field
@@ -1069,6 +1070,7 @@ class DeviceGUI(object):
# is really the db key, but as this can accidentally match across libraries we
# also verify the title. The db_id exists on Sony devices. Fallback is title
# and author match
+ resend_metadata = False
for booklist in booklists:
for book in booklist:
if getattr(book, 'uuid', None) in self.db_book_uuid_cache:
@@ -1082,11 +1084,20 @@ class DeviceGUI(object):
if d is not None:
if getattr(book, 'application_id', None) in d['db_ids']:
book.in_library = True
+ book.smart_update(d['db_ids'][book.application_id])
+ resend_metadata = True
continue
if book.db_id in d['db_ids']:
book.in_library = True
+ book.smart_update(d['db_ids'][book.db_id])
+ resend_metadata = True
continue
book_authors = authors_to_string(book.authors).lower() if book.authors else ''
book_authors = re.sub('(?u)\W|[_]', '', book_authors)
if book_authors in d['authors']:
book.in_library = True
+ book.smart_update(d['authors'][book_authors])
+ resend_metadata = True
+ if resend_metadata:
+ # Correcting metadata cache on device.
+ self.device_manager.sync_booklists(None, booklists)
diff --git a/src/calibre/library/database2.py b/src/calibre/library/database2.py
index fd59503eed..b0f2d3cb39 100644
--- a/src/calibre/library/database2.py
+++ b/src/calibre/library/database2.py
@@ -470,14 +470,14 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
im = PILImage.open(f)
im.convert('RGB').save(path, 'JPEG')
- def book_on_device(self, index):
+ def book_on_device(self, id):
if callable(self.book_on_device_func):
- return self.book_on_device_func(index)
+ return self.book_on_device_func(id)
return None
- def book_on_device_string(self, index):
+ def book_on_device_string(self, id):
loc = []
- on = self.book_on_device(index)
+ on = self.book_on_device(id)
if on is not None:
m, a, b = on
if m is not None:
From 96079a712ccaf9369f5aa766c432f6f2adaefad9 Mon Sep 17 00:00:00 2001
From: Charles Haley <>
Date: Fri, 14 May 2010 14:28:53 +0100
Subject: [PATCH 084/324] Put @classmethods back in. I don't understand why the
decoration is there, so I hesitate to take it out. Far as I can see, there is
no reason for it, but ...
---
src/calibre/devices/usbms/driver.py | 34 ++++++++++++++++++-----------
src/calibre/gui2/device.py | 10 ++++-----
2 files changed, 26 insertions(+), 18 deletions(-)
diff --git a/src/calibre/devices/usbms/driver.py b/src/calibre/devices/usbms/driver.py
index 361f7ea1bf..1fdf3bdf84 100644
--- a/src/calibre/devices/usbms/driver.py
+++ b/src/calibre/devices/usbms/driver.py
@@ -27,14 +27,17 @@ class USBMS(CLI, Device):
author = _('John Schember')
supported_platforms = ['windows', 'osx', 'linux']
+ booklist_class = BookList
+ book_class = Book
+
FORMATS = []
CAN_SET_METADATA = True
METADATA_CACHE = 'metadata.calibre'
def initialize(self):
Device.initialize(self)
- self.booklist_class = BookList
- self.book_class = Book
+# self.booklist_class = BookList
+# self.book_class = Book
def get_device_information(self, end_session=True):
self.report_progress(1.0, _('Get device information...'))
@@ -234,14 +237,15 @@ class USBMS(CLI, Device):
self.report_progress(1.0, _('Sending metadata to device...'))
- def parse_metadata_cache(self, prefix, name, bl):
+ @classmethod
+ def parse_metadata_cache(cls, prefix, name, bl):
js = []
need_sync = False
try:
with open(os.path.join(prefix, name), 'rb') as f:
js = json.load(f, encoding='utf-8')
for item in js:
- book = self.book_class(prefix, item.get('lpath', None))
+ book = cls.book_class(prefix, item.get('lpath', None))
for key in item.keys():
setattr(book, key, item[key])
bl.append(book)
@@ -252,30 +256,34 @@ class USBMS(CLI, Device):
need_sync = True
return bl, need_sync
- def update_metadata_item(self, item):
+ @classmethod
+ def update_metadata_item(cls, item):
changed = False
size = os.stat(item.path).st_size
if size != item.size:
changed = True
- mi = self.metadata_from_path(item.path)
+ mi = cls.metadata_from_path(item.path)
item.smart_update(mi)
item.size = size
return item, changed
- def metadata_from_path(self, path):
- return self.metadata_from_formats([path])
+ @classmethod
+ def metadata_from_path(cls, path):
+ return cls.metadata_from_formats([path])
- def metadata_from_formats(self, fmts):
+ @classmethod
+ def metadata_from_formats(cls, fmts):
from calibre.ebooks.metadata.meta import metadata_from_formats
from calibre.customize.ui import quick_metadata
with quick_metadata:
return metadata_from_formats(fmts)
- def book_from_path(self, prefix, path):
+ @classmethod
+ def book_from_path(cls, prefix, path):
from calibre.ebooks.metadata import MetaInformation
- if self.settings().read_metadata or self.MUST_READ_METADATA:
- mi = self.metadata_from_path(os.path.join(prefix, path))
+ if cls.settings().read_metadata or cls.MUST_READ_METADATA:
+ mi = cls.metadata_from_path(os.path.join(prefix, path))
else:
from calibre.ebooks.metadata.meta import metadata_from_filename
mi = metadata_from_filename(os.path.basename(path),
@@ -285,5 +293,5 @@ class USBMS(CLI, Device):
mi = MetaInformation(os.path.splitext(os.path.basename(path))[0],
[_('Unknown')])
mi.size = os.stat(os.path.join(prefix, path)).st_size
- book = self.book_class(prefix, path, other=mi)
+ book = cls.book_class(prefix, path, other=mi)
return book
diff --git a/src/calibre/gui2/device.py b/src/calibre/gui2/device.py
index a9f69bbca5..af314c5468 100644
--- a/src/calibre/gui2/device.py
+++ b/src/calibre/gui2/device.py
@@ -1022,11 +1022,11 @@ class DeviceGUI(object):
book_authors = authors_to_string(book.authors).lower()
book_authors = re.sub('(?u)\W|[_]', '', book_authors)
self.book_db_title_cache[i][book_title]['authors'].add(book_authors)
- id = getattr(book, 'application_id', None)
- if id is None:
- id = book.db_id
- if id is not None:
- self.book_db_title_cache[i][book_title]['db_ids'].add(id)
+ db_id = getattr(book, 'application_id', None)
+ if db_id is None:
+ db_id = book.db_id
+ if db_id is not None:
+ self.book_db_title_cache[i][book_title]['db_ids'].add(db_id)
uuid = getattr(book, 'uuid', None)
if uuid is not None:
self.book_db_uuid_cache[i].add(uuid)
From dc130d56a92f0250f1e49df53eaa9a7054afd75f Mon Sep 17 00:00:00 2001
From: Charles Haley <>
Date: Fri, 14 May 2010 14:52:33 +0100
Subject: [PATCH 085/324] Cleanup, remove some print statements
---
src/calibre/devices/prs505/books.py | 1 -
src/calibre/devices/prs505/driver.py | 10 +++-------
src/calibre/devices/usbms/driver.py | 9 ++++-----
3 files changed, 7 insertions(+), 13 deletions(-)
diff --git a/src/calibre/devices/prs505/books.py b/src/calibre/devices/prs505/books.py
index 855d8d5cd3..7f4071a6cf 100644
--- a/src/calibre/devices/prs505/books.py
+++ b/src/calibre/devices/prs505/books.py
@@ -291,7 +291,6 @@ class BookList(_BookList):
db_id = book.application_id
if db_id is None:
db_id = book.db_id
- print 'here', db_id
if db_id is not None:
imap[book.application_id] = sony_id
# filter the list, removing books not on device but on playlist
diff --git a/src/calibre/devices/prs505/driver.py b/src/calibre/devices/prs505/driver.py
index 1d403cb75d..9ff88da592 100644
--- a/src/calibre/devices/prs505/driver.py
+++ b/src/calibre/devices/prs505/driver.py
@@ -13,8 +13,7 @@ import os
import re
from calibre.devices.usbms.driver import USBMS
-from calibre.devices.usbms.books import Book
-from calibre.devices.prs505.books import BookList, fix_ids
+from calibre.devices.prs505.books import BookList as PRS_BookList, fix_ids
from calibre.devices.prs505 import MEDIA_XML
from calibre.devices.prs505 import CACHE_XML
from calibre import __appname__
@@ -28,6 +27,8 @@ class PRS505(USBMS):
supported_platforms = ['windows', 'osx', 'linux']
path_sep = '/'
+ booklist_class = PRS_BookList # See USBMS for some explanation of this
+
FORMATS = ['epub', 'lrf', 'lrx', 'rtf', 'pdf', 'txt']
VENDOR_ID = [0x054c] #: SONY Vendor Id
@@ -59,11 +60,6 @@ class PRS505(USBMS):
METADATA_CACHE = "database/cache/metadata.calibre"
- def initialize(self):
- USBMS.initialize(self) # Must be first, so _class vars are set right
- self.booklist_class = BookList
- self.book_class = Book
-
def windows_filter_pnp_id(self, pnp_id):
return '_LAUNCHER' in pnp_id
diff --git a/src/calibre/devices/usbms/driver.py b/src/calibre/devices/usbms/driver.py
index 1fdf3bdf84..64b27a993d 100644
--- a/src/calibre/devices/usbms/driver.py
+++ b/src/calibre/devices/usbms/driver.py
@@ -27,6 +27,10 @@ class USBMS(CLI, Device):
author = _('John Schember')
supported_platforms = ['windows', 'osx', 'linux']
+ # Store type instances of BookList and Book. We must do this because
+ # a) we need to override these classes in some device drivers, and
+ # b) the classmethods seem only to see real attributes declared in the
+ # class, not attributes stored in the class
booklist_class = BookList
book_class = Book
@@ -34,11 +38,6 @@ class USBMS(CLI, Device):
CAN_SET_METADATA = True
METADATA_CACHE = 'metadata.calibre'
- def initialize(self):
- Device.initialize(self)
-# self.booklist_class = BookList
-# self.book_class = Book
-
def get_device_information(self, end_session=True):
self.report_progress(1.0, _('Get device information...'))
return (self.get_gui_name(), '', '', '')
From 9a0dfff78e93857ed4bf4dad44efff506ba6f913 Mon Sep 17 00:00:00 2001
From: Charles Haley <>
Date: Sat, 15 May 2010 13:37:39 +0100
Subject: [PATCH 086/324] 1) improve performance of OnDevice refresh. Instead
of rebuilding the complete book list, iterate through the existing one and
set the value of OnDevice correctly. 2) Fix problems with Sony readers and
metadata caching. Needed to ensure that when a book is added to the booklist
from the JSON cache, it is added to the sony cache if it isn't already there.
3) Build the sony metadata maps (caches) on the fly instead of in
reorder_playlists. 4) Refactor method declarations. 5) Move the JSON cache to
the root of the card for Sony devices.
---
src/calibre/devices/prs505/books.py | 78 ++++++++++++++++------------
src/calibre/devices/prs505/driver.py | 2 -
src/calibre/devices/usbms/books.py | 7 ++-
src/calibre/devices/usbms/driver.py | 19 +++----
src/calibre/gui2/library.py | 2 +-
src/calibre/gui2/ui.py | 2 +-
src/calibre/library/caches.py | 6 +++
src/calibre/library/database2.py | 2 +
8 files changed, 71 insertions(+), 47 deletions(-)
diff --git a/src/calibre/devices/prs505/books.py b/src/calibre/devices/prs505/books.py
index 7f4071a6cf..40a98913be 100644
--- a/src/calibre/devices/prs505/books.py
+++ b/src/calibre/devices/prs505/books.py
@@ -10,9 +10,8 @@ from base64 import b64encode as encode
from calibre.devices.usbms.books import BookList as _BookList
from calibre.devices import strftime as _strftime
-from calibre.devices.usbms.books import Book as _Book
-from calibre.devices.prs505 import MEDIA_XML
-from calibre.devices.prs505 import CACHE_XML
+from calibre.devices.prs505 import MEDIA_XML, CACHE_XML
+from calibre.devices.errors import PathError
strftime = functools.partial(_strftime, zone=time.gmtime)
@@ -33,10 +32,14 @@ def sortable_title(title):
class BookList(_BookList):
- def __init__(self, oncard, prefix):
- _BookList.__init__(self, oncard, prefix)
+ def __init__(self, oncard, prefix, settings):
+ _BookList.__init__(self, oncard, prefix, settings)
if prefix is None:
return
+ self.sony_id_cache = {}
+ self.books_lpath_cache = {}
+ opts = settings()
+ self.collections = opts.extra_customization.split(',') if opts.extra_customization else []
db = CACHE_XML if oncard else MEDIA_XML
xml_file = open(prefix + db, 'rb')
xml_file.seek(0)
@@ -50,8 +53,21 @@ class BookList(_BookList):
self.root_element = records[0]
else:
self.prefix = ''
+ for child in self.root_element.childNodes:
+ if child.nodeType == child.ELEMENT_NODE and child.hasAttribute("id"):
+ self.sony_id_cache[child.getAttribute('id')] = child.getAttribute('path')
+ # set the key to none. Will be filled in later when booklist is built
+ self.books_lpath_cache[child.getAttribute('path')] = None
self.tag_order = {}
+ paths = self.purge_corrupted_files()
+ for path in paths:
+ try:
+ self.del_file(path, end_session=False)
+ except PathError: # Incase this is a refetch without a sync in between
+ continue
+
+
def max_id(self):
max = 0
for child in self.root_element.childNodes:
@@ -73,22 +89,27 @@ class BookList(_BookList):
def supports_tags(self):
return True
- def book_by_path(self, path):
- for child in self.root_element.childNodes:
- if child.nodeType == child.ELEMENT_NODE and child.hasAttribute("path"):
- if path == child.getAttribute('path'):
- return child
- return None
-
- def add_book(self, book, collections=None):
+ def add_book(self, book, replace_metadata):
+ # Add a node into the DOM tree, representing a book. Also add to booklist
if book in self:
- return
- """ Add a node into the DOM tree, representing a book """
- node = self.document.createElement(self.prefix + "text")
- mime = MIME_MAP.get(book.lpath.rpartition('.')[-1].lower(), MIME_MAP['epub'])
+ # replacing metadata for book
+ self.delete_node(book.lpath)
+ else:
+ self.append(book)
+ if not replace_metadata:
+ if self.books_lpath_cache.has_key(book.lpath):
+ self.books_lpath_cache[book.lpath] = book
+ return
+ # Book not in metadata. Add it. Note that we don't need to worry about
+ # extra books in the Sony metadata. The reader deletes them for us when
+ # we disconnect. That said, if it becomes important one day, we can do
+ # it by scanning the books_lpath_cache for None entries and removing the
+ # corresponding nodes.
+ self.books_lpath_cache[book.lpath] = book
cid = self.max_id()+1
- book.sony_id = cid
- self.append(book)
+ node = self.document.createElement(self.prefix + "text")
+ self.sony_id_cache[cid] = book.lpath
+ mime = MIME_MAP.get(book.lpath.rpartition('.')[-1].lower(), MIME_MAP['epub'])
try:
sourceid = str(self[0].sourceid) if len(self) else '1'
except:
@@ -120,7 +141,7 @@ class BookList(_BookList):
self.root_element.appendChild(node)
tags = []
- for item in collections:
+ for item in self.collections:
item = item.strip()
mitem = getattr(book, item, None)
titems = []
@@ -141,6 +162,7 @@ class BookList(_BookList):
if hasattr(book, 'tag_order'):
self.tag_order.update(book.tag_order)
self.set_playlists(cid, tags)
+ return True # metadata cache has changed. Must sync at end
def _delete_node(self, node):
nid = node.getAttribute('id')
@@ -162,7 +184,8 @@ class BookList(_BookList):
def remove_book(self, book):
'''
Remove DOM node corresponding to book with C{path == path}.
- Also remove book from any collections it is part of.
+ Also remove book from any collections it is part of, and remove
+ from the booklist
'''
self.remove(book)
self.delete_node(book.lpath)
@@ -264,15 +287,6 @@ class BookList(_BookList):
stream.write(src.replace("'", '''))
def reorder_playlists(self):
- sony_id_cache = {}
- for child in self.root_element.childNodes:
- if child.nodeType == child.ELEMENT_NODE and child.hasAttribute("id"):
- sony_id_cache[child.getAttribute('id')] = child.getAttribute('path')
-
- books_lpath_cache = {}
- for book in self:
- books_lpath_cache[book.lpath] = book
-
for title in self.tag_order.keys():
pl = self.playlist_by_title(title)
if not pl:
@@ -281,9 +295,9 @@ class BookList(_BookList):
sony_ids = [id.getAttribute('id') \
for id in pl.childNodes if hasattr(id, 'getAttribute')]
# convert IDs in playlist to a list of lpaths
- sony_paths = [sony_id_cache[id] for id in sony_ids]
+ sony_paths = [self.sony_id_cache[id] for id in sony_ids]
# create list of books containing lpaths
- books = [books_lpath_cache.get(p, None) for p in sony_paths]
+ books = [self.books_lpath_cache.get(p, None) for p in sony_paths]
# create dict of db_id -> sony_id
imap = {}
for book, sony_id in zip(books, sony_ids):
diff --git a/src/calibre/devices/prs505/driver.py b/src/calibre/devices/prs505/driver.py
index 9ff88da592..d2823ff4a4 100644
--- a/src/calibre/devices/prs505/driver.py
+++ b/src/calibre/devices/prs505/driver.py
@@ -58,8 +58,6 @@ class PRS505(USBMS):
'series, tags, authors'
EXTRA_CUSTOMIZATION_DEFAULT = ', '.join(['series', 'tags'])
- METADATA_CACHE = "database/cache/metadata.calibre"
-
def windows_filter_pnp_id(self, pnp_id):
return '_LAUNCHER' in pnp_id
diff --git a/src/calibre/devices/usbms/books.py b/src/calibre/devices/usbms/books.py
index ce74db6f54..b153300282 100644
--- a/src/calibre/devices/usbms/books.py
+++ b/src/calibre/devices/usbms/books.py
@@ -108,16 +108,19 @@ class Book(MetaInformation):
class BookList(_BookList):
+ def __init__(self, oncard, prefix, settings):
+ pass
+
def supports_tags(self):
return True
def set_tags(self, book, tags):
book.tags = tags
- def add_book(self, book, collections=None):
+ def add_book(self, book, replace_metadata):
'''
Add the book to the booklist. Intent is to maintain any device-internal
- metadata
+ metadata. Return True if booklists must be sync'ed
'''
if book not in self:
self.append(book)
diff --git a/src/calibre/devices/usbms/driver.py b/src/calibre/devices/usbms/driver.py
index 64b27a993d..95b7441f44 100644
--- a/src/calibre/devices/usbms/driver.py
+++ b/src/calibre/devices/usbms/driver.py
@@ -58,20 +58,22 @@ class USBMS(CLI, Device):
prefix = self._card_a_prefix if oncard == 'carda' else \
self._card_b_prefix if oncard == 'cardb' \
else self._main_prefix
- metadata = self.booklist_class(oncard, prefix)
ebook_dirs = self.EBOOK_DIR_CARD_A if oncard == 'carda' else \
self.EBOOK_DIR_CARD_B if oncard == 'cardb' else \
self.get_main_ebook_dir()
- bl, need_sync = self.parse_metadata_cache(prefix, self.METADATA_CACHE,
- self.booklist_class(oncard, prefix))
+ # build a temporary list of books from the metadata cache
+ bl, need_sync = self.parse_metadata_cache(prefix, self.METADATA_CACHE)
# make a dict cache of paths so the lookup in the loop below is faster.
bl_cache = {}
for idx,b in enumerate(bl):
bl_cache[b.lpath] = idx
self.count_found_in_bl = 0
+ # Make the real booklist that will be filled in below
+ metadata = self.booklist_class(oncard, prefix, self.settings)
+
def update_booklist(filename, path, prefix):
changed = False
if path_to_ext(filename) in self.FORMATS:
@@ -86,7 +88,8 @@ class USBMS(CLI, Device):
else:
item = self.book_from_path(prefix, lpath)
changed = True
- metadata.append(item)
+ if metadata.add_book(item, replace_metadata=False):
+ changed = True
except: # Probably a filename encoding error
import traceback
traceback.print_exc()
@@ -183,10 +186,7 @@ class USBMS(CLI, Device):
if book.size is None:
book.size = os.stat(path).st_size
- opts = self.settings()
- collections = opts.extra_customization.split(',') if opts.extra_customization else []
- booklists[blist].add_book(book, collections, *location[1:-1])
-
+ booklists[blist].add_book(book, replace_metadata=True)
self.report_progress(1.0, _('Adding books to device metadata listing...'))
def delete_books(self, paths, end_session=True):
@@ -237,7 +237,8 @@ class USBMS(CLI, Device):
self.report_progress(1.0, _('Sending metadata to device...'))
@classmethod
- def parse_metadata_cache(cls, prefix, name, bl):
+ def parse_metadata_cache(cls, prefix, name):
+ bl = []
js = []
need_sync = False
try:
diff --git a/src/calibre/gui2/library.py b/src/calibre/gui2/library.py
index e116e39397..eeda687312 100644
--- a/src/calibre/gui2/library.py
+++ b/src/calibre/gui2/library.py
@@ -371,7 +371,7 @@ class BooksModel(QAbstractTableModel):
def set_device_connected(self, is_connected):
self.device_connected = is_connected
self.read_config()
- self.refresh(reset=True)
+ self.db.refresh_ondevice()
self.database_changed.emit(self.db)
def set_book_on_device_func(self, func):
diff --git a/src/calibre/gui2/ui.py b/src/calibre/gui2/ui.py
index 48e22f8903..ba6bac76e4 100644
--- a/src/calibre/gui2/ui.py
+++ b/src/calibre/gui2/ui.py
@@ -947,7 +947,7 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
self.device_manager.device)
self.location_view.model().device_connected(self.device_manager.device)
self.eject_action.setEnabled(True)
- self.refresh_ondevice_info (device_connected = True)
+ # don't refresh_ondevice here. It will happen in metadata_downloaded
else:
self.save_device_view_settings()
self.device_connected = False
diff --git a/src/calibre/library/caches.py b/src/calibre/library/caches.py
index 9ed150733a..acc8eaffb6 100644
--- a/src/calibre/library/caches.py
+++ b/src/calibre/library/caches.py
@@ -547,6 +547,12 @@ class ResultCache(SearchQueryParser):
def count(self):
return len(self._map)
+ def refresh_ondevice(self, db):
+ ondevice_col = self.FIELD_MAP['ondevice']
+ for item in self._data:
+ if item is not None:
+ item[ondevice_col] = db.book_on_device_string(item[0])
+
def refresh(self, db, field=None, ascending=True):
temp = db.conn.get('SELECT * FROM meta2')
self._data = list(itertools.repeat(None, temp[-1][0]+2)) if temp else []
diff --git a/src/calibre/library/database2.py b/src/calibre/library/database2.py
index b0f2d3cb39..5971333078 100644
--- a/src/calibre/library/database2.py
+++ b/src/calibre/library/database2.py
@@ -245,6 +245,8 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
self.has_id = self.data.has_id
self.count = self.data.count
+ self.refresh_ondevice = functools.partial(self.data.refresh_ondevice, self)
+
self.refresh()
self.last_update_check = self.last_modified()
From 1a42f0aae76b553395eb4d9b1cf9abf00bcb07e2 Mon Sep 17 00:00:00 2001
From: Charles Haley <>
Date: Sat, 15 May 2010 20:08:18 +0100
Subject: [PATCH 087/324] First iteration of folder_device.
---
src/calibre/devices/folder_device/__init__.py | 10 +++
src/calibre/devices/folder_device/driver.py | 74 +++++++++++++++++++
src/calibre/devices/htc_td2/driver.py | 3 +-
src/calibre/gui2/device.py | 35 +++++++++
src/calibre/gui2/ui.py | 14 ++++
5 files changed, 135 insertions(+), 1 deletion(-)
create mode 100644 src/calibre/devices/folder_device/__init__.py
create mode 100644 src/calibre/devices/folder_device/driver.py
diff --git a/src/calibre/devices/folder_device/__init__.py b/src/calibre/devices/folder_device/__init__.py
new file mode 100644
index 0000000000..3d1a86922e
--- /dev/null
+++ b/src/calibre/devices/folder_device/__init__.py
@@ -0,0 +1,10 @@
+#!/usr/bin/env python
+# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai
+from __future__ import with_statement
+
+__license__ = 'GPL v3'
+__copyright__ = '2009, Kovid Goyal '
+__docformat__ = 'restructuredtext en'
+
+
+
diff --git a/src/calibre/devices/folder_device/driver.py b/src/calibre/devices/folder_device/driver.py
new file mode 100644
index 0000000000..700b7f3eec
--- /dev/null
+++ b/src/calibre/devices/folder_device/driver.py
@@ -0,0 +1,74 @@
+'''
+Created on 15 May 2010
+
+@author: charles
+'''
+import os
+import time
+
+from calibre.customize.ui import available_output_formats
+from calibre.devices.usbms.driver import USBMS, BookList
+from calibre.devices.interface import DevicePlugin
+from calibre.devices.usbms.deviceconfig import DeviceConfig
+from calibre.utils.filenames import ascii_filename as sanitize, shorten_components_to
+
+class FOLDER_DEVICE(USBMS):
+ type = _('Device Interface')
+
+ # Ordered list of supported formats
+ FORMATS = ['epub', 'fb2', 'mobi', 'lrf', 'tcr', 'pmlz', 'lit', 'rtf', 'rb', 'pdf', 'oeb', 'txt', 'pdb']
+
+ THUMBNAIL_HEIGHT = 68 # Height for thumbnails on device
+ # Whether the metadata on books can be set via the GUI.
+ CAN_SET_METADATA = True
+ SUPPORTS_SUB_DIRS = True
+ DELETE_EXTS = []
+ #: Path separator for paths to books on device
+ path_sep = os.sep
+ #: Icon for this device
+ icon = I('reader.svg')
+ METADATA_CACHE = '.metadata.calibre'
+
+ _main_prefix = None
+ _card_a_prefix = None
+ _card_b_prefix = None
+
+ def __init__(self, path):
+ self._main_prefix = path
+ self.booklist_class = BookList
+ self.is_connected = True
+
+ @classmethod
+ def get_gui_name(cls):
+ if hasattr(cls, 'gui_name'):
+ return cls.gui_name
+ if hasattr(cls, '__name__'):
+ return cls.__name__
+ return cls.name
+
+ def disconnect_from_folder(self):
+ self.is_connected = False
+
+ def is_usb_connected(self, devices_on_system, debug=False,
+ only_presence=False):
+ return self.is_connected, self
+
+ def open(self):
+ if self._main_prefix is None:
+ raise NotImplementedError()
+ return True
+
+ def set_progress_reporter(self, report_progress):
+ self.report_progress = report_progress
+
+ def card_prefix(self, end_session=True):
+ return (None, None)
+
+ def total_space(self, end_session=True):
+ return (1024*1024*1024, 0, 0)
+
+ def free_space(self, end_session=True):
+ return (1024*1024*1024, 0, 0)
+
+ def get_main_ebook_dir(self):
+ return ''
diff --git a/src/calibre/devices/htc_td2/driver.py b/src/calibre/devices/htc_td2/driver.py
index 9a83e32961..41eccfa0b2 100644
--- a/src/calibre/devices/htc_td2/driver.py
+++ b/src/calibre/devices/htc_td2/driver.py
@@ -19,7 +19,8 @@ class HTC_TD2(USBMS):
VENDOR_ID = {
# HTC
- 0x0bb4 : { 0x0c30 : [0x000]},
+# 0x0bb4 : { 0x0c30 : [0x000]},
+ 0xFbb4 : { 0x0c30 : [0x000]},
}
EBOOK_DIR_MAIN = ['EBooks']
EXTRA_CUSTOMIZATION_MESSAGE = _('Comma separated list of directories to '
diff --git a/src/calibre/gui2/device.py b/src/calibre/gui2/device.py
index af314c5468..048e5b0ccb 100644
--- a/src/calibre/gui2/device.py
+++ b/src/calibre/gui2/device.py
@@ -25,6 +25,7 @@ from calibre.utils.filenames import ascii_filename
from calibre.devices.errors import FreeSpaceError
from calibre.utils.smtp import compose_mail, sendmail, extract_email_address, \
config as email_config
+from calibre.devices.folder_device.driver import FOLDER_DEVICE
class DeviceJob(BaseJob):
@@ -207,6 +208,27 @@ class DeviceManager(Thread):
return self.create_job(self._get_device_information, done,
description=_('Get device information'))
+ def connect_to_folder(self, path):
+ dev = FOLDER_DEVICE(path)
+ try:
+ dev.open()
+ except:
+ print 'Unable to open device', dev
+ traceback.print_exc()
+ return False
+ self.connected_device = dev
+ self.connected_slot(True)
+ return True
+
+ def disconnect_folder(self):
+ if self.connected_device is not None:
+ if hasattr(self.connected_device, 'disconnect_from_folder'):
+ self.connected_device.disconnect_from_folder()
+
+# def connect_to_folder(self, path):
+# return self.create_job(self._connect_to_folder, None,
+# description=_('Connect to folder'))
+
def _books(self):
'''Get metadata from device'''
mainlist = self.device.books(oncard=None, end_session=False)
@@ -309,6 +331,8 @@ class DeviceAction(QAction):
class DeviceMenu(QMenu):
fetch_annotations = pyqtSignal()
+ connect_to_folder = pyqtSignal()
+ disconnect_from_folder = pyqtSignal()
def __init__(self, parent=None):
QMenu.__init__(self, parent)
@@ -410,6 +434,17 @@ class DeviceMenu(QMenu):
annot.triggered.connect(lambda x :
self.fetch_annotations.emit())
self.annotation_action = annot
+
+ mitem = self.addAction(_('Connect to folder (experimental)'))
+ mitem.setEnabled(True)
+ mitem.triggered.connect(lambda x : self.connect_to_folder.emit())
+ self.connect_to_folder_action = mitem
+
+ mitem = self.addAction(_('Disconnect from folder (experimental)'))
+ mitem.setEnabled(False)
+ mitem.triggered.connect(lambda x : self.disconnect_from_folder.emit())
+ self.disconnect_from_folder_action = mitem
+
self.enable_device_actions(False)
def change_default_action(self, action):
diff --git a/src/calibre/gui2/ui.py b/src/calibre/gui2/ui.py
index ba6bac76e4..8cd89bd397 100644
--- a/src/calibre/gui2/ui.py
+++ b/src/calibre/gui2/ui.py
@@ -666,6 +666,18 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
MainWindow.resizeEvent(self, ev)
self.search.setMaximumWidth(self.width()-150)
+ def connect_to_folder(self):
+ dir = choose_dir(self, 'Select Device Folder', 'Select folder to open')
+ if dir is not None:
+ print dir
+ self.device_manager.connect_to_folder(dir)
+ self._sync_menu.connect_to_folder_action.setEnabled(False)
+ self._sync_menu.disconnect_from_folder_action.setEnabled(True)
+
+ def disconnect_from_folder(self):
+ self.device_manager.disconnect_folder()
+ self._sync_menu.connect_to_folder_action.setEnabled(True)
+ self._sync_menu.disconnect_from_folder_action.setEnabled(False)
def create_device_menu(self):
self._sync_menu = DeviceMenu(self)
@@ -676,6 +688,8 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
self.connect(self.action_sync, SIGNAL('triggered(bool)'),
self._sync_menu.trigger_default)
self._sync_menu.fetch_annotations.connect(self.fetch_annotations)
+ self._sync_menu.connect_to_folder.connect(self.connect_to_folder)
+ self._sync_menu.disconnect_from_folder.connect(self.disconnect_from_folder)
def add_spare_server(self, *args):
self.spare_servers.append(Server(limit=int(config['worker_limit']/2.0)))
From 583f9c1197491c325972fc79b1cb0601cd25e2e2 Mon Sep 17 00:00:00 2001
From: Charles Haley <>
Date: Sat, 15 May 2010 20:35:15 +0100
Subject: [PATCH 088/324] Normalize paths for folder_device.
---
src/calibre/devices/usbms/driver.py | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/src/calibre/devices/usbms/driver.py b/src/calibre/devices/usbms/driver.py
index 95b7441f44..c6320f2746 100644
--- a/src/calibre/devices/usbms/driver.py
+++ b/src/calibre/devices/usbms/driver.py
@@ -174,11 +174,19 @@ class USBMS(CLI, Device):
blist = 2 if location[1] == 'cardb' else 1 if location[1] == 'carda' else 0
if self._main_prefix:
+ # Normalize path and prefix
+ if self._main_prefix.find('\\') >= 0:
+ path = path.replace('/', '\\')
+ else:
+ path = path.replace('\\', '/')
prefix = self._main_prefix if path.startswith(self._main_prefix) else None
if not prefix and self._card_a_prefix:
prefix = self._card_a_prefix if path.startswith(self._card_a_prefix) else None
if not prefix and self._card_b_prefix:
prefix = self._card_b_prefix if path.startswith(self._card_b_prefix) else None
+ if prefix is None:
+ print 'in add_books_to_metadata. Prefix is None!', path, self._main_prefix
+ continue
lpath = path.partition(prefix)[2]
if lpath.startswith(os.sep):
lpath = lpath[len(os.sep):]
From 3cfb28f0fff303b7f44aeacbce6d12343049b332 Mon Sep 17 00:00:00 2001
From: Charles Haley <>
Date: Sat, 15 May 2010 21:06:11 +0100
Subject: [PATCH 089/324] Regenerate sony_id_cache in fix_ids, because it
changes all the values.
---
src/calibre/devices/prs505/books.py | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/src/calibre/devices/prs505/books.py b/src/calibre/devices/prs505/books.py
index 40a98913be..20fed3e2ed 100644
--- a/src/calibre/devices/prs505/books.py
+++ b/src/calibre/devices/prs505/books.py
@@ -357,6 +357,11 @@ def fix_ids(main, carda, cardb):
item.parentNode.removeChild(item)
item.unlink()
db.reorder_playlists()
+ db.sony_id_cache = {}
+ for child in db.root_element.childNodes:
+ if child.nodeType == child.ELEMENT_NODE and child.hasAttribute("id"):
+ db.sony_id_cache[child.getAttribute('id')] = child.getAttribute('path')
+
regen_ids(main)
regen_ids(carda)
From 0ce1a052b29417ba728b63a2f4e791361b84298b Mon Sep 17 00:00:00 2001
From: Charles Haley <>
Date: Sat, 15 May 2010 21:18:38 +0100
Subject: [PATCH 090/324] 1) don't try to sync if device is no longer connected
2) disable folder_device when another device is connected
---
src/calibre/gui2/device.py | 3 ++-
src/calibre/gui2/ui.py | 4 ++++
2 files changed, 6 insertions(+), 1 deletion(-)
diff --git a/src/calibre/gui2/device.py b/src/calibre/gui2/device.py
index 048e5b0ccb..1703e4a644 100644
--- a/src/calibre/gui2/device.py
+++ b/src/calibre/gui2/device.py
@@ -1135,4 +1135,5 @@ class DeviceGUI(object):
resend_metadata = True
if resend_metadata:
# Correcting metadata cache on device.
- self.device_manager.sync_booklists(None, booklists)
+ if self.device_manager.is_connected:
+ self.device_manager.sync_booklists(None, booklists)
diff --git a/src/calibre/gui2/ui.py b/src/calibre/gui2/ui.py
index 8cd89bd397..725672324c 100644
--- a/src/calibre/gui2/ui.py
+++ b/src/calibre/gui2/ui.py
@@ -948,6 +948,8 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
Called when a device is connected to the computer.
'''
if connected:
+ self._sync_menu.connect_to_folder_action.setEnabled(False)
+ self._sync_menu.disconnect_from_folder_action.setEnabled(False)
self.device_manager.get_device_information(\
Dispatcher(self.info_read))
self.set_default_thumbnail(\
@@ -963,6 +965,8 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
self.eject_action.setEnabled(True)
# don't refresh_ondevice here. It will happen in metadata_downloaded
else:
+ self._sync_menu.connect_to_folder_action.setEnabled(True)
+ self._sync_menu.disconnect_from_folder_action.setEnabled(False)
self.save_device_view_settings()
self.device_connected = False
self._sync_menu.enable_device_actions(False)
From 8b197ebd660718ac955e368662cb531f086d5e47 Mon Sep 17 00:00:00 2001
From: Charles Haley <>
Date: Sat, 15 May 2010 22:13:30 +0100
Subject: [PATCH 091/324] 1) clean up refreshing ondevice when devices are
plugged in and out. 2) close xml_file in sony BookList.init
---
src/calibre/devices/prs505/books.py | 6 +++---
src/calibre/gui2/ui.py | 11 +++++------
2 files changed, 8 insertions(+), 9 deletions(-)
diff --git a/src/calibre/devices/prs505/books.py b/src/calibre/devices/prs505/books.py
index 20fed3e2ed..61f3e3c363 100644
--- a/src/calibre/devices/prs505/books.py
+++ b/src/calibre/devices/prs505/books.py
@@ -41,9 +41,9 @@ class BookList(_BookList):
opts = settings()
self.collections = opts.extra_customization.split(',') if opts.extra_customization else []
db = CACHE_XML if oncard else MEDIA_XML
- xml_file = open(prefix + db, 'rb')
- xml_file.seek(0)
- self.document = dom.parse(xml_file)
+ with open(prefix + db, 'rb') as xml_file:
+ xml_file.seek(0)
+ self.document = dom.parse(xml_file)
self.root_element = self.document.documentElement
self.mountpath = prefix
records = self.root_element.getElementsByTagName('records')
diff --git a/src/calibre/gui2/ui.py b/src/calibre/gui2/ui.py
index 725672324c..9bb89dec68 100644
--- a/src/calibre/gui2/ui.py
+++ b/src/calibre/gui2/ui.py
@@ -949,7 +949,6 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
'''
if connected:
self._sync_menu.connect_to_folder_action.setEnabled(False)
- self._sync_menu.disconnect_from_folder_action.setEnabled(False)
self.device_manager.get_device_information(\
Dispatcher(self.info_read))
self.set_default_thumbnail(\
@@ -963,10 +962,9 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
self.device_manager.device)
self.location_view.model().device_connected(self.device_manager.device)
self.eject_action.setEnabled(True)
- # don't refresh_ondevice here. It will happen in metadata_downloaded
+ self.refresh_ondevice_info (device_connected = True, reset_only = True)
else:
self._sync_menu.connect_to_folder_action.setEnabled(True)
- self._sync_menu.disconnect_from_folder_action.setEnabled(False)
self.save_device_view_settings()
self.device_connected = False
self._sync_menu.enable_device_actions(False)
@@ -1035,10 +1033,11 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
############################################################################
### Force the library view to refresh, taking into consideration books information
- def refresh_ondevice_info(self, device_connected):
- # Save current column widths because we might be turning on OnDevice
- self.library_view.write_settings()
+ def refresh_ondevice_info(self, device_connected, reset_only = False):
self.book_on_device(None, reset=True)
+ if reset_only:
+ return
+ self.library_view.write_settings()
self.library_view.model().set_device_connected(device_connected)
############################################################################
From 70a3207906dd91e3142e697899f0718b32daabc3 Mon Sep 17 00:00:00 2001
From: Charles Haley <>
Date: Sun, 16 May 2010 09:21:41 +0100
Subject: [PATCH 092/324] 1) folder device fixes 2) added configuration of the
folder device 3) fixed set_books_in_library to save/restore search state,
necessary because it must scan the entire database, not just the search
results. 4) removed the HTC driver
---
src/calibre/customize/builtins.py | 4 +-
src/calibre/devices/folder_device/driver.py | 51 +++++++++++++--------
src/calibre/devices/htc_td2/__init__.py | 10 ----
src/calibre/devices/htc_td2/driver.py | 45 ------------------
src/calibre/devices/usbms/device.py | 6 ++-
src/calibre/gui2/device.py | 49 ++++++++++++--------
src/calibre/gui2/ui.py | 5 +-
src/calibre/library/caches.py | 8 ++++
src/calibre/library/database2.py | 2 +
9 files changed, 78 insertions(+), 102 deletions(-)
delete mode 100644 src/calibre/devices/htc_td2/__init__.py
delete mode 100644 src/calibre/devices/htc_td2/driver.py
diff --git a/src/calibre/customize/builtins.py b/src/calibre/customize/builtins.py
index 1ad6c03fc2..6865954440 100644
--- a/src/calibre/customize/builtins.py
+++ b/src/calibre/customize/builtins.py
@@ -455,7 +455,7 @@ from calibre.devices.edge.driver import EDGE
from calibre.devices.teclast.driver import TECLAST_K3
from calibre.devices.sne.driver import SNE
from calibre.devices.misc import PALMPRE, KOBO, AVANT
-from calibre.devices.htc_td2.driver import HTC_TD2
+from calibre.devices.folder_device.driver import FOLDER_DEVICE_FOR_CONFIG
from calibre.ebooks.metadata.fetch import GoogleBooks, ISBNDB, Amazon
from calibre.library.catalog import CSV_XML, EPUB_MOBI
@@ -540,7 +540,7 @@ plugins += [
PALMPRE,
KOBO,
AZBOOKA,
- HTC_TD2,
+ FOLDER_DEVICE_FOR_CONFIG,
AVANT,
]
plugins += [x for x in list(locals().values()) if isinstance(x, type) and \
diff --git a/src/calibre/devices/folder_device/driver.py b/src/calibre/devices/folder_device/driver.py
index 700b7f3eec..31da69d49a 100644
--- a/src/calibre/devices/folder_device/driver.py
+++ b/src/calibre/devices/folder_device/driver.py
@@ -4,36 +4,48 @@ Created on 15 May 2010
@author: charles
'''
import os
-import time
-from calibre.customize.ui import available_output_formats
from calibre.devices.usbms.driver import USBMS, BookList
-from calibre.devices.interface import DevicePlugin
-from calibre.devices.usbms.deviceconfig import DeviceConfig
-from calibre.utils.filenames import ascii_filename as sanitize, shorten_components_to
+
+# This class is added to the standard device plugin chain, so that it can
+# be configured. It has invalid vendor_id etc, so it will never match a
+# device. The 'real' FOLDER_DEVICE will use the config from it.
+class FOLDER_DEVICE_FOR_CONFIG(USBMS):
+ name = 'Folder Device Interface'
+ gui_name = 'Folder Device'
+ description = _('Use an arbitrary folder as a device.')
+ author = 'John Schember/Charles Haley'
+ supported_platforms = ['windows', 'osx', 'linux']
+ FORMATS = ['epub', 'fb2', 'mobi', 'lrf', 'tcr', 'pmlz', 'lit', 'rtf', 'rb', 'pdf', 'oeb', 'txt', 'pdb']
class FOLDER_DEVICE(USBMS):
type = _('Device Interface')
- # Ordered list of supported formats
+ name = 'Folder Device Interface'
+ gui_name = 'Folder Device'
+ description = _('Use an arbitrary folder as a device.')
+ author = 'John Schember/Charles Haley'
+ supported_platforms = ['windows', 'osx', 'linux']
FORMATS = ['epub', 'fb2', 'mobi', 'lrf', 'tcr', 'pmlz', 'lit', 'rtf', 'rb', 'pdf', 'oeb', 'txt', 'pdb']
THUMBNAIL_HEIGHT = 68 # Height for thumbnails on device
- # Whether the metadata on books can be set via the GUI.
+
CAN_SET_METADATA = True
SUPPORTS_SUB_DIRS = True
- DELETE_EXTS = []
- #: Path separator for paths to books on device
- path_sep = os.sep
+
#: Icon for this device
- icon = I('reader.svg')
+ icon = I('sd.svg')
METADATA_CACHE = '.metadata.calibre'
- _main_prefix = None
+ _main_prefix = ''
_card_a_prefix = None
_card_b_prefix = None
+ is_connected = False
+
def __init__(self, path):
+ if not os.path.isdir(path):
+ raise IOError, 'Path is not a folder'
self._main_prefix = path
self.booklist_class = BookList
self.is_connected = True
@@ -47,6 +59,7 @@ class FOLDER_DEVICE(USBMS):
return cls.name
def disconnect_from_folder(self):
+ self._main_prefix = ''
self.is_connected = False
def is_usb_connected(self, devices_on_system, debug=False,
@@ -54,8 +67,8 @@ class FOLDER_DEVICE(USBMS):
return self.is_connected, self
def open(self):
- if self._main_prefix is None:
- raise NotImplementedError()
+ if not self._main_prefix:
+ return False
return True
def set_progress_reporter(self, report_progress):
@@ -64,11 +77,9 @@ class FOLDER_DEVICE(USBMS):
def card_prefix(self, end_session=True):
return (None, None)
- def total_space(self, end_session=True):
- return (1024*1024*1024, 0, 0)
-
- def free_space(self, end_session=True):
- return (1024*1024*1024, 0, 0)
-
def get_main_ebook_dir(self):
return ''
+
+ @classmethod
+ def settings(self):
+ return FOLDER_DEVICE_FOR_CONFIG._config().parse()
diff --git a/src/calibre/devices/htc_td2/__init__.py b/src/calibre/devices/htc_td2/__init__.py
deleted file mode 100644
index 3d1a86922e..0000000000
--- a/src/calibre/devices/htc_td2/__init__.py
+++ /dev/null
@@ -1,10 +0,0 @@
-#!/usr/bin/env python
-# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai
-from __future__ import with_statement
-
-__license__ = 'GPL v3'
-__copyright__ = '2009, Kovid Goyal '
-__docformat__ = 'restructuredtext en'
-
-
-
diff --git a/src/calibre/devices/htc_td2/driver.py b/src/calibre/devices/htc_td2/driver.py
deleted file mode 100644
index 41eccfa0b2..0000000000
--- a/src/calibre/devices/htc_td2/driver.py
+++ /dev/null
@@ -1,45 +0,0 @@
-# -*- coding: utf-8 -*-
-
-__license__ = 'GPL v3'
-__copyright__ = '2009, Kovid Goyal '
-__docformat__ = 'restructuredtext en'
-
-from calibre.devices.usbms.driver import USBMS
-
-class HTC_TD2(USBMS):
-
- name = 'HTC TD2 Phone driver'
- gui_name = 'HTC TD2'
- description = _('Communicate with HTC TD2 phones.')
- author = 'Charles Haley'
- supported_platforms = ['osx', 'linux']
-
- # Ordered list of supported formats
- FORMATS = ['epub', 'pdf']
-
- VENDOR_ID = {
- # HTC
-# 0x0bb4 : { 0x0c30 : [0x000]},
- 0xFbb4 : { 0x0c30 : [0x000]},
- }
- EBOOK_DIR_MAIN = ['EBooks']
- EXTRA_CUSTOMIZATION_MESSAGE = _('Comma separated list of directories to '
- 'send e-books to on the device. The first one that exists will '
- 'be used')
- EXTRA_CUSTOMIZATION_DEFAULT = ', '.join(EBOOK_DIR_MAIN)
-
- VENDOR_NAME = ['']
- WINDOWS_MAIN_MEM = ['']
-
- MAIN_MEMORY_VOLUME_LABEL = 'HTC Phone Internal Memory'
-
- SUPPORTS_SUB_DIRS = True
-
- def post_open_callback(self):
- opts = self.settings()
- dirs = opts.extra_customization
- if not dirs:
- dirs = self.EBOOK_DIR_MAIN
- else:
- dirs = [x.strip() for x in dirs.split(',')]
- self.EBOOK_DIR_MAIN = dirs
diff --git a/src/calibre/devices/usbms/device.py b/src/calibre/devices/usbms/device.py
index 1b048d1bb6..249733b4e3 100644
--- a/src/calibre/devices/usbms/device.py
+++ b/src/calibre/devices/usbms/device.py
@@ -113,15 +113,17 @@ class Device(DeviceConfig, DevicePlugin):
def _windows_space(cls, prefix):
if not prefix:
return 0, 0
+ if prefix.endswith(os.sep):
+ prefix = prefix[:-1]
win32file = __import__('win32file', globals(), locals(), [], -1)
try:
sectors_per_cluster, bytes_per_sector, free_clusters, total_clusters = \
- win32file.GetDiskFreeSpace(prefix[:-1])
+ win32file.GetDiskFreeSpace(prefix)
except Exception, err:
if getattr(err, 'args', [None])[0] == 21: # Disk not ready
time.sleep(3)
sectors_per_cluster, bytes_per_sector, free_clusters, total_clusters = \
- win32file.GetDiskFreeSpace(prefix[:-1])
+ win32file.GetDiskFreeSpace(prefix)
else: raise
mult = sectors_per_cluster * bytes_per_sector
return total_clusters * mult, free_clusters * mult
diff --git a/src/calibre/gui2/device.py b/src/calibre/gui2/device.py
index 1703e4a644..d6f1a7a205 100644
--- a/src/calibre/gui2/device.py
+++ b/src/calibre/gui2/device.py
@@ -428,23 +428,24 @@ class DeviceMenu(QMenu):
if opts.accounts:
self.addSeparator()
self.addMenu(self.email_to_menu)
+
+ self.addSeparator()
+ mitem = self.addAction(_('Connect to folder'))
+ mitem.setEnabled(True)
+ mitem.triggered.connect(lambda x : self.connect_to_folder.emit())
+ self.connect_to_folder_action = mitem
+
+ mitem = self.addAction(_('Disconnect from folder'))
+ mitem.setEnabled(False)
+ mitem.triggered.connect(lambda x : self.disconnect_from_folder.emit())
+ self.disconnect_from_folder_action = mitem
+
self.addSeparator()
annot = self.addAction(_('Fetch annotations (experimental)'))
annot.setEnabled(False)
annot.triggered.connect(lambda x :
self.fetch_annotations.emit())
self.annotation_action = annot
-
- mitem = self.addAction(_('Connect to folder (experimental)'))
- mitem.setEnabled(True)
- mitem.triggered.connect(lambda x : self.connect_to_folder.emit())
- self.connect_to_folder_action = mitem
-
- mitem = self.addAction(_('Disconnect from folder (experimental)'))
- mitem.setEnabled(False)
- mitem.triggered.connect(lambda x : self.disconnect_from_folder.emit())
- self.disconnect_from_folder_action = mitem
-
self.enable_device_actions(False)
def change_default_action(self, action):
@@ -1089,8 +1090,17 @@ class DeviceGUI(object):
# First build a cache of the library, so the search isn't On**2
self.db_book_title_cache = {}
self.db_book_uuid_cache = set()
- for idx in range(self.library_view.model().db.count()):
- mi = self.library_view.model().db.get_metadata(idx, index_is_id=False)
+ db = self.library_view.model().db
+ # The following is a terrible hack, made necessary because the db
+ # result_cache will always use the results filtered by the current
+ # search. We need all the db entries here. Choice was to either
+ # cache the search results so we can use the entire db, to duplicate
+ # large parts of the get_metadata code, or to use db_ids and pay the
+ # large performance penalty of zillions of SQL queries. Choice:
+ # save/restore the search state.
+ state = db.get_state_before_scan()
+ for idx in range(db.count()):
+ mi = db.get_metadata(idx, index_is_id=False)
title = re.sub('(?u)\W|[_]', '', mi.title.lower())
if title not in self.db_book_title_cache:
self.db_book_title_cache[title] = {'authors':{}, 'db_ids':{}}
@@ -1099,12 +1109,13 @@ class DeviceGUI(object):
self.db_book_title_cache[title]['authors'][authors] = mi
self.db_book_title_cache[title]['db_ids'][mi.application_id] = mi
self.db_book_uuid_cache.add(mi.uuid)
+ db.restore_state_after_scan(state)
- # Now iterate through all the books on the device, setting the in_library field
- # Fastest and most accurate key is the uuid. Second is the application_id, which
- # is really the db key, but as this can accidentally match across libraries we
- # also verify the title. The db_id exists on Sony devices. Fallback is title
- # and author match
+ # Now iterate through all the books on the device, setting the
+ # in_library field Fastest and most accurate key is the uuid. Second is
+ # the application_id, which is really the db key, but as this can
+ # accidentally match across libraries we also verify the title. The
+ # db_id exists on Sony devices. Fallback is title and author match
resend_metadata = False
for booklist in booklists:
for book in booklist:
@@ -1135,5 +1146,5 @@ class DeviceGUI(object):
resend_metadata = True
if resend_metadata:
# Correcting metadata cache on device.
- if self.device_manager.is_connected:
+ if self.device_manager.is_device_connected:
self.device_manager.sync_booklists(None, booklists)
diff --git a/src/calibre/gui2/ui.py b/src/calibre/gui2/ui.py
index 9bb89dec68..23a0490f14 100644
--- a/src/calibre/gui2/ui.py
+++ b/src/calibre/gui2/ui.py
@@ -669,15 +669,11 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
def connect_to_folder(self):
dir = choose_dir(self, 'Select Device Folder', 'Select folder to open')
if dir is not None:
- print dir
self.device_manager.connect_to_folder(dir)
- self._sync_menu.connect_to_folder_action.setEnabled(False)
self._sync_menu.disconnect_from_folder_action.setEnabled(True)
def disconnect_from_folder(self):
self.device_manager.disconnect_folder()
- self._sync_menu.connect_to_folder_action.setEnabled(True)
- self._sync_menu.disconnect_from_folder_action.setEnabled(False)
def create_device_menu(self):
self._sync_menu = DeviceMenu(self)
@@ -965,6 +961,7 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
self.refresh_ondevice_info (device_connected = True, reset_only = True)
else:
self._sync_menu.connect_to_folder_action.setEnabled(True)
+ self._sync_menu.disconnect_from_folder_action.setEnabled(False)
self.save_device_view_settings()
self.device_connected = False
self._sync_menu.enable_device_actions(False)
diff --git a/src/calibre/library/caches.py b/src/calibre/library/caches.py
index acc8eaffb6..73faa6f1ab 100644
--- a/src/calibre/library/caches.py
+++ b/src/calibre/library/caches.py
@@ -553,6 +553,14 @@ class ResultCache(SearchQueryParser):
if item is not None:
item[ondevice_col] = db.book_on_device_string(item[0])
+ def get_state_before_scan(self):
+ retval = self._map_filtered
+ self._map_filtered = self._map
+ return retval
+
+ def restore_state_after_scan(self, map_filtered):
+ self._map_filtered = map_filtered
+
def refresh(self, db, field=None, ascending=True):
temp = db.conn.get('SELECT * FROM meta2')
self._data = list(itertools.repeat(None, temp[-1][0]+2)) if temp else []
diff --git a/src/calibre/library/database2.py b/src/calibre/library/database2.py
index 5971333078..063538656f 100644
--- a/src/calibre/library/database2.py
+++ b/src/calibre/library/database2.py
@@ -245,6 +245,8 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
self.has_id = self.data.has_id
self.count = self.data.count
+ self.get_state_before_scan = self.data.get_state_before_scan
+ self.restore_state_after_scan = self.data.restore_state_after_scan
self.refresh_ondevice = functools.partial(self.data.refresh_ondevice, self)
self.refresh()
From 7fe122be682a807c12fd095ada695e1e3812f066 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Sun, 16 May 2010 09:27:26 -0600
Subject: [PATCH 093/324] Add iterall method to iterate over entire ResultCache
---
src/calibre/library/caches.py | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/src/calibre/library/caches.py b/src/calibre/library/caches.py
index 9ed150733a..3877314da9 100644
--- a/src/calibre/library/caches.py
+++ b/src/calibre/library/caches.py
@@ -171,6 +171,11 @@ class ResultCache(SearchQueryParser):
for id in self._map_filtered:
yield self._data[id]
+ def iterall(self):
+ for x in self._data:
+ if x is not None:
+ yield x
+
def universal_set(self):
return set([i[0] for i in self._data if i is not None])
From 266d7c02bef5fcff4b9c8d54ea95ae5d3cdf0162 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Sun, 16 May 2010 09:54:51 -0600
Subject: [PATCH 094/324] Add iterallids method to ResultCache
---
src/calibre/library/caches.py | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/src/calibre/library/caches.py b/src/calibre/library/caches.py
index 3877314da9..68ed4cc092 100644
--- a/src/calibre/library/caches.py
+++ b/src/calibre/library/caches.py
@@ -176,6 +176,11 @@ class ResultCache(SearchQueryParser):
if x is not None:
yield x
+ def iterallids(self):
+ idx = self.FIELD_MAP['id']
+ for x in self.iterall():
+ yield x[idx]
+
def universal_set(self):
return set([i[0] for i in self._data if i is not None])
From a522f76a2154c71a898ac5ffb04f5041f5c2ce74 Mon Sep 17 00:00:00 2001
From: Charles Haley <>
Date: Sun, 16 May 2010 18:05:45 +0100
Subject: [PATCH 095/324] Commit for starson17 testing
---
src/calibre/devices/usbms/driver.py | 46 ++++++++++++++++++-----------
src/calibre/gui2/device.py | 4 +++
src/calibre/gui2/library.py | 9 +++++-
3 files changed, 40 insertions(+), 19 deletions(-)
diff --git a/src/calibre/devices/usbms/driver.py b/src/calibre/devices/usbms/driver.py
index c6320f2746..7a46ef3dc7 100644
--- a/src/calibre/devices/usbms/driver.py
+++ b/src/calibre/devices/usbms/driver.py
@@ -78,7 +78,7 @@ class USBMS(CLI, Device):
changed = False
if path_to_ext(filename) in self.FORMATS:
try:
- lpath = os.path.join(path, filename).partition(prefix)[2]
+ lpath = os.path.join(path, filename).partition(self.normalize_path(prefix))[2]
if lpath.startswith(os.sep):
lpath = lpath[len(os.sep):]
idx = bl_cache.get(lpath.replace('\\', '/'), None)
@@ -98,7 +98,9 @@ class USBMS(CLI, Device):
if isinstance(ebook_dirs, basestring):
ebook_dirs = [ebook_dirs]
for ebook_dir in ebook_dirs:
- ebook_dir = os.path.join(prefix, *(ebook_dir.split('/'))) if ebook_dir else prefix
+ ebook_dir = self.normalize_path( \
+ os.path.join(prefix, *(ebook_dir.split('/'))) \
+ if ebook_dir else prefix)
if not os.path.exists(ebook_dir): continue
# Get all books in the ebook_dir directory
if self.SUPPORTS_SUB_DIRS:
@@ -119,6 +121,7 @@ class USBMS(CLI, Device):
# if count != len(bl) then there were items in it that we did not
# find on the device. If need_sync is True then there were either items
# on the device that were not in bl or some of the items were changed.
+ print "count found in cache: %d, count of files in cache: %d, must_sync_cache: %s" % (self.count_found_in_bl, len(bl), need_sync)
if self.count_found_in_bl != len(bl) or need_sync:
if oncard == 'cardb':
self.sync_booklists((None, None, metadata))
@@ -140,13 +143,12 @@ class USBMS(CLI, Device):
for i, infile in enumerate(files):
mdata, fname = metadata.next(), names.next()
- filepath = self.create_upload_path(path, mdata, fname)
-
+ filepath = self.normalize_path(self.create_upload_path(path, mdata, fname))
paths.append(filepath)
-
- self.put_file(infile, filepath, replace_file=True)
+ self.put_file(self.normalize_path(infile), filepath, replace_file=True)
try:
- self.upload_cover(os.path.dirname(filepath), os.path.splitext(os.path.basename(filepath))[0], mdata)
+ self.upload_cover(os.path.dirname(filepath),
+ os.path.splitext(os.path.basename(filepath))[0], mdata)
except: # Failure to upload cover is not catastrophic
import traceback
traceback.print_exc()
@@ -192,14 +194,14 @@ class USBMS(CLI, Device):
lpath = lpath[len(os.sep):]
book = self.book_class(prefix, lpath, other=info)
if book.size is None:
- book.size = os.stat(path).st_size
-
+ book.size = os.stat(self.normalize_path(path)).st_size
booklists[blist].add_book(book, replace_metadata=True)
self.report_progress(1.0, _('Adding books to device metadata listing...'))
def delete_books(self, paths, end_session=True):
for i, path in enumerate(paths):
self.report_progress((i+1) / float(len(paths)), _('Removing books from device...'))
+ path = self.normalize_path(path)
if os.path.exists(path):
# Delete the ebook
os.unlink(path)
@@ -228,15 +230,15 @@ class USBMS(CLI, Device):
self.report_progress(1.0, _('Removing books from device metadata listing...'))
def sync_booklists(self, booklists, end_session=True):
- if not os.path.exists(self._main_prefix):
- os.makedirs(self._main_prefix)
+ if not os.path.exists(self.normalize_path(self._main_prefix)):
+ os.makedirs(self.normalize_path(self._main_prefix))
def write_prefix(prefix, listid):
if prefix is not None and isinstance(booklists[listid], self.booklist_class):
if not os.path.exists(prefix):
- os.makedirs(prefix)
+ os.makedirs(self.normalize_path(prefix))
js = [item.to_json() for item in booklists[listid]]
- with open(os.path.join(prefix, self.METADATA_CACHE), 'wb') as f:
+ with open(self.normalize_path(os.path.join(prefix, self.METADATA_CACHE)), 'wb') as f:
json.dump(js, f, indent=2, encoding='utf-8')
write_prefix(self._main_prefix, 0)
write_prefix(self._card_a_prefix, 1)
@@ -244,13 +246,21 @@ class USBMS(CLI, Device):
self.report_progress(1.0, _('Sending metadata to device...'))
+ @classmethod
+ def normalize_path(cls, path):
+ if os.sep == '\\':
+ path = path.replace('/', '\\')
+ else:
+ path = path.replace('\\', '/')
+ return path
+
@classmethod
def parse_metadata_cache(cls, prefix, name):
bl = []
js = []
need_sync = False
try:
- with open(os.path.join(prefix, name), 'rb') as f:
+ with open(cls.normalize_path(os.path.join(prefix, name)), 'rb') as f:
js = json.load(f, encoding='utf-8')
for item in js:
book = cls.book_class(prefix, item.get('lpath', None))
@@ -267,7 +277,7 @@ class USBMS(CLI, Device):
@classmethod
def update_metadata_item(cls, item):
changed = False
- size = os.stat(item.path).st_size
+ size = os.stat(cls.normalize_path(item.path)).st_size
if size != item.size:
changed = True
mi = cls.metadata_from_path(item.path)
@@ -291,15 +301,15 @@ class USBMS(CLI, Device):
from calibre.ebooks.metadata import MetaInformation
if cls.settings().read_metadata or cls.MUST_READ_METADATA:
- mi = cls.metadata_from_path(os.path.join(prefix, path))
+ mi = cls.metadata_from_path(cls.normalize_path(os.path.join(prefix, path)))
else:
from calibre.ebooks.metadata.meta import metadata_from_filename
- mi = metadata_from_filename(os.path.basename(path),
+ mi = metadata_from_filename(cls.normalize_path(os.path.basename(path)),
re.compile(r'^(?P[ \S]+?)[ _]-[ _](?P[ \S]+?)_+\d+'))
if mi is None:
mi = MetaInformation(os.path.splitext(os.path.basename(path))[0],
[_('Unknown')])
- mi.size = os.stat(os.path.join(prefix, path)).st_size
+ mi.size = os.stat(cls.normalize_path(os.path.join(prefix, path))).st_size
book = cls.book_class(prefix, path, other=mi)
return book
diff --git a/src/calibre/gui2/device.py b/src/calibre/gui2/device.py
index d6f1a7a205..c5fdbec2dd 100644
--- a/src/calibre/gui2/device.py
+++ b/src/calibre/gui2/device.py
@@ -1144,6 +1144,10 @@ class DeviceGUI(object):
book.in_library = True
book.smart_update(d['authors'][book_authors])
resend_metadata = True
+ # Set author_sort if it isn't already
+ asort = getattr(book, 'author_sort', None)
+ if not asort:
+ pass
if resend_metadata:
# Correcting metadata cache on device.
if self.device_manager.is_device_connected:
diff --git a/src/calibre/gui2/library.py b/src/calibre/gui2/library.py
index eeda687312..9a9ffb5d94 100644
--- a/src/calibre/gui2/library.py
+++ b/src/calibre/gui2/library.py
@@ -1379,7 +1379,14 @@ class DeviceBooksModel(BooksModel):
x, y = self.db[x].in_library, self.db[y].in_library
return cmp(x, y)
def authorcmp(x, y):
- x, y = authors_to_string(self.db[x].authors), authors_to_string(self.db[y].authors)
+ ax = getattr(self.db[x], 'author_sort', None)
+ ay = getattr(self.db[y], 'author_sort', None)
+ if ax and ay:
+ x = ax
+ y = ay
+ else:
+ x, y = authors_to_string(self.db[x].authors), \
+ authors_to_string(self.db[y].authors)
return cmp(x, y)
fcmp = strcmp('title_sorter') if col == 0 else authorcmp if col == 1 else \
sizecmp if col == 2 else datecmp if col == 3 else tagscmp if col == 4 else libcmp
From 44ce5c5af96bb45d95c384f18f45c372f1ef75da Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Sun, 16 May 2010 11:25:08 -0600
Subject: [PATCH 096/324] Fix human readable size display to handle exabytes
---
src/calibre/gui2/__init__.py | 10 ++++------
1 file changed, 4 insertions(+), 6 deletions(-)
diff --git a/src/calibre/gui2/__init__.py b/src/calibre/gui2/__init__.py
index 5bdaeb408e..40eec6a762 100644
--- a/src/calibre/gui2/__init__.py
+++ b/src/calibre/gui2/__init__.py
@@ -232,12 +232,10 @@ def info_dialog(parent, title, msg, det_msg='', show=False):
def human_readable(size):
""" Convert a size in bytes into a human readable form """
divisor, suffix = 1, "B"
- if size < 1024*1024:
- divisor, suffix = 1024., "KB"
- elif size < 1024*1024*1024:
- divisor, suffix = 1024*1024, "MB"
- elif size < 1024*1024*1024*1024:
- divisor, suffix = 1024*1024*1024, "GB"
+ for i, candidate in enumerate(('KB', 'MB', 'GB', 'TB', 'PB', 'EB')):
+ if size < 1024**(i+2):
+ divisor, suffix = 1024**(i+1), candidate
+ break
size = str(float(size)/divisor)
if size.find(".") > -1:
size = size[:size.find(".")+2]
From 855ed5478297c42cc8a93e50e31a329d574f138d Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Sun, 16 May 2010 11:30:04 -0600
Subject: [PATCH 097/324] ...
---
src/calibre/gui2/__init__.py | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/src/calibre/gui2/__init__.py b/src/calibre/gui2/__init__.py
index 40eec6a762..9cb68ea01a 100644
--- a/src/calibre/gui2/__init__.py
+++ b/src/calibre/gui2/__init__.py
@@ -232,9 +232,9 @@ def info_dialog(parent, title, msg, det_msg='', show=False):
def human_readable(size):
""" Convert a size in bytes into a human readable form """
divisor, suffix = 1, "B"
- for i, candidate in enumerate(('KB', 'MB', 'GB', 'TB', 'PB', 'EB')):
- if size < 1024**(i+2):
- divisor, suffix = 1024**(i+1), candidate
+ for i, candidate in enumerate(('B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB')):
+ if size < 1024**(i+1):
+ divisor, suffix = 1024**(i), candidate
break
size = str(float(size)/divisor)
if size.find(".") > -1:
From 1e90881b905886ad584cca71df7454482cd7d0e7 Mon Sep 17 00:00:00 2001
From: Charles Haley <>
Date: Sun, 16 May 2010 18:56:56 +0100
Subject: [PATCH 098/324] 1) changes to ondevice scan to use Kovid's iterator
2) correction to path construction in USMBS
---
src/calibre/devices/usbms/driver.py | 4 ++--
src/calibre/gui2/device.py | 13 ++-----------
src/calibre/library/caches.py | 8 --------
src/calibre/library/database2.py | 2 --
4 files changed, 4 insertions(+), 23 deletions(-)
diff --git a/src/calibre/devices/usbms/driver.py b/src/calibre/devices/usbms/driver.py
index 7a46ef3dc7..c5b3d653c3 100644
--- a/src/calibre/devices/usbms/driver.py
+++ b/src/calibre/devices/usbms/driver.py
@@ -190,8 +190,8 @@ class USBMS(CLI, Device):
print 'in add_books_to_metadata. Prefix is None!', path, self._main_prefix
continue
lpath = path.partition(prefix)[2]
- if lpath.startswith(os.sep):
- lpath = lpath[len(os.sep):]
+ if lpath.startswith('/') or lpath.startswith('\\'):
+ lpath = lpath[1:]
book = self.book_class(prefix, lpath, other=info)
if book.size is None:
book.size = os.stat(self.normalize_path(path)).st_size
diff --git a/src/calibre/gui2/device.py b/src/calibre/gui2/device.py
index c5fdbec2dd..fa77dc862f 100644
--- a/src/calibre/gui2/device.py
+++ b/src/calibre/gui2/device.py
@@ -1091,16 +1091,8 @@ class DeviceGUI(object):
self.db_book_title_cache = {}
self.db_book_uuid_cache = set()
db = self.library_view.model().db
- # The following is a terrible hack, made necessary because the db
- # result_cache will always use the results filtered by the current
- # search. We need all the db entries here. Choice was to either
- # cache the search results so we can use the entire db, to duplicate
- # large parts of the get_metadata code, or to use db_ids and pay the
- # large performance penalty of zillions of SQL queries. Choice:
- # save/restore the search state.
- state = db.get_state_before_scan()
- for idx in range(db.count()):
- mi = db.get_metadata(idx, index_is_id=False)
+ for id in db.data.iterallids():
+ mi = db.get_metadata(id, index_is_id=True)
title = re.sub('(?u)\W|[_]', '', mi.title.lower())
if title not in self.db_book_title_cache:
self.db_book_title_cache[title] = {'authors':{}, 'db_ids':{}}
@@ -1109,7 +1101,6 @@ class DeviceGUI(object):
self.db_book_title_cache[title]['authors'][authors] = mi
self.db_book_title_cache[title]['db_ids'][mi.application_id] = mi
self.db_book_uuid_cache.add(mi.uuid)
- db.restore_state_after_scan(state)
# Now iterate through all the books on the device, setting the
# in_library field Fastest and most accurate key is the uuid. Second is
diff --git a/src/calibre/library/caches.py b/src/calibre/library/caches.py
index 45a357c1b5..e280a2178b 100644
--- a/src/calibre/library/caches.py
+++ b/src/calibre/library/caches.py
@@ -563,14 +563,6 @@ class ResultCache(SearchQueryParser):
if item is not None:
item[ondevice_col] = db.book_on_device_string(item[0])
- def get_state_before_scan(self):
- retval = self._map_filtered
- self._map_filtered = self._map
- return retval
-
- def restore_state_after_scan(self, map_filtered):
- self._map_filtered = map_filtered
-
def refresh(self, db, field=None, ascending=True):
temp = db.conn.get('SELECT * FROM meta2')
self._data = list(itertools.repeat(None, temp[-1][0]+2)) if temp else []
diff --git a/src/calibre/library/database2.py b/src/calibre/library/database2.py
index 063538656f..5971333078 100644
--- a/src/calibre/library/database2.py
+++ b/src/calibre/library/database2.py
@@ -245,8 +245,6 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
self.has_id = self.data.has_id
self.count = self.data.count
- self.get_state_before_scan = self.data.get_state_before_scan
- self.restore_state_after_scan = self.data.restore_state_after_scan
self.refresh_ondevice = functools.partial(self.data.refresh_ondevice, self)
self.refresh()
From 0a4dd08686553e162d112f84a244f8227b22cfa2 Mon Sep 17 00:00:00 2001
From: Charles Haley <>
Date: Sun, 16 May 2010 20:37:49 +0100
Subject: [PATCH 099/324] Use an invalid vendor ID for the folder_device
---
src/calibre/devices/folder_device/driver.py | 4 ++++
src/calibre/gui2/device.py | 4 ----
2 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/src/calibre/devices/folder_device/driver.py b/src/calibre/devices/folder_device/driver.py
index 31da69d49a..2b4fc4dea9 100644
--- a/src/calibre/devices/folder_device/driver.py
+++ b/src/calibre/devices/folder_device/driver.py
@@ -28,6 +28,10 @@ class FOLDER_DEVICE(USBMS):
supported_platforms = ['windows', 'osx', 'linux']
FORMATS = ['epub', 'fb2', 'mobi', 'lrf', 'tcr', 'pmlz', 'lit', 'rtf', 'rb', 'pdf', 'oeb', 'txt', 'pdb']
+ VENDOR_ID = 0xffff
+ PRODUCT_ID = 0xffff
+ BCD = 0xffff
+
THUMBNAIL_HEIGHT = 68 # Height for thumbnails on device
CAN_SET_METADATA = True
diff --git a/src/calibre/gui2/device.py b/src/calibre/gui2/device.py
index fa77dc862f..31fe4bbbbd 100644
--- a/src/calibre/gui2/device.py
+++ b/src/calibre/gui2/device.py
@@ -225,10 +225,6 @@ class DeviceManager(Thread):
if hasattr(self.connected_device, 'disconnect_from_folder'):
self.connected_device.disconnect_from_folder()
-# def connect_to_folder(self, path):
-# return self.create_job(self._connect_to_folder, None,
-# description=_('Connect to folder'))
-
def _books(self):
'''Get metadata from device'''
mainlist = self.device.books(oncard=None, end_session=False)
From aa36a2aada2793b62c1a9c1511602efdf021dadd Mon Sep 17 00:00:00 2001
From: Charles Haley <>
Date: Sun, 16 May 2010 20:38:30 +0100
Subject: [PATCH 100/324] Put the vendor ID in the right place.
---
src/calibre/devices/folder_device/driver.py | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/src/calibre/devices/folder_device/driver.py b/src/calibre/devices/folder_device/driver.py
index 2b4fc4dea9..e7d09675c7 100644
--- a/src/calibre/devices/folder_device/driver.py
+++ b/src/calibre/devices/folder_device/driver.py
@@ -17,6 +17,10 @@ class FOLDER_DEVICE_FOR_CONFIG(USBMS):
author = 'John Schember/Charles Haley'
supported_platforms = ['windows', 'osx', 'linux']
FORMATS = ['epub', 'fb2', 'mobi', 'lrf', 'tcr', 'pmlz', 'lit', 'rtf', 'rb', 'pdf', 'oeb', 'txt', 'pdb']
+ VENDOR_ID = 0xffff
+ PRODUCT_ID = 0xffff
+ BCD = 0xffff
+
class FOLDER_DEVICE(USBMS):
type = _('Device Interface')
From 462ae5b9e24145943ac70f3441477b49285da56c Mon Sep 17 00:00:00 2001
From: Charles Haley <>
Date: Sun, 16 May 2010 21:13:34 +0100
Subject: [PATCH 101/324] Clean up eject_device
---
src/calibre/devices/folder_device/driver.py | 3 +++
src/calibre/devices/usbms/driver.py | 2 ++
2 files changed, 5 insertions(+)
diff --git a/src/calibre/devices/folder_device/driver.py b/src/calibre/devices/folder_device/driver.py
index e7d09675c7..f85fca55e1 100644
--- a/src/calibre/devices/folder_device/driver.py
+++ b/src/calibre/devices/folder_device/driver.py
@@ -88,6 +88,9 @@ class FOLDER_DEVICE(USBMS):
def get_main_ebook_dir(self):
return ''
+ def eject(self):
+ self.is_connected = False
+
@classmethod
def settings(self):
return FOLDER_DEVICE_FOR_CONFIG._config().parse()
diff --git a/src/calibre/devices/usbms/driver.py b/src/calibre/devices/usbms/driver.py
index c5b3d653c3..332f337a2f 100644
--- a/src/calibre/devices/usbms/driver.py
+++ b/src/calibre/devices/usbms/driver.py
@@ -248,6 +248,8 @@ class USBMS(CLI, Device):
@classmethod
def normalize_path(cls, path):
+ if path is None:
+ return None
if os.sep == '\\':
path = path.replace('/', '\\')
else:
From 92753c6f978965671005bcef32968c2a085f4857 Mon Sep 17 00:00:00 2001
From: Charles Haley <>
Date: Sun, 16 May 2010 22:12:16 +0100
Subject: [PATCH 102/324] Add confirmation dialog to delete from device
---
src/calibre/gui2/ui.py | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/src/calibre/gui2/ui.py b/src/calibre/gui2/ui.py
index 5343583f5c..c1e208625b 100644
--- a/src/calibre/gui2/ui.py
+++ b/src/calibre/gui2/ui.py
@@ -1523,6 +1523,11 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
sm = view.selectionModel()
sm.select(ci, sm.Select)
else:
+ if not confirm('
'+_('The selected books will be '
+ 'permanently deleted '
+ 'from your device. Are you sure?')
+ +'
', 'library_delete_books', self):
+ return
if self.stack.currentIndex() == 1:
view = self.memory_view
elif self.stack.currentIndex() == 2:
From 2047bfbe9b4454826b92243755f5a8a7ab7e9b34 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Sun, 16 May 2010 19:18:48 -0600
Subject: [PATCH 103/324] Beginnings of add books wizard
---
src/calibre/gui2/add_wizard/__init__.py | 174 +++++++++++++++++++++++
src/calibre/gui2/add_wizard/scan.ui | 25 ++++
src/calibre/gui2/add_wizard/welcome.ui | 134 ++++++++++++++++++
src/calibre/library/add_to_library.py | 178 ++++++++++++++++++++++++
4 files changed, 511 insertions(+)
create mode 100644 src/calibre/gui2/add_wizard/__init__.py
create mode 100644 src/calibre/gui2/add_wizard/scan.ui
create mode 100644 src/calibre/gui2/add_wizard/welcome.ui
create mode 100644 src/calibre/library/add_to_library.py
diff --git a/src/calibre/gui2/add_wizard/__init__.py b/src/calibre/gui2/add_wizard/__init__.py
new file mode 100644
index 0000000000..f7518db3fc
--- /dev/null
+++ b/src/calibre/gui2/add_wizard/__init__.py
@@ -0,0 +1,174 @@
+#!/usr/bin/env python
+# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai
+
+__license__ = 'GPL v3'
+__copyright__ = '2010, Kovid Goyal '
+__docformat__ = 'restructuredtext en'
+
+import os
+
+from PyQt4.Qt import QWizard, QWizardPage, QIcon, QPixmap, Qt, QThread, \
+ pyqtSignal
+
+from calibre.gui2 import error_dialog, choose_dir, gprefs
+from calibre.constants import filesystem_encoding
+from calibre.library.add_to_library import find_folders_under, \
+ find_books_in_folder, hash_merge_format_collections
+
+class WizardPage(QWizardPage): # {{{
+
+ def __init__(self, db, parent):
+ QWizardPage.__init__(self, parent)
+ self.db = db
+ self.register = parent.register
+ self.setupUi(self)
+
+ self.do_init()
+
+ def do_init(self):
+ pass
+
+# }}}
+
+# Scan root folder Page {{{
+
+from calibre.gui2.add_wizard.scan_ui import Ui_WizardPage as ScanWidget
+
+class RecursiveFinder(QThread):
+
+ activity_changed = pyqtSignal(object, object) # description and total count
+ activity_iterated = pyqtSignal(object, object) # item desc, progress number
+
+ def __init__(self, parent=None):
+ QThread.__init__(self, parent)
+ self.canceled = False
+ self.cancel_callback = lambda : self.canceled
+ self.folders = set([])
+ self.books = []
+
+ def cancel(self, *args):
+ self.canceled = True
+
+ def set_params(self, root, db, one_per_folder):
+ self.root, self.db = root, db
+ self.one_per_folder = one_per_folder
+
+ def run(self):
+ self.activity_changed.emit(_('Searching for sub-folders'), 0)
+ self.folders = find_folders_under(self.root, self.db,
+ cancel_callback=self.cancel_callback)
+ if self.canceled:
+ return
+ self.activity_changed.emit(_('Searching for books'), len(self.folders))
+ for i, folder in enumerate(self.folders):
+ if self.canceled:
+ break
+ books_in_folder = find_books_in_folder(folder, self.one_per_folder,
+ cancel_callback=self.cancel_callback)
+ if self.canceled:
+ break
+ self.books.extend(books_in_folder)
+ self.activity_iterated.emit(folder, i)
+
+ self.activity_changed.emit(
+ _('Looking for duplicates based on file hash'), 0)
+
+ self.books = hash_merge_format_collections(self.books,
+ cancel_callback=self.cancel_callback)
+
+
+
+class ScanPage(WizardPage, ScanWidget):
+
+ ID = 2
+
+# }}}
+
+# Welcome Page {{{
+
+from calibre.gui2.add_wizard.welcome_ui import Ui_WizardPage as WelcomeWidget
+
+class WelcomePage(WizardPage, WelcomeWidget):
+
+ ID = 1
+
+ def do_init(self):
+ # Root folder must be filled
+ self.registerField('root_folder*', self.opt_root_folder)
+
+ self.register['root_folder'] = self.get_root_folder
+ self.register['one_per_folder'] = self.get_one_per_folder
+
+ self.button_choose_root_folder.clicked.connect(self.choose_root_folder)
+
+ def choose_root_folder(self, *args):
+ x = self.get_root_folder()
+ if x is None:
+ x = '~'
+ x = choose_dir(self, 'add wizard choose root folder',
+ _('Choose root folder'), default_dir=x)
+ if x is not None:
+ self.opt_root_folder.setText(os.path.abspath(x))
+
+ def initializePage(self):
+ opf = gprefs.get('add wizard one per folder', True)
+ self.opt_one_per_folder.setChecked(opf)
+ self.opt_many_per_folder.setChecked(not opf)
+ add_dir = gprefs.get('add wizard root folder', None)
+ if add_dir is not None:
+ self.opt_root_folder.setText(add_dir)
+
+ def get_root_folder(self):
+ x = unicode(self.opt_root_folder.text()).strip()
+ if not x:
+ return None
+ return os.path.abspath(x.encode(filesystem_encoding))
+
+ def get_one_per_folder(self):
+ return self.opt_one_per_folder.isChecked()
+
+ def validatePage(self):
+ x = self.get_root_folder()
+ xu = x.decode(filesystem_encoding)
+ if x and os.access(x, os.R_OK) and os.path.isdir(x):
+ gprefs['add wizard root folder'] = xu
+ gprefs['add wizard one per folder'] = self.get_one_per_folder()
+ return True
+ error_dialog(self, _('Invalid root folder'),
+ xu + _('is not a valid root folder'), show=True)
+ return False
+
+# }}}
+
+class Wizard(QWizard): # {{{
+
+ def __init__(self, db, parent=None):
+ QWizard.__init__(self, parent)
+ self.setModal(True)
+ self.setWindowTitle(_('Add books to calibre'))
+ self.setWindowIcon(QIcon(I('add_book.svg')))
+ self.setPixmap(self.LogoPixmap, QPixmap(P('content_server/calibre.png')).scaledToHeight(80,
+ Qt.SmoothTransformation))
+ self.setPixmap(self.WatermarkPixmap,
+ QPixmap(I('welcome_wizard.svg')))
+
+ self.register = {}
+
+ for attr, cls in [
+ ('welcome_page', WelcomePage),
+ ('scan_page', ScanPage),
+ ]:
+ setattr(self, attr, cls(db, self))
+ self.setPage(getattr(cls, 'ID'), getattr(self, attr))
+
+# }}}
+
+# Test Wizard {{{
+if __name__ == '__main__':
+ from PyQt4.Qt import QApplication
+ from calibre.library import db
+ app = QApplication([])
+ w = Wizard(db())
+ w.exec_()
+# }}}
+
diff --git a/src/calibre/gui2/add_wizard/scan.ui b/src/calibre/gui2/add_wizard/scan.ui
new file mode 100644
index 0000000000..b697ff9894
--- /dev/null
+++ b/src/calibre/gui2/add_wizard/scan.ui
@@ -0,0 +1,25 @@
+
+
+ WizardPage
+
+
+
+ 0
+ 0
+ 400
+ 300
+
+
+
+ WizardPage
+
+
+ Scanning root folder for books
+
+
+ This may take a few minutes
+
+
+
+
+
diff --git a/src/calibre/gui2/add_wizard/welcome.ui b/src/calibre/gui2/add_wizard/welcome.ui
new file mode 100644
index 0000000000..52fcabb714
--- /dev/null
+++ b/src/calibre/gui2/add_wizard/welcome.ui
@@ -0,0 +1,134 @@
+
+
+ WizardPage
+
+
+
+ 0
+ 0
+ 704
+ 468
+
+
+
+ WizardPage
+
+
+ Choose the location to add books from
+
+
+ Select a folder on your hard disk
+
+
+
+
+
+ <p>calibre can scan your computer for existing books automatically. These books will then be <b>copied</b> into the calibre library. This wizard will help you customize the scanning and import process for your existing book collection.</p>
+<p>Choose a root folder. Books will be searched for only inside this folder and any sub-folders.</p>
+<p>Make sure that the folder you chose for your calibre library <b>is not</b> under the root folder you choose.</p>
+
+
+ true
+
+
+
+
+
+
+ &Root folder:
+
+
+ opt_root_folder
+
+
+
+
+
+
+ This folder and its sub-folders will be scanned for books to import into calibre's library
+
+
+
+
+
+
+ Choose root folder
+
+
+ ...
+
+
+
+ :/images/document_open.svg:/images/document_open.svg
+
+
+
+
+
+
+ Handle multiple files per book
+
+
+
+
+
+ &One book per folder, assumes every ebook file in a folder is the same book in a different format
+
+
+
+
+
+
+ &Multiple books per folder, assumes every ebook file is a different book
+
+
+
+
+
+
+
+
+
+ Qt::Vertical
+
+
+
+ 20
+ 40
+
+
+
+
+
+
+
+ Qt::Vertical
+
+
+
+ 20
+ 40
+
+
+
+
+
+
+
+ Qt::Vertical
+
+
+
+ 20
+ 40
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/calibre/library/add_to_library.py b/src/calibre/library/add_to_library.py
new file mode 100644
index 0000000000..8451241e3c
--- /dev/null
+++ b/src/calibre/library/add_to_library.py
@@ -0,0 +1,178 @@
+#!/usr/bin/env python
+# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai
+
+__license__ = 'GPL v3'
+__copyright__ = '2010, Kovid Goyal '
+__docformat__ = 'restructuredtext en'
+
+import os
+from hashlib import sha1
+
+from calibre.constants import filesystem_encoding
+from calibre.ebooks import BOOK_EXTENSIONS
+
+def find_folders_under(root, db, add_root=True, # {{{
+ follow_links=False, cancel_callback=lambda : False):
+ '''
+ Find all folders under the specified root path, ignoring any folders under
+ the library path of db
+
+ root must be a bytestring in filesystem_encoding
+
+ If follow_links is True, follow symbolic links. WARNING; this can lead to
+ infinite recursion.
+
+ cancel_callback must be a no argument callable that returns True to cancel
+ the search
+ '''
+ assert not isinstance(root, unicode) # root must be in filesystem encoding
+ lp = db.library_path
+ if isinstance(lp, unicode):
+ try:
+ lp = lp.encode(filesystem_encoding)
+ except:
+ lp = None
+ if lp:
+ lp = os.path.abspath(lp)
+
+ root = os.path.abspath(root)
+
+ ans = set([])
+ for dirpath, dirnames, __ in os.walk(root, topdown=True, followlinks=follow_links):
+ if cancel_callback():
+ break
+ for x in list(dirnames):
+ path = os.path.join(dirpath, x)
+ if lp and path.startswith(lp):
+ dirnames.remove(x)
+ if lp and dirpath.startswith(lp):
+ continue
+ ans.add(dirpath)
+
+ if not add_root:
+ ans.remove(root)
+
+ return ans
+
+# }}}
+
+class FormatCollection(object): # {{{
+
+ def __init__(self, parent_folder, formats):
+ self.path_map = {}
+ for x in set(formats):
+ fmt = os.path.splitext(x)[1].lower()
+ if fmt:
+ fmt = fmt[1:]
+ self.path_map[fmt] = x
+ self.parent_folder = None
+ self.hash_map = {}
+ for fmt, path in self.format_map.items():
+ self.hash_map[fmt] = self.hash_of_file(path)
+
+ def hash_of_file(self, path):
+ with open(path, 'rb') as f:
+ return sha1(f.read()).digest()
+
+ @property
+ def hashes(self):
+ return frozenset(self.formats.values())
+
+ @property
+ def is_empty(self):
+ return len(self) == 0
+
+ def __iter__(self):
+ for x in self.path_map:
+ yield x
+
+ def __len__(self):
+ return len(self.path_map)
+
+ def remove(self, fmt):
+ self.hash_map.pop(fmt, None)
+ self.path_map.pop(fmt, None)
+
+ def matches(self, other):
+ if not self.hashes.intersection(other.hashes):
+ return False
+ for fmt in self:
+ if self.hash_map[fmt] != other.hash_map.get(fmt, False):
+ return False
+ return True
+
+ def merge(self, other):
+ for fmt in list(other):
+ self.path_map[fmt] = other.path_map[fmt]
+ self.hash_map[fmt] = other.hash_map[fmt]
+ other.remove(fmt)
+
+# }}}
+
+def books_in_folder(folder, one_per_folder, # {{{
+ cancel_callback=lambda : False):
+ assert not isinstance(folder, unicode)
+
+ dirpath = os.path.abspath(folder)
+ if one_per_folder:
+ formats = set([])
+ for path in os.listdir(dirpath):
+ if cancel_callback():
+ return []
+ path = os.path.abspath(os.path.join(dirpath, path))
+ if os.path.isdir(path) or not os.access(path, os.R_OK):
+ continue
+ ext = os.path.splitext(path)[1]
+ if not ext:
+ continue
+ ext = ext[1:].lower()
+ if ext not in BOOK_EXTENSIONS and ext != 'opf':
+ continue
+ formats.add(path)
+ return [FormatCollection(folder, formats)]
+ else:
+ books = {}
+ for path in os.listdir(dirpath):
+ if cancel_callback():
+ return
+ path = os.path.abspath(os.path.join(dirpath, path))
+ if os.path.isdir(path) or not os.access(path, os.R_OK):
+ continue
+ ext = os.path.splitext(path)[1]
+ if not ext:
+ continue
+ ext = ext[1:].lower()
+ if ext not in BOOK_EXTENSIONS:
+ continue
+
+ key = os.path.splitext(path)[0]
+ if not books.has_key(key):
+ books[key] = set([])
+ books[key].add(path)
+
+ return [FormatCollection(folder, x) for x in books.values() if x]
+
+# }}}
+
+def hash_merge_format_collections(collections, cancel_callback=lambda:False):
+ ans = []
+
+ collections = list(collections)
+ l = len(collections)
+ for i in range(l):
+ if cancel_callback():
+ return collections
+ one = collections[i]
+ if one.is_empty:
+ continue
+ for j in range(i+1, l):
+ if cancel_callback():
+ return collections
+ two = collections[j]
+ if two.is_empty:
+ continue
+ if one.matches(two):
+ one.merge(two)
+ ans.append(one)
+
+ return ans
From 86f56c4a852ece9c190c936bbd4cd7d002d586fb Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Sun, 16 May 2010 19:51:52 -0600
Subject: [PATCH 104/324] Icons for connect/disconnect folder actions
---
src/calibre/gui2/device.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/calibre/gui2/device.py b/src/calibre/gui2/device.py
index 31fe4bbbbd..8f4ff6617f 100644
--- a/src/calibre/gui2/device.py
+++ b/src/calibre/gui2/device.py
@@ -426,12 +426,12 @@ class DeviceMenu(QMenu):
self.addMenu(self.email_to_menu)
self.addSeparator()
- mitem = self.addAction(_('Connect to folder'))
+ mitem = self.addAction(QIcon(I('document_open.svg')), _('Connect to folder'))
mitem.setEnabled(True)
mitem.triggered.connect(lambda x : self.connect_to_folder.emit())
self.connect_to_folder_action = mitem
- mitem = self.addAction(_('Disconnect from folder'))
+ mitem = self.addAction(QIcon(I('eject.svg')), _('Disconnect from folder'))
mitem.setEnabled(False)
mitem.triggered.connect(lambda x : self.disconnect_from_folder.emit())
self.disconnect_from_folder_action = mitem
From 66d260445898ba40d77aecd0bd5860d02a73f589 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Sun, 16 May 2010 21:18:45 -0600
Subject: [PATCH 105/324] Remove unnecessary method definitions from folder
driver
---
src/calibre/devices/folder_device/driver.py | 11 -----------
1 file changed, 11 deletions(-)
diff --git a/src/calibre/devices/folder_device/driver.py b/src/calibre/devices/folder_device/driver.py
index f85fca55e1..0dcbae87ce 100644
--- a/src/calibre/devices/folder_device/driver.py
+++ b/src/calibre/devices/folder_device/driver.py
@@ -58,14 +58,6 @@ class FOLDER_DEVICE(USBMS):
self.booklist_class = BookList
self.is_connected = True
- @classmethod
- def get_gui_name(cls):
- if hasattr(cls, 'gui_name'):
- return cls.gui_name
- if hasattr(cls, '__name__'):
- return cls.__name__
- return cls.name
-
def disconnect_from_folder(self):
self._main_prefix = ''
self.is_connected = False
@@ -85,9 +77,6 @@ class FOLDER_DEVICE(USBMS):
def card_prefix(self, end_session=True):
return (None, None)
- def get_main_ebook_dir(self):
- return ''
-
def eject(self):
self.is_connected = False
From fcdcd68adfd99bee04846755243709fa36f522f2 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Sun, 16 May 2010 21:21:21 -0600
Subject: [PATCH 106/324] Plugin customization GUI: Sort plugins by name
---
src/calibre/gui2/dialogs/config/__init__.py | 3 +++
1 file changed, 3 insertions(+)
diff --git a/src/calibre/gui2/dialogs/config/__init__.py b/src/calibre/gui2/dialogs/config/__init__.py
index 1cb6aad283..ff50ff7718 100644
--- a/src/calibre/gui2/dialogs/config/__init__.py
+++ b/src/calibre/gui2/dialogs/config/__init__.py
@@ -109,6 +109,9 @@ class PluginModel(QAbstractItemModel):
self._data[plugin.type].append(plugin)
self.categories = sorted(self._data.keys())
+ for plugins in self._data.values():
+ plugins.sort(cmp=lambda x, y: cmp(x.name.lower(), y.name.lower()))
+
def index(self, row, column, parent):
if not self.hasIndex(row, column, parent):
return QModelIndex()
From f4a02e09d6ec8d576cfdd0f830c1493531c82d71 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Sun, 16 May 2010 21:47:00 -0600
Subject: [PATCH 107/324] Cleanup BookList classes
---
src/calibre/devices/interface.py | 16 +++++++++++++++-
src/calibre/devices/usbms/books.py | 12 +-----------
2 files changed, 16 insertions(+), 12 deletions(-)
diff --git a/src/calibre/devices/interface.py b/src/calibre/devices/interface.py
index b38b62e20c..356ebfc876 100644
--- a/src/calibre/devices/interface.py
+++ b/src/calibre/devices/interface.py
@@ -387,7 +387,7 @@ class BookList(list):
__getslice__ = None
__setslice__ = None
- def __init__(self, oncard, prefix):
+ def __init__(self, oncard, prefix, settings):
pass
def supports_tags(self):
@@ -402,3 +402,17 @@ class BookList(list):
'''
raise NotImplementedError()
+ def add_book(self, book, replace_metadata):
+ '''
+ Add the book to the booklist. Intent is to maintain any device-internal
+ metadata. Return True if booklists must be sync'ed
+ '''
+ raise NotImplementedError()
+
+ def remove_book(self, book):
+ '''
+ Remove a book from the booklist. Correct any device metadata at the
+ same time
+ '''
+ raise NotImplementedError()
+
diff --git a/src/calibre/devices/usbms/books.py b/src/calibre/devices/usbms/books.py
index b153300282..edd5907713 100644
--- a/src/calibre/devices/usbms/books.py
+++ b/src/calibre/devices/usbms/books.py
@@ -108,9 +108,6 @@ class Book(MetaInformation):
class BookList(_BookList):
- def __init__(self, oncard, prefix, settings):
- pass
-
def supports_tags(self):
return True
@@ -118,16 +115,9 @@ class BookList(_BookList):
book.tags = tags
def add_book(self, book, replace_metadata):
- '''
- Add the book to the booklist. Intent is to maintain any device-internal
- metadata. Return True if booklists must be sync'ed
- '''
if book not in self:
self.append(book)
+ return True
def remove_book(self, book):
- '''
- Remove a book from the booklist. Correct any device metadata at the
- same time
- '''
self.remove(book)
From feb5a6f0595c37c38bfff33a057b00a029afe9c5 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Sun, 16 May 2010 21:55:09 -0600
Subject: [PATCH 108/324] USBMS: books emthod should always returna n object of
type BookList
---
src/calibre/devices/usbms/driver.py | 8 +++++---
1 file changed, 5 insertions(+), 3 deletions(-)
diff --git a/src/calibre/devices/usbms/driver.py b/src/calibre/devices/usbms/driver.py
index 332f337a2f..c8f48511a4 100644
--- a/src/calibre/devices/usbms/driver.py
+++ b/src/calibre/devices/usbms/driver.py
@@ -45,15 +45,17 @@ class USBMS(CLI, Device):
def books(self, oncard=None, end_session=True):
from calibre.ebooks.metadata.meta import path_to_ext
+ dummy_bl = BookList(None, None, None)
+
if oncard == 'carda' and not self._card_a_prefix:
self.report_progress(1.0, _('Getting list of books on device...'))
- return []
+ return dummy_bl
elif oncard == 'cardb' and not self._card_b_prefix:
self.report_progress(1.0, _('Getting list of books on device...'))
- return []
+ return dummy_bl
elif oncard and oncard != 'carda' and oncard != 'cardb':
self.report_progress(1.0, _('Getting list of books on device...'))
- return []
+ return dummy_bl
prefix = self._card_a_prefix if oncard == 'carda' else \
self._card_b_prefix if oncard == 'cardb' \
From 473ccd8a715ce8660c052d536a81e861d261d7ec Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Sun, 16 May 2010 22:07:23 -0600
Subject: [PATCH 109/324] usbms.driver: Report progress as 50% not 5000%. Also
remove spurious changed=True, since add_book now correctly reports changed
---
src/calibre/devices/usbms/driver.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/calibre/devices/usbms/driver.py b/src/calibre/devices/usbms/driver.py
index c8f48511a4..c7c4e06834 100644
--- a/src/calibre/devices/usbms/driver.py
+++ b/src/calibre/devices/usbms/driver.py
@@ -89,7 +89,6 @@ class USBMS(CLI, Device):
self.count_found_in_bl += 1
else:
item = self.book_from_path(prefix, lpath)
- changed = True
if metadata.add_book(item, replace_metadata=False):
changed = True
except: # Probably a filename encoding error
@@ -108,7 +107,7 @@ class USBMS(CLI, Device):
if self.SUPPORTS_SUB_DIRS:
for path, dirs, files in os.walk(ebook_dir):
for filename in files:
- self.report_progress(50.0, _('Getting list of books on device...'))
+ self.report_progress(0.5, _('Getting list of books on device...'))
changed = update_booklist(filename, path, prefix)
if changed:
need_sync = True
@@ -250,6 +249,7 @@ class USBMS(CLI, Device):
@classmethod
def normalize_path(cls, path):
+ 'Return path with platform native path separators'
if path is None:
return None
if os.sep == '\\':
From 84e5059b11657aacc36a30e867c8c31cb096c870 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Sun, 16 May 2010 22:26:59 -0600
Subject: [PATCH 110/324] More usbms.driver cleanups
---
src/calibre/devices/prs505/driver.py | 2 +-
src/calibre/devices/usbms/books.py | 2 +-
src/calibre/devices/usbms/driver.py | 17 +++++++++++------
3 files changed, 13 insertions(+), 8 deletions(-)
diff --git a/src/calibre/devices/prs505/driver.py b/src/calibre/devices/prs505/driver.py
index d2823ff4a4..9926e5f61c 100644
--- a/src/calibre/devices/prs505/driver.py
+++ b/src/calibre/devices/prs505/driver.py
@@ -27,7 +27,7 @@ class PRS505(USBMS):
supported_platforms = ['windows', 'osx', 'linux']
path_sep = '/'
- booklist_class = PRS_BookList # See USBMS for some explanation of this
+ booklist_class = PRS_BookList # See usbms.driver for some explanation of this
FORMATS = ['epub', 'lrf', 'lrx', 'rtf', 'pdf', 'txt']
diff --git a/src/calibre/devices/usbms/books.py b/src/calibre/devices/usbms/books.py
index edd5907713..3ecee3755f 100644
--- a/src/calibre/devices/usbms/books.py
+++ b/src/calibre/devices/usbms/books.py
@@ -37,7 +37,7 @@ class Book(MetaInformation):
else:
self.lpath = lpath
self.mime = mime_type_ext(path_to_ext(lpath))
- self.size = None # will be set later
+ self.size = size # will be set later if None
self.datetime = time.gmtime()
if other:
diff --git a/src/calibre/devices/usbms/driver.py b/src/calibre/devices/usbms/driver.py
index c7c4e06834..1d5343024c 100644
--- a/src/calibre/devices/usbms/driver.py
+++ b/src/calibre/devices/usbms/driver.py
@@ -15,6 +15,7 @@ import re
import json
from itertools import cycle
+from calibre import prints
from calibre.devices.usbms.cli import CLI
from calibre.devices.usbms.device import Device
from calibre.devices.usbms.books import BookList, Book
@@ -122,7 +123,7 @@ class USBMS(CLI, Device):
# if count != len(bl) then there were items in it that we did not
# find on the device. If need_sync is True then there were either items
# on the device that were not in bl or some of the items were changed.
- print "count found in cache: %d, count of files in cache: %d, must_sync_cache: %s" % (self.count_found_in_bl, len(bl), need_sync)
+ #print "count found in cache: %d, count of files in cache: %d, must_sync_cache: %s" % (self.count_found_in_bl, len(bl), need_sync)
if self.count_found_in_bl != len(bl) or need_sync:
if oncard == 'cardb':
self.sync_booklists((None, None, metadata))
@@ -146,7 +147,9 @@ class USBMS(CLI, Device):
mdata, fname = metadata.next(), names.next()
filepath = self.normalize_path(self.create_upload_path(path, mdata, fname))
paths.append(filepath)
- self.put_file(self.normalize_path(infile), filepath, replace_file=True)
+ if not hasattr(infile, 'read'):
+ infile = self.normalize_path(infile)
+ self.put_file(infile, filepath, replace_file=True)
try:
self.upload_cover(os.path.dirname(filepath),
os.path.splitext(os.path.basename(filepath))[0], mdata)
@@ -188,7 +191,8 @@ class USBMS(CLI, Device):
if not prefix and self._card_b_prefix:
prefix = self._card_b_prefix if path.startswith(self._card_b_prefix) else None
if prefix is None:
- print 'in add_books_to_metadata. Prefix is None!', path, self._main_prefix
+ prints('in add_books_to_metadata. Prefix is None!', path,
+ self._main_prefix)
continue
lpath = path.partition(prefix)[2]
if lpath.startswith('/') or lpath.startswith('\\'):
@@ -238,7 +242,8 @@ class USBMS(CLI, Device):
if prefix is not None and isinstance(booklists[listid], self.booklist_class):
if not os.path.exists(prefix):
os.makedirs(self.normalize_path(prefix))
- js = [item.to_json() for item in booklists[listid]]
+ js = [item.to_json() for item in booklists[listid] if
+ hasattr(item, 'to_json')]
with open(self.normalize_path(os.path.join(prefix, self.METADATA_CACHE)), 'wb') as f:
json.dump(js, f, indent=2, encoding='utf-8')
write_prefix(self._main_prefix, 0)
@@ -314,6 +319,6 @@ class USBMS(CLI, Device):
if mi is None:
mi = MetaInformation(os.path.splitext(os.path.basename(path))[0],
[_('Unknown')])
- mi.size = os.stat(cls.normalize_path(os.path.join(prefix, path))).st_size
- book = cls.book_class(prefix, path, other=mi)
+ size = os.stat(cls.normalize_path(os.path.join(prefix, path))).st_size
+ book = cls.book_class(prefix, path, other=mi, size=size)
return book
From 0d4506f9c146990e51dea9172608229ab6bc97ef Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Sun, 16 May 2010 22:34:51 -0600
Subject: [PATCH 111/324] Change line endings
---
src/calibre/devices/folder_device/driver.py | 170 ++++++++++----------
1 file changed, 85 insertions(+), 85 deletions(-)
diff --git a/src/calibre/devices/folder_device/driver.py b/src/calibre/devices/folder_device/driver.py
index 0dcbae87ce..6cc825dd9b 100644
--- a/src/calibre/devices/folder_device/driver.py
+++ b/src/calibre/devices/folder_device/driver.py
@@ -1,85 +1,85 @@
-'''
-Created on 15 May 2010
-
-@author: charles
-'''
-import os
-
-from calibre.devices.usbms.driver import USBMS, BookList
-
-# This class is added to the standard device plugin chain, so that it can
-# be configured. It has invalid vendor_id etc, so it will never match a
-# device. The 'real' FOLDER_DEVICE will use the config from it.
-class FOLDER_DEVICE_FOR_CONFIG(USBMS):
- name = 'Folder Device Interface'
- gui_name = 'Folder Device'
- description = _('Use an arbitrary folder as a device.')
- author = 'John Schember/Charles Haley'
- supported_platforms = ['windows', 'osx', 'linux']
- FORMATS = ['epub', 'fb2', 'mobi', 'lrf', 'tcr', 'pmlz', 'lit', 'rtf', 'rb', 'pdf', 'oeb', 'txt', 'pdb']
- VENDOR_ID = 0xffff
- PRODUCT_ID = 0xffff
- BCD = 0xffff
-
-
-class FOLDER_DEVICE(USBMS):
- type = _('Device Interface')
-
- name = 'Folder Device Interface'
- gui_name = 'Folder Device'
- description = _('Use an arbitrary folder as a device.')
- author = 'John Schember/Charles Haley'
- supported_platforms = ['windows', 'osx', 'linux']
- FORMATS = ['epub', 'fb2', 'mobi', 'lrf', 'tcr', 'pmlz', 'lit', 'rtf', 'rb', 'pdf', 'oeb', 'txt', 'pdb']
-
- VENDOR_ID = 0xffff
- PRODUCT_ID = 0xffff
- BCD = 0xffff
-
- THUMBNAIL_HEIGHT = 68 # Height for thumbnails on device
-
- CAN_SET_METADATA = True
- SUPPORTS_SUB_DIRS = True
-
- #: Icon for this device
- icon = I('sd.svg')
- METADATA_CACHE = '.metadata.calibre'
-
- _main_prefix = ''
- _card_a_prefix = None
- _card_b_prefix = None
-
- is_connected = False
-
- def __init__(self, path):
- if not os.path.isdir(path):
- raise IOError, 'Path is not a folder'
- self._main_prefix = path
- self.booklist_class = BookList
- self.is_connected = True
-
- def disconnect_from_folder(self):
- self._main_prefix = ''
- self.is_connected = False
-
- def is_usb_connected(self, devices_on_system, debug=False,
- only_presence=False):
- return self.is_connected, self
-
- def open(self):
- if not self._main_prefix:
- return False
- return True
-
- def set_progress_reporter(self, report_progress):
- self.report_progress = report_progress
-
- def card_prefix(self, end_session=True):
- return (None, None)
-
- def eject(self):
- self.is_connected = False
-
- @classmethod
- def settings(self):
- return FOLDER_DEVICE_FOR_CONFIG._config().parse()
+'''
+Created on 15 May 2010
+
+@author: charles
+'''
+import os
+
+from calibre.devices.usbms.driver import USBMS, BookList
+
+# This class is added to the standard device plugin chain, so that it can
+# be configured. It has invalid vendor_id etc, so it will never match a
+# device. The 'real' FOLDER_DEVICE will use the config from it.
+class FOLDER_DEVICE_FOR_CONFIG(USBMS):
+ name = 'Folder Device Interface'
+ gui_name = 'Folder Device'
+ description = _('Use an arbitrary folder as a device.')
+ author = 'John Schember/Charles Haley'
+ supported_platforms = ['windows', 'osx', 'linux']
+ FORMATS = ['epub', 'fb2', 'mobi', 'lrf', 'tcr', 'pmlz', 'lit', 'rtf', 'rb', 'pdf', 'oeb', 'txt', 'pdb']
+ VENDOR_ID = 0xffff
+ PRODUCT_ID = 0xffff
+ BCD = 0xffff
+
+
+class FOLDER_DEVICE(USBMS):
+ type = _('Device Interface')
+
+ name = 'Folder Device Interface'
+ gui_name = 'Folder Device'
+ description = _('Use an arbitrary folder as a device.')
+ author = 'John Schember/Charles Haley'
+ supported_platforms = ['windows', 'osx', 'linux']
+ FORMATS = ['epub', 'fb2', 'mobi', 'lrf', 'tcr', 'pmlz', 'lit', 'rtf', 'rb', 'pdf', 'oeb', 'txt', 'pdb']
+
+ VENDOR_ID = 0xffff
+ PRODUCT_ID = 0xffff
+ BCD = 0xffff
+
+ THUMBNAIL_HEIGHT = 68 # Height for thumbnails on device
+
+ CAN_SET_METADATA = True
+ SUPPORTS_SUB_DIRS = True
+
+ #: Icon for this device
+ icon = I('sd.svg')
+ METADATA_CACHE = '.metadata.calibre'
+
+ _main_prefix = ''
+ _card_a_prefix = None
+ _card_b_prefix = None
+
+ is_connected = False
+
+ def __init__(self, path):
+ if not os.path.isdir(path):
+ raise IOError, 'Path is not a folder'
+ self._main_prefix = path
+ self.booklist_class = BookList
+ self.is_connected = True
+
+ def disconnect_from_folder(self):
+ self._main_prefix = ''
+ self.is_connected = False
+
+ def is_usb_connected(self, devices_on_system, debug=False,
+ only_presence=False):
+ return self.is_connected, self
+
+ def open(self):
+ if not self._main_prefix:
+ return False
+ return True
+
+ def set_progress_reporter(self, report_progress):
+ self.report_progress = report_progress
+
+ def card_prefix(self, end_session=True):
+ return (None, None)
+
+ def eject(self):
+ self.is_connected = False
+
+ @classmethod
+ def settings(self):
+ return FOLDER_DEVICE_FOR_CONFIG._config().parse()
From 4201dbeeaca994ebfed1c87fc9a8c2e6f29e1a43 Mon Sep 17 00:00:00 2001
From: Charles Haley <>
Date: Mon, 17 May 2010 10:48:47 +0100
Subject: [PATCH 112/324] 1) Ensure that folder_device prefix (the path) end in
os.sep 2) Fix regression causing sync of metadata cache on every connect 3)
Report correct progress when adding books
---
src/calibre/devices/folder_device/driver.py | 5 ++++-
src/calibre/devices/usbms/books.py | 2 +-
src/calibre/devices/usbms/device.py | 3 +--
src/calibre/devices/usbms/driver.py | 19 ++++++++++++++-----
4 files changed, 20 insertions(+), 9 deletions(-)
diff --git a/src/calibre/devices/folder_device/driver.py b/src/calibre/devices/folder_device/driver.py
index 6cc825dd9b..6b06cdf092 100644
--- a/src/calibre/devices/folder_device/driver.py
+++ b/src/calibre/devices/folder_device/driver.py
@@ -54,7 +54,10 @@ class FOLDER_DEVICE(USBMS):
def __init__(self, path):
if not os.path.isdir(path):
raise IOError, 'Path is not a folder'
- self._main_prefix = path
+ if path.endswith(os.sep):
+ self._main_prefix = path
+ else:
+ self._main_prefix = path + os.sep
self.booklist_class = BookList
self.is_connected = True
diff --git a/src/calibre/devices/usbms/books.py b/src/calibre/devices/usbms/books.py
index 3ecee3755f..59f098d421 100644
--- a/src/calibre/devices/usbms/books.py
+++ b/src/calibre/devices/usbms/books.py
@@ -117,7 +117,7 @@ class BookList(_BookList):
def add_book(self, book, replace_metadata):
if book not in self:
self.append(book)
- return True
+ return False # subclasses return True if device metadata has changed
def remove_book(self, book):
self.remove(book)
diff --git a/src/calibre/devices/usbms/device.py b/src/calibre/devices/usbms/device.py
index 249733b4e3..9b1da24805 100644
--- a/src/calibre/devices/usbms/device.py
+++ b/src/calibre/devices/usbms/device.py
@@ -113,8 +113,7 @@ class Device(DeviceConfig, DevicePlugin):
def _windows_space(cls, prefix):
if not prefix:
return 0, 0
- if prefix.endswith(os.sep):
- prefix = prefix[:-1]
+ prefix = prefix[:-1]
win32file = __import__('win32file', globals(), locals(), [], -1)
try:
sectors_per_cluster, bytes_per_sector, free_clusters, total_clusters = \
diff --git a/src/calibre/devices/usbms/driver.py b/src/calibre/devices/usbms/driver.py
index 1d5343024c..3a30b3c10e 100644
--- a/src/calibre/devices/usbms/driver.py
+++ b/src/calibre/devices/usbms/driver.py
@@ -90,6 +90,7 @@ class USBMS(CLI, Device):
self.count_found_in_bl += 1
else:
item = self.book_from_path(prefix, lpath)
+ changed = True
if metadata.add_book(item, replace_metadata=False):
changed = True
except: # Probably a filename encoding error
@@ -106,12 +107,17 @@ class USBMS(CLI, Device):
if not os.path.exists(ebook_dir): continue
# Get all books in the ebook_dir directory
if self.SUPPORTS_SUB_DIRS:
+ # build a list of files to check, so we can accurately report progress
+ flist = []
for path, dirs, files in os.walk(ebook_dir):
for filename in files:
- self.report_progress(0.5, _('Getting list of books on device...'))
- changed = update_booklist(filename, path, prefix)
- if changed:
- need_sync = True
+ if filename != self.METADATA_CACHE:
+ flist.append({'filename':filename, 'path': path})
+ for i, f in enumerate(flist):
+ self.report_progress(i/float(len(flist)), _('Getting list of books on device...'))
+ changed = update_booklist(f['filename'], f['path'], prefix)
+ if changed:
+ need_sync = True
else:
paths = os.listdir(ebook_dir)
for i, filename in enumerate(paths):
@@ -123,7 +129,10 @@ class USBMS(CLI, Device):
# if count != len(bl) then there were items in it that we did not
# find on the device. If need_sync is True then there were either items
# on the device that were not in bl or some of the items were changed.
- #print "count found in cache: %d, count of files in cache: %d, must_sync_cache: %s" % (self.count_found_in_bl, len(bl), need_sync)
+
+ #print "count found in cache: %d, count of files in cache: %d, need_sync: %s, must_sync_cache: %s" % \
+ # (self.count_found_in_bl, len(bl), need_sync,
+ # need_sync or self.count_found_in_bl != len(bl))
if self.count_found_in_bl != len(bl) or need_sync:
if oncard == 'cardb':
self.sync_booklists((None, None, metadata))
From 922121f726092b67d864abed9c41c0a3216fe09b Mon Sep 17 00:00:00 2001
From: Charles Haley <>
Date: Mon, 17 May 2010 11:09:27 +0100
Subject: [PATCH 113/324] Calculate author_sort for metadata cache, if it isn't
already set
---
src/calibre/gui2/device.py | 10 ++++++----
1 file changed, 6 insertions(+), 4 deletions(-)
diff --git a/src/calibre/gui2/device.py b/src/calibre/gui2/device.py
index 8f4ff6617f..26afc58068 100644
--- a/src/calibre/gui2/device.py
+++ b/src/calibre/gui2/device.py
@@ -19,7 +19,7 @@ from calibre.devices.scanner import DeviceScanner
from calibre.gui2 import config, error_dialog, Dispatcher, dynamic, \
pixmap_to_data, warning_dialog, \
question_dialog
-from calibre.ebooks.metadata import authors_to_string
+from calibre.ebooks.metadata import authors_to_string, authors_to_sort_string
from calibre import preferred_encoding
from calibre.utils.filenames import ascii_filename
from calibre.devices.errors import FreeSpaceError
@@ -1133,9 +1133,11 @@ class DeviceGUI(object):
resend_metadata = True
# Set author_sort if it isn't already
asort = getattr(book, 'author_sort', None)
- if not asort:
- pass
+ if not asort and book.authors:
+ book.author_sort = authors_to_sort_string(book.authors)
+ resend_metadata = True
+
if resend_metadata:
- # Correcting metadata cache on device.
+ # Correct the metadata cache on device.
if self.device_manager.is_device_connected:
self.device_manager.sync_booklists(None, booklists)
From f6f028ee5cf0b06f055c15f84655fa93d110b33f Mon Sep 17 00:00:00 2001
From: Charles Haley <>
Date: Mon, 17 May 2010 12:53:32 +0100
Subject: [PATCH 114/324] Remove race conditions when connecting folders and a
device is present
---
src/calibre/devices/folder_device/driver.py | 4 ++
src/calibre/gui2/device.py | 51 +++++++++++++--------
src/calibre/gui2/ui.py | 5 +-
3 files changed, 39 insertions(+), 21 deletions(-)
diff --git a/src/calibre/devices/folder_device/driver.py b/src/calibre/devices/folder_device/driver.py
index 6b06cdf092..792de9ee0a 100644
--- a/src/calibre/devices/folder_device/driver.py
+++ b/src/calibre/devices/folder_device/driver.py
@@ -61,6 +61,10 @@ class FOLDER_DEVICE(USBMS):
self.booklist_class = BookList
self.is_connected = True
+ def reset(self, key='-1', log_packets=False, report_progress=None,
+ detected_device=None):
+ pass
+
def disconnect_from_folder(self):
self._main_prefix = ''
self.is_connected = False
diff --git a/src/calibre/gui2/device.py b/src/calibre/gui2/device.py
index 26afc58068..edf0e763f7 100644
--- a/src/calibre/gui2/device.py
+++ b/src/calibre/gui2/device.py
@@ -86,7 +86,9 @@ class DeviceManager(Thread):
self.current_job = None
self.scanner = DeviceScanner()
self.connected_device = None
- self.ejected_devices = set([])
+ self.ejected_devices = set([])
+ self.connected_device_is_folder = False
+ self.folder_connection_path = None
def report_progress(self, *args):
pass
@@ -99,7 +101,7 @@ class DeviceManager(Thread):
def device(self):
return self.connected_device
- def do_connect(self, connected_devices):
+ def do_connect(self, connected_devices, is_folder_device):
for dev, detected_device in connected_devices:
dev.reset(detected_device=detected_device,
report_progress=self.report_progress)
@@ -110,7 +112,8 @@ class DeviceManager(Thread):
traceback.print_exc()
continue
self.connected_device = dev
- self.connected_slot(True)
+ self.connected_device_is_folder = is_folder_device
+ self.connected_slot(True, is_folder_device)
return True
return False
@@ -128,7 +131,7 @@ class DeviceManager(Thread):
if self.connected_device in self.ejected_devices:
self.ejected_devices.remove(self.connected_device)
else:
- self.connected_slot(False)
+ self.connected_slot(False, self.connected_device_is_folder)
self.connected_device = None
def detect_device(self):
@@ -149,17 +152,19 @@ class DeviceManager(Thread):
if possibly_connected:
possibly_connected_devices.append((device, detected_device))
if possibly_connected_devices:
- if not self.do_connect(possibly_connected_devices):
+ if not self.do_connect(possibly_connected_devices,
+ is_folder_device=False):
print 'Connect to device failed, retrying in 5 seconds...'
time.sleep(5)
- if not self.do_connect(possibly_connected_devices):
+ if not self.do_connect(possibly_connected_devices,
+ is_folder_device=False):
print 'Device connect failed again, giving up'
def umount_device(self, *args):
if self.is_device_connected:
self.connected_device.eject()
self.ejected_devices.add(self.connected_device)
- self.connected_slot(False)
+ self.connected_slot(False, self.connected_device_is_folder)
def next(self):
if not self.jobs.empty():
@@ -170,7 +175,18 @@ class DeviceManager(Thread):
def run(self):
while self.keep_going:
- self.detect_device()
+ if not self.is_device_connected and \
+ self.folder_connection_path is not None:
+ f = self.folder_connection_path
+ self.folder_connection_path = None # Make sure we try this folder only once
+ try:
+ dev = FOLDER_DEVICE(f)
+ self.do_connect([[dev, None],], is_folder_device=True)
+ except:
+ print 'Unable to open folder as device', f
+ traceback.print_exc()
+ else:
+ self.detect_device()
while True:
job = self.next()
if job is not None:
@@ -182,7 +198,6 @@ class DeviceManager(Thread):
break
time.sleep(self.sleep_time)
-
def create_job(self, func, done, description, args=[], kwargs={}):
job = DeviceJob(func, done, self.job_manager,
args=args, kwargs=kwargs, description=description)
@@ -208,21 +223,19 @@ class DeviceManager(Thread):
return self.create_job(self._get_device_information, done,
description=_('Get device information'))
+ # This will be called on the GUI thread. Because of this, we must store
+ # information that the scanner thread will use to do the real work.
def connect_to_folder(self, path):
- dev = FOLDER_DEVICE(path)
- try:
- dev.open()
- except:
- print 'Unable to open device', dev
- traceback.print_exc()
- return False
- self.connected_device = dev
- self.connected_slot(True)
- return True
+ self.folder_connection_path = path
+ # This is called on the GUI thread. No problem here, because it calls the
+ # device driver, telling it to tell the scanner when it passes by that the
+ # folder has disconnected.
def disconnect_folder(self):
if self.connected_device is not None:
if hasattr(self.connected_device, 'disconnect_from_folder'):
+ # As we are on the wrong thread, this call must *not* do
+ # anything besides set a flag that the right thread will see.
self.connected_device.disconnect_from_folder()
def _books(self):
diff --git a/src/calibre/gui2/ui.py b/src/calibre/gui2/ui.py
index c1e208625b..1aecadba88 100644
--- a/src/calibre/gui2/ui.py
+++ b/src/calibre/gui2/ui.py
@@ -673,7 +673,6 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
dir = choose_dir(self, 'Select Device Folder', 'Select folder to open')
if dir is not None:
self.device_manager.connect_to_folder(dir)
- self._sync_menu.disconnect_from_folder_action.setEnabled(True)
def disconnect_from_folder(self):
self.device_manager.disconnect_folder()
@@ -945,12 +944,14 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
elif model.location_for_row(x) == 'cardb':
self.card_b_view.write_settings()
- def device_detected(self, connected):
+ def device_detected(self, connected, is_folder_device):
'''
Called when a device is connected to the computer.
'''
if connected:
self._sync_menu.connect_to_folder_action.setEnabled(False)
+ if is_folder_device:
+ self._sync_menu.disconnect_from_folder_action.setEnabled(True)
self.device_manager.get_device_information(\
Dispatcher(self.info_read))
self.set_default_thumbnail(\
From 7a9135c8e5687d74e4418f42c1601424ebc963af Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Mon, 17 May 2010 11:18:31 -0600
Subject: [PATCH 115/324] Support loading of device images from paths instead
of keeping them in memory
---
src/calibre/devices/interface.py | 4 +++-
src/calibre/gui2/library.py | 7 +++++--
2 files changed, 8 insertions(+), 3 deletions(-)
diff --git a/src/calibre/devices/interface.py b/src/calibre/devices/interface.py
index 356ebfc876..40cac4d615 100644
--- a/src/calibre/devices/interface.py
+++ b/src/calibre/devices/interface.py
@@ -380,7 +380,9 @@ class BookList(list):
3. size (file size of the book)
4. datetime (a UTC time tuple)
5. path (path on the device to the book)
- 6. thumbnail (can be None)
+ 6. thumbnail (can be None) thumbnail is either a str/bytes object with the
+ image data or it should have an attribute image_path that stores an
+ absolute (platform native) path to the image
7. tags (a list of strings, can be empty).
'''
diff --git a/src/calibre/gui2/library.py b/src/calibre/gui2/library.py
index d08c7d50c8..0c6f7566bd 100644
--- a/src/calibre/gui2/library.py
+++ b/src/calibre/gui2/library.py
@@ -1418,9 +1418,12 @@ class DeviceBooksModel(BooksModel):
data = {}
item = self.db[self.map[current.row()]]
cdata = item.thumbnail
- if cdata:
+ if cdata is not None:
img = QImage()
- img.loadFromData(cdata)
+ if hasattr(cdata, 'image_path'):
+ img.load(cdata.image_path)
+ else:
+ img.loadFromData(cdata)
if img.isNull():
img = self.default_image
data['cover'] = img
From 7900d4dad6a550ebaca1153f83593b940367f5ba Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Mon, 17 May 2010 12:06:07 -0600
Subject: [PATCH 116/324] Use a queue for folder connections requests to make
it more thread safe
---
src/calibre/gui2/device.py | 23 ++++++++++++++---------
1 file changed, 14 insertions(+), 9 deletions(-)
diff --git a/src/calibre/gui2/device.py b/src/calibre/gui2/device.py
index edf0e763f7..1bc35b6a2b 100644
--- a/src/calibre/gui2/device.py
+++ b/src/calibre/gui2/device.py
@@ -20,7 +20,7 @@ from calibre.gui2 import config, error_dialog, Dispatcher, dynamic, \
pixmap_to_data, warning_dialog, \
question_dialog
from calibre.ebooks.metadata import authors_to_string, authors_to_sort_string
-from calibre import preferred_encoding
+from calibre import preferred_encoding, prints
from calibre.utils.filenames import ascii_filename
from calibre.devices.errors import FreeSpaceError
from calibre.utils.smtp import compose_mail, sendmail, extract_email_address, \
@@ -88,7 +88,7 @@ class DeviceManager(Thread):
self.connected_device = None
self.ejected_devices = set([])
self.connected_device_is_folder = False
- self.folder_connection_path = None
+ self.folder_connection_requests = Queue.Queue(0)
def report_progress(self, *args):
pass
@@ -175,15 +175,20 @@ class DeviceManager(Thread):
def run(self):
while self.keep_going:
- if not self.is_device_connected and \
- self.folder_connection_path is not None:
- f = self.folder_connection_path
- self.folder_connection_path = None # Make sure we try this folder only once
+ folder_path = None
+ while True:
try:
- dev = FOLDER_DEVICE(f)
+ folder_path = self.folder_connection_requests.get_nowait()
+ except Queue.Empty:
+ break
+ if not folder_path or not os.access(folder_path, os.R_OK):
+ folder_path = None
+ if not self.is_device_connected and folder_path is not None:
+ try:
+ dev = FOLDER_DEVICE(folder_path)
self.do_connect([[dev, None],], is_folder_device=True)
except:
- print 'Unable to open folder as device', f
+ prints('Unable to open folder as device', folder_path)
traceback.print_exc()
else:
self.detect_device()
@@ -226,7 +231,7 @@ class DeviceManager(Thread):
# This will be called on the GUI thread. Because of this, we must store
# information that the scanner thread will use to do the real work.
def connect_to_folder(self, path):
- self.folder_connection_path = path
+ self.folder_connection_requests.put(path)
# This is called on the GUI thread. No problem here, because it calls the
# device driver, telling it to tell the scanner when it passes by that the
From 78bb3a2753724e82f31f45bf87e6e28a57ae6f6b Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Mon, 17 May 2010 12:31:42 -0600
Subject: [PATCH 117/324] Add menu option to save only current output format to
disk in a single directory
---
src/calibre/gui2/ui.py | 13 +++++++++++++
1 file changed, 13 insertions(+)
diff --git a/src/calibre/gui2/ui.py b/src/calibre/gui2/ui.py
index d27a608bd7..c6365f694c 100644
--- a/src/calibre/gui2/ui.py
+++ b/src/calibre/gui2/ui.py
@@ -348,6 +348,10 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
self.save_menu.addAction(_('Save to disk in a single directory'))
self.save_menu.addAction(_('Save only %s format to disk')%
prefs['output_format'].upper())
+ self.save_menu.addAction(
+ _('Save only %s format to disk in a single directory')%
+ prefs['output_format'].upper())
+
self.save_sub_menu = SaveMenu(self)
self.save_menu.addMenu(self.save_sub_menu)
self.connect(self.save_sub_menu, SIGNAL('save_fmt(PyQt_PyObject)'),
@@ -376,6 +380,8 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
self.save_to_single_dir)
QObject.connect(self.save_menu.actions()[2], SIGNAL("triggered(bool)"),
self.save_single_format_to_disk)
+ QObject.connect(self.save_menu.actions()[3], SIGNAL("triggered(bool)"),
+ self.save_single_fmt_to_single_dir)
QObject.connect(self.action_view, SIGNAL("triggered(bool)"),
self.view_book)
QObject.connect(self.view_menu.actions()[0],
@@ -1810,6 +1816,10 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
def save_to_single_dir(self, checked):
self.save_to_disk(checked, True)
+ def save_single_fmt_to_single_dir(self, *args):
+ self.save_to_disk(False, single_dir=True,
+ single_format=prefs['output_format'])
+
def save_to_disk(self, checked, single_dir=False, single_format=None):
rows = self.current_view().selectionModel().selectedRows()
if not rows or len(rows) == 0:
@@ -2262,6 +2272,9 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
self.save_menu.actions()[2].setText(
_('Save only %s format to disk')%
prefs['output_format'].upper())
+ self.save_menu.actions()[3].setText(
+ _('Save only %s format to disk in a single directory')%
+ prefs['output_format'].upper())
self.library_view.model().read_config()
self.library_view.model().refresh()
self.library_view.model().research()
From 38b1f35ff7c10c812404cdb57f704fb9f0795db7 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Mon, 17 May 2010 12:46:14 -0600
Subject: [PATCH 118/324] Don't print out error messages when no cache is
present on a device
---
src/calibre/devices/usbms/driver.py | 28 ++++++++++++++++------------
1 file changed, 16 insertions(+), 12 deletions(-)
diff --git a/src/calibre/devices/usbms/driver.py b/src/calibre/devices/usbms/driver.py
index 3a30b3c10e..73cca0fb4d 100644
--- a/src/calibre/devices/usbms/driver.py
+++ b/src/calibre/devices/usbms/driver.py
@@ -277,18 +277,22 @@ class USBMS(CLI, Device):
bl = []
js = []
need_sync = False
- try:
- with open(cls.normalize_path(os.path.join(prefix, name)), 'rb') as f:
- js = json.load(f, encoding='utf-8')
- for item in js:
- book = cls.book_class(prefix, item.get('lpath', None))
- for key in item.keys():
- setattr(book, key, item[key])
- bl.append(book)
- except:
- import traceback
- traceback.print_exc()
- bl = []
+ cache_file = cls.normalize_path(os.path.join(prefix, name))
+ if os.access(cache_file, os.R_OK):
+ try:
+ with open(cache_file, 'rb') as f:
+ js = json.load(f, encoding='utf-8')
+ for item in js:
+ book = cls.book_class(prefix, item.get('lpath', None))
+ for key in item.keys():
+ setattr(book, key, item[key])
+ bl.append(book)
+ except:
+ import traceback
+ traceback.print_exc()
+ bl = []
+ need_sync = True
+ else:
need_sync = True
return bl, need_sync
From a5b4012059df8edf5d1b3289f31027b82e6c7c11 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Mon, 17 May 2010 13:06:39 -0600
Subject: [PATCH 119/324] Fix regression taht caused usbms based drivers to not
get correct timestamp for files on device
---
src/calibre/devices/usbms/books.py | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/src/calibre/devices/usbms/books.py b/src/calibre/devices/usbms/books.py
index 59f098d421..879b377b1b 100644
--- a/src/calibre/devices/usbms/books.py
+++ b/src/calibre/devices/usbms/books.py
@@ -38,8 +38,10 @@ class Book(MetaInformation):
self.lpath = lpath
self.mime = mime_type_ext(path_to_ext(lpath))
self.size = size # will be set later if None
- self.datetime = time.gmtime()
-
+ try:
+ self.datetime = time.gmtime(os.path.getctime(self.path))
+ except:
+ self.datetime = time.gmtime()
if other:
self.smart_update(other)
From 63e9296f3b1dee0bb366eaa143bf3e06aab8d6ac Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Mon, 17 May 2010 13:17:09 -0600
Subject: [PATCH 120/324] Ask for confirmation if the user tries to open the
containing folder for more than 3 books at a time
---
src/calibre/gui2/ui.py | 33 +++++++++++++++++++--------------
1 file changed, 19 insertions(+), 14 deletions(-)
diff --git a/src/calibre/gui2/ui.py b/src/calibre/gui2/ui.py
index c6365f694c..16b003a5c6 100644
--- a/src/calibre/gui2/ui.py
+++ b/src/calibre/gui2/ui.py
@@ -2162,14 +2162,25 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
format = d.format()
self.view_format(row, format)
+ def _view_check(self, num, max_=3):
+ if num <= max_:
+ return True
+ return question_dialog(self, _('Multiple Books Selected'),
+ _('You are attempting to open %d books. Opening too many '
+ 'books at once can be slow and have a negative effect on the '
+ 'responsiveness of your computer. Once started the process '
+ 'cannot be stopped until complete. Do you wish to continue?'
+ ) % num)
+
def view_folder(self, *args):
rows = self.current_view().selectionModel().selectedRows()
- if self.current_view() is self.library_view:
- if not rows or len(rows) == 0:
- d = error_dialog(self, _('Cannot open folder'),
- _('No book selected'))
- d.exec_()
- return
+ if not rows or len(rows) == 0:
+ d = error_dialog(self, _('Cannot open folder'),
+ _('No book selected'))
+ d.exec_()
+ return
+ if not self._view_check(len(rows)):
+ return
for row in rows:
path = self.library_view.model().db.abspath(row.row())
QDesktopServices.openUrl(QUrl.fromLocalFile(path))
@@ -2187,14 +2198,8 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
self._launch_viewer()
return
- if len(rows) >= 3:
- if not question_dialog(self, _('Multiple Books Selected'),
- _('You are attempting to open %d books. Opening too many '
- 'books at once can be slow and have a negative effect on the '
- 'responsiveness of your computer. Once started the process '
- 'cannot be stopped until complete. Do you wish to continue?'
- )% len(rows)):
- return
+ if not self._view_check(len(rows)):
+ return
if self.current_view() is self.library_view:
for row in rows:
From ce023e2c563d65c5c1a029c27c9bd4576c689fd0 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Mon, 17 May 2010 14:00:43 -0600
Subject: [PATCH 121/324] Ensure sort indicator is correct after a column is
added or removed
---
src/calibre/gui2/library.py | 15 +++++++++++----
1 file changed, 11 insertions(+), 4 deletions(-)
diff --git a/src/calibre/gui2/library.py b/src/calibre/gui2/library.py
index 0c6f7566bd..b5d2d653e5 100644
--- a/src/calibre/gui2/library.py
+++ b/src/calibre/gui2/library.py
@@ -29,7 +29,7 @@ from calibre.utils.date import dt_factory, qt_to_dt, isoformat, now
from calibre.utils.pyparsing import ParseException
from calibre.utils.search_query_parser import SearchQueryParser
-
+# Delegates {{{
class RatingDelegate(QStyledItemDelegate):
COLOR = QColor("blue")
SIZE = 16
@@ -303,7 +303,9 @@ class CcBoolDelegate(QStyledItemDelegate):
val = 2 if val is None else 1 if not val else 0
editor.setCurrentIndex(val)
-class BooksModel(QAbstractTableModel):
+# }}}
+
+class BooksModel(QAbstractTableModel): # {{{
about_to_be_sorted = pyqtSignal(object, name='aboutToBeSorted')
sorting_done = pyqtSignal(object, name='sortingDone')
@@ -973,13 +975,13 @@ class BooksModel(QAbstractTableModel):
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)
@@ -1084,6 +1086,11 @@ class BooksView(TableView):
if not self.restore_column_widths():
self.resizeColumnsToContents()
+ sort_col = self._model.sorted_on[0]
+ if sort_col in cm:
+ idx = cm.index(sort_col)
+ self.horizontalHeader().setSortIndicator(idx, self._model.sorted_on[1])
+
def set_context_menu(self, edit_metadata, send_to_device, convert, view,
save, open_folder, book_details, delete, similar_menu=None):
self.setContextMenuPolicy(Qt.DefaultContextMenu)
From d393b430bdfd9601e14f84ad8facf841fce7e979 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Mon, 17 May 2010 16:04:30 -0600
Subject: [PATCH 122/324] Start to move column display logic into the view
classes, where it belongs
---
src/calibre/gui2/library.py | 148 ++++++++++++++++++------------------
src/calibre/gui2/ui.py | 25 +-----
2 files changed, 77 insertions(+), 96 deletions(-)
diff --git a/src/calibre/gui2/library.py b/src/calibre/gui2/library.py
index b5d2d653e5..806b5851bc 100644
--- a/src/calibre/gui2/library.py
+++ b/src/calibre/gui2/library.py
@@ -19,7 +19,7 @@ from PyQt4.QtCore import QAbstractTableModel, QVariant, Qt, pyqtSignal, \
from calibre import strftime
from calibre.ebooks.metadata import fmt_sidx, authors_to_string, string_to_authors
from calibre.ebooks.metadata.meta import set_metadata as _set_metadata
-from calibre.gui2 import NONE, TableView, config, error_dialog, UNDEFINED_QDATE
+from calibre.gui2 import NONE, config, error_dialog, UNDEFINED_QDATE
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
@@ -30,6 +30,15 @@ from calibre.utils.pyparsing import ParseException
from calibre.utils.search_query_parser import SearchQueryParser
# Delegates {{{
+
+class DummyDelegate(QStyledItemDelegate):
+
+ def sizeHint(self, option, index):
+ return QSize(0, 0)
+
+ def paint(self, painter, option, index):
+ pass
+
class RatingDelegate(QStyledItemDelegate):
COLOR = QColor("blue")
SIZE = 16
@@ -313,6 +322,7 @@ class BooksModel(QAbstractTableModel): # {{{
orig_headers = {
'title' : _("Title"),
+ 'ondevice' : _("On Device"),
'authors' : _("Author(s)"),
'size' : _("Size (MB)"),
'timestamp' : _("Date"),
@@ -321,7 +331,6 @@ class BooksModel(QAbstractTableModel): # {{{
'publisher' : _("Publisher"),
'tags' : _("Tags"),
'series' : _("Series"),
- 'ondevice' : _("On Device"),
}
def __init__(self, parent=None, buffer=40):
@@ -342,6 +351,7 @@ class BooksModel(QAbstractTableModel): # {{{
self.bool_no_icon = QIcon(I('list_remove.svg'))
self.bool_blank_icon = QIcon(I('blank.svg'))
self.device_connected = False
+ self.read_config()
def is_custom_column(self, cc_label):
return cc_label in self.custom_columns
@@ -352,29 +362,10 @@ class BooksModel(QAbstractTableModel): # {{{
def read_config(self):
self.use_roman_numbers = config['use_roman_numerals_for_series_number']
- cmap = config['column_map'][:] # force a copy
- self.headers = {}
- self.column_map = []
- for col in cmap: # take out any columns no longer in the db
- if col == 'ondevice':
- if self.device_connected:
- self.column_map.append(col)
- elif col in self.orig_headers or col in self.custom_columns:
- self.column_map.append(col)
- for col in self.column_map:
- if col in self.orig_headers:
- self.headers[col] = self.orig_headers[col]
- elif col in self.custom_columns:
- self.headers[col] = self.custom_columns[col]['name']
- self.build_data_convertors()
- self.reset()
- self.emit(SIGNAL('columns_sorted()'))
def set_device_connected(self, is_connected):
self.device_connected = is_connected
- self.read_config()
self.db.refresh_ondevice()
- self.database_changed.emit(self.db)
def set_book_on_device_func(self, func):
self.book_on_device = func
@@ -382,7 +373,24 @@ class BooksModel(QAbstractTableModel): # {{{
def set_database(self, db):
self.db = db
self.custom_columns = self.db.custom_column_label_map
- self.read_config()
+ self.column_map = list(self.orig_headers.keys()) + \
+ list(self.custom_columns)
+ def col_idx(name):
+ if name == 'ondevice':
+ return -1
+ if name not in self.db.FIELD_MAP:
+ return 100000
+ return self.db.FIELD_MAP[name]
+
+ self.column_map.sort(cmp=lambda x,y: cmp(col_idx(x), col_idx(y)))
+ for col in self.column_map:
+ if col in self.orig_headers:
+ self.headers[col] = self.orig_headers[col]
+ elif col in self.custom_columns:
+ self.headers[col] = self.custom_columns[col]['name']
+
+ self.build_data_convertors()
+ self.reset()
self.database_changed.emit(db)
def refresh_ids(self, ids, current_row=-1):
@@ -982,7 +990,7 @@ class BooksModel(QAbstractTableModel): # {{{
# }}}
-class BooksView(TableView):
+class BooksView(QTableView): # {{{
TIME_FMT = '%d %b %Y'
wrapper = textwrap.TextWrapper(width=20)
@@ -997,7 +1005,7 @@ class BooksView(TableView):
return ('%.'+str(precision)+'f') % ((size/(1024.*1024.)),)
def __init__(self, parent, modelcls=BooksModel):
- TableView.__init__(self, parent)
+ QTableView.__init__(self, parent)
self.rating_delegate = RatingDelegate(self)
self.timestamp_delegate = DateDelegate(self)
self.pubdate_delegate = PubDateDelegate(self)
@@ -1005,6 +1013,7 @@ class BooksView(TableView):
self.authors_delegate = TextDelegate(self)
self.series_delegate = TextDelegate(self)
self.publisher_delegate = TextDelegate(self)
+ self.text_delegate = TextDelegate(self)
self.cc_text_delegate = CcTextDelegate(self)
self.cc_bool_delegate = CcBoolDelegate(self)
self.cc_comments_delegate = CcCommentsDelegate(self)
@@ -1013,13 +1022,9 @@ class BooksView(TableView):
self.setModel(self._model)
self.setSelectionBehavior(QAbstractItemView.SelectRows)
self.setSortingEnabled(True)
- for i in range(10):
- self.setItemDelegateForColumn(i, TextDelegate(self))
- self.columns_sorted()
- QObject.connect(self.selectionModel(), SIGNAL('currentRowChanged(QModelIndex, QModelIndex)'),
- self._model.current_changed)
- self.connect(self._model, SIGNAL('columns_sorted()'),
- self.columns_sorted, Qt.QueuedConnection)
+ self.selectionModel().currentRowChanged.connect(self._model.current_changed)
+ self.column_header = self.horizontalHeader()
+ self._model.database_changed.connect(self.database_changed)
hv = self.verticalHeader()
hv.setClickable(True)
hv.setCursor(Qt.PointingHandCursor)
@@ -1040,56 +1045,49 @@ class BooksView(TableView):
sm.select(idx, sm.Select|sm.Rows)
self.selected_ids = []
- def columns_sorted(self):
+ def set_ondevice_column_visibility(self):
+ m = self._model
+ self.column_header.setSectionHidden(m.column_map.index('ondevice'),
+ not m.device_connected)
+
+ def set_device_connected(self, is_connected):
+ self._model.set_device_connected(is_connected)
+ self.set_ondevice_column_visibility()
+
+ def database_changed(self, db):
for i in range(self.model().columnCount(None)):
if self.itemDelegateForColumn(i) in (self.rating_delegate,
self.timestamp_delegate, self.pubdate_delegate):
self.setItemDelegateForColumn(i, self.itemDelegate())
cm = self._model.column_map
+ self.set_ondevice_column_visibility()
- if 'rating' in cm:
- self.setItemDelegateForColumn(cm.index('rating'), self.rating_delegate)
- if 'timestamp' in cm:
- self.setItemDelegateForColumn(cm.index('timestamp'), self.timestamp_delegate)
- if 'pubdate' in cm:
- self.setItemDelegateForColumn(cm.index('pubdate'), self.pubdate_delegate)
- if 'tags' in cm:
- self.setItemDelegateForColumn(cm.index('tags'), self.tags_delegate)
- if 'authors' in cm:
- self.setItemDelegateForColumn(cm.index('authors'), self.authors_delegate)
- if 'publisher' in cm:
- 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':
- delegate = CcDateDelegate(self)
- delegate.set_format(cc['display'].get('date_format',''))
- self.setItemDelegateForColumn(cm.index(colhead), 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:
+ if self._model.is_custom_column(colhead):
+ cc = self._model.custom_columns[colhead]
+ if cc['datatype'] == 'datetime':
+ delegate = CcDateDelegate(self)
+ delegate.set_format(cc['display'].get('date_format',''))
+ self.setItemDelegateForColumn(cm.index(colhead), 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'] 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)
- if not self.restore_column_widths():
- self.resizeColumnsToContents()
-
- sort_col = self._model.sorted_on[0]
- if sort_col in cm:
- idx = cm.index(sort_col)
- self.horizontalHeader().setSortIndicator(idx, self._model.sorted_on[1])
+ 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)
+ else:
+ dattr = colhead+'_delegate'
+ delegate = colhead if hasattr(self, dattr) else 'text'
+ self.setItemDelegateForColumn(cm.index(colhead), getattr(self,
+ delegate+'_delegate'))
def set_context_menu(self, edit_metadata, send_to_device, convert, view,
save, open_folder, book_details, delete, similar_menu=None):
@@ -1131,7 +1129,7 @@ class BooksView(TableView):
idx = self._model.column_map.index(colname)
except ValueError:
idx = 0
- TableView.sortByColumn(self, idx, order)
+ QTableView.sortByColumn(self, idx, order)
@classmethod
def paths_from_event(cls, event):
@@ -1195,6 +1193,8 @@ class BooksView(TableView):
def row_count(self):
return self._model.count()
+# }}}
+
class DeviceBooksView(BooksView):
def __init__(self, parent):
@@ -1218,7 +1218,7 @@ class DeviceBooksView(BooksView):
QObject.connect(self._model, SIGNAL('booklist_dirtied()'), slot)
def sortByColumn(self, col, order):
- TableView.sortByColumn(self, col, order)
+ QTableView.sortByColumn(self, col, order)
def dropEvent(self, *args):
error_dialog(self, _('Not allowed'),
diff --git a/src/calibre/gui2/ui.py b/src/calibre/gui2/ui.py
index 16b003a5c6..c65a1ba81c 100644
--- a/src/calibre/gui2/ui.py
+++ b/src/calibre/gui2/ui.py
@@ -535,8 +535,6 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
self.library_view.model().set_book_on_device_func(self.book_on_device)
prefs['library_path'] = self.library_path
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)
self.cover_cache = CoverCache(self.library_path)
self.cover_cache.start()
@@ -943,7 +941,8 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
def save_device_view_settings(self):
model = self.location_view.model()
- self.memory_view.write_settings()
+ return
+ #self.memory_view.write_settings()
for x in range(model.rowCount()):
if x > 1:
if model.location_for_row(x) == 'carda':
@@ -1030,10 +1029,6 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
self.card_b_view.set_editable(self.device_manager.device.CAN_SET_METADATA)
for view in (self.memory_view, self.card_a_view, self.card_b_view):
view.sortByColumn(3, Qt.DescendingOrder)
- view.read_settings()
- if not view.restore_column_widths():
- view.resizeColumnsToContents()
- view.resize_on_select = not view.isVisible()
if view.model().rowCount(None) > 1:
view.resizeRowToContents(0)
height = view.rowHeight(0)
@@ -1048,8 +1043,7 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
self.book_on_device(None, reset=True)
if reset_only:
return
- self.library_view.write_settings()
- self.library_view.model().set_device_connected(device_connected)
+ self.library_view.set_device_connected(device_connected)
############################################################################
######################### Fetch annotations ################################
@@ -2262,8 +2256,6 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
return
d = ConfigDialog(self, self.library_view.model(),
server=self.content_server)
- # Save current column widths in case columns are turned on or off
- self.library_view.write_settings()
d.exec_()
self.content_server = d.server
@@ -2328,14 +2320,6 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
'''
page = 0 if location == 'library' else 1 if location == 'main' else 2 if location == 'carda' else 3
self.stack.setCurrentIndex(page)
- view = self.memory_view if page == 1 else \
- self.card_a_view if page == 2 else \
- self.card_b_view if page == 3 else None
- if view:
- if view.resize_on_select:
- if not view.restore_column_widths():
- view.resizeColumnsToContents()
- view.resize_on_select = False
self.status_bar.reset_info()
self.sidebar.location_changed(location)
if location == 'library':
@@ -2442,9 +2426,6 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
config.set('main_window_geometry', self.saveGeometry())
dynamic.set('sort_history', self.library_view.model().sort_history)
self.sidebar.save_state()
- self.library_view.write_settings()
- if self.device_connected:
- self.save_device_view_settings()
def restart(self):
self.quit(restart=True)
From 3047defba92f8bc16439a3441cf430ee4b6e679c Mon Sep 17 00:00:00 2001
From: Charles Haley <>
Date: Mon, 17 May 2010 23:35:13 +0100
Subject: [PATCH 123/324] Changes to usbms to use correct booklist.add_book api
---
src/calibre/devices/usbms/books.py | 3 +-
src/calibre/devices/usbms/driver.py | 65 ++++++++++++++---------------
src/calibre/utils/windows/Makefile | 10 ++---
3 files changed, 39 insertions(+), 39 deletions(-)
diff --git a/src/calibre/devices/usbms/books.py b/src/calibre/devices/usbms/books.py
index 59f098d421..ced46ea2a1 100644
--- a/src/calibre/devices/usbms/books.py
+++ b/src/calibre/devices/usbms/books.py
@@ -117,7 +117,8 @@ class BookList(_BookList):
def add_book(self, book, replace_metadata):
if book not in self:
self.append(book)
- return False # subclasses return True if device metadata has changed
+ return True
+ return False
def remove_book(self, book):
self.remove(book)
diff --git a/src/calibre/devices/usbms/driver.py b/src/calibre/devices/usbms/driver.py
index 3a30b3c10e..1291ffa834 100644
--- a/src/calibre/devices/usbms/driver.py
+++ b/src/calibre/devices/usbms/driver.py
@@ -66,16 +66,14 @@ class USBMS(CLI, Device):
self.EBOOK_DIR_CARD_B if oncard == 'cardb' else \
self.get_main_ebook_dir()
- # build a temporary list of books from the metadata cache
- bl, need_sync = self.parse_metadata_cache(prefix, self.METADATA_CACHE)
+ # get the metadata cache
+ bl = self.booklist_class(oncard, prefix, self.settings)
+ need_sync = self.parse_metadata_cache(bl, prefix, self.METADATA_CACHE)
+
# make a dict cache of paths so the lookup in the loop below is faster.
bl_cache = {}
for idx,b in enumerate(bl):
bl_cache[b.lpath] = idx
- self.count_found_in_bl = 0
-
- # Make the real booklist that will be filled in below
- metadata = self.booklist_class(oncard, prefix, self.settings)
def update_booklist(filename, path, prefix):
changed = False
@@ -86,13 +84,14 @@ class USBMS(CLI, Device):
lpath = lpath[len(os.sep):]
idx = bl_cache.get(lpath.replace('\\', '/'), None)
if idx is not None:
- item, changed = self.update_metadata_item(bl[idx])
- self.count_found_in_bl += 1
+ if self.update_metadata_item(bl[idx]):
+ #print 'update_metadata_item returned true'
+ changed = True
+ bl_cache[lpath.replace('\\', '/')] = None
else:
- item = self.book_from_path(prefix, lpath)
- changed = True
- if metadata.add_book(item, replace_metadata=False):
- changed = True
+ if bl.add_book(self.book_from_path(prefix, lpath),
+ replace_metadata=False):
+ changed = True
except: # Probably a filename encoding error
import traceback
traceback.print_exc()
@@ -126,23 +125,23 @@ class USBMS(CLI, Device):
if changed:
need_sync = True
- # if count != len(bl) then there were items in it that we did not
- # find on the device. If need_sync is True then there were either items
- # on the device that were not in bl or some of the items were changed.
+ for val in bl_cache.itervalues():
+ if val is not None:
+ need_sync = True
+ del bl[val]
- #print "count found in cache: %d, count of files in cache: %d, need_sync: %s, must_sync_cache: %s" % \
- # (self.count_found_in_bl, len(bl), need_sync,
- # need_sync or self.count_found_in_bl != len(bl))
- if self.count_found_in_bl != len(bl) or need_sync:
+ #print "count found in cache: %d, count of files in metadata: %d, need_sync: %s" % \
+ # (len(bl_cache), len(bl), need_sync)
+ if need_sync: #self.count_found_in_bl != len(bl) or need_sync:
if oncard == 'cardb':
- self.sync_booklists((None, None, metadata))
+ self.sync_booklists((None, None, bl))
elif oncard == 'carda':
- self.sync_booklists((None, metadata, None))
+ self.sync_booklists((None, bl, None))
else:
- self.sync_booklists((metadata, None, None))
+ self.sync_booklists((bl, None, None))
self.report_progress(1.0, _('Getting list of books on device...'))
- return metadata
+ return bl
def upload_books(self, files, names, on_card=None, end_session=True,
metadata=None):
@@ -273,8 +272,8 @@ class USBMS(CLI, Device):
return path
@classmethod
- def parse_metadata_cache(cls, prefix, name):
- bl = []
+ def parse_metadata_cache(cls, bl, prefix, name):
+ # bl = cls.booklist_class()
js = []
need_sync = False
try:
@@ -290,18 +289,18 @@ class USBMS(CLI, Device):
traceback.print_exc()
bl = []
need_sync = True
- return bl, need_sync
+ return need_sync
@classmethod
- def update_metadata_item(cls, item):
+ def update_metadata_item(cls, book):
changed = False
- size = os.stat(cls.normalize_path(item.path)).st_size
- if size != item.size:
+ size = os.stat(cls.normalize_path(book.path)).st_size
+ if size != book.size:
changed = True
- mi = cls.metadata_from_path(item.path)
- item.smart_update(mi)
- item.size = size
- return item, changed
+ mi = cls.metadata_from_path(book.path)
+ book.smart_update(mi)
+ book.size = size
+ return changed
@classmethod
def metadata_from_path(cls, path):
diff --git a/src/calibre/utils/windows/Makefile b/src/calibre/utils/windows/Makefile
index 6e2dc51a7e..51e8078471 100644
--- a/src/calibre/utils/windows/Makefile
+++ b/src/calibre/utils/windows/Makefile
@@ -2,18 +2,18 @@
# Invoke with nmake /f Makefile.winutil
test : winutil.pyd
- python.exe -c "import winutil; winutil.set_debug(True); print repr(winutil.strftime(u'%b %a %A')); "
+ \python26\python.exe -c "import winutil; winutil.set_debug(True); print repr(winutil.strftime(u'%b %a %A')); "
#python.exe -c "import winutil; winutil.set_debug(True); print winutil.get_usb_devices(); print winutil.get_mounted_volumes_for_usb_device(0x054c, 0x031e)"
winutil.pyd : winutil.obj
- link.exe /DLL /nologo /INCREMENTAL:NO /LIBPATH:c:\Python25\libs \
- /LIBPATH:c:\Python25\PCBuild shell32.lib setupapi.lib /EXPORT:initwinutil \
+ link.exe /DLL /nologo /INCREMENTAL:NO /LIBPATH:c:\Python26\libs \
+ /LIBPATH:c:\Python26\PCBuild shell32.lib setupapi.lib Wininet.lib /EXPORT:initwinutil \
winutil.obj /OUT:winutil.pyd
winutil.obj : winutil.c
- cl.exe /c /nologo /Ox /MD /W3 /GX /DNDEBUG -Ic:\Python25\include \
- -Ic:\Python25\PC -Ic:\WinDDK\6001.18001\inc\api /Tcwinutil.c /Fowinutil.obj
+ cl.exe /c /nologo /Ox /MD /W3 /GX /DNDEBUG -Ic:\Python26\include \
+ -Ic:\Python26\PC -Ic:\WinDDK\6001.18001\inc\api /Tcwinutil.c /Fowinutil.obj
clean :
del winutil.pyd winutil.obj winutil.exp winutil.lib
From 83c75cd864dcae6e3c142c97cce3c581ac53abcf Mon Sep 17 00:00:00 2001
From: Charles Haley <>
Date: Mon, 17 May 2010 23:55:56 +0100
Subject: [PATCH 124/324] Finish using booklist api correctly
---
src/calibre/devices/usbms/driver.py | 15 ++++++++++-----
1 file changed, 10 insertions(+), 5 deletions(-)
diff --git a/src/calibre/devices/usbms/driver.py b/src/calibre/devices/usbms/driver.py
index 4589b48af3..361ee2300b 100644
--- a/src/calibre/devices/usbms/driver.py
+++ b/src/calibre/devices/usbms/driver.py
@@ -82,13 +82,15 @@ class USBMS(CLI, Device):
lpath = os.path.join(path, filename).partition(self.normalize_path(prefix))[2]
if lpath.startswith(os.sep):
lpath = lpath[len(os.sep):]
- idx = bl_cache.get(lpath.replace('\\', '/'), None)
+ lpath = lpath.replace('\\', '/')
+ idx = bl_cache.get(lpath, None)
if idx is not None:
if self.update_metadata_item(bl[idx]):
#print 'update_metadata_item returned true'
changed = True
- bl_cache[lpath.replace('\\', '/')] = None
+ bl_cache[lpath] = None
else:
+ #print "adding new book", lpath
if bl.add_book(self.book_from_path(prefix, lpath),
replace_metadata=False):
changed = True
@@ -125,10 +127,13 @@ class USBMS(CLI, Device):
if changed:
need_sync = True
- for val in bl_cache.itervalues():
- if val is not None:
+ # Remove books that are no longer in the filesystem. Cache contains
+ # indices into the booklist if book not in filesystem, None otherwise
+ # Do the operation in reverse order so indices remain valid
+ for idx in bl_cache.itervalues().reversed():
+ if idx is not None:
need_sync = True
- del bl[val]
+ del bl[idx]
#print "count found in cache: %d, count of files in metadata: %d, need_sync: %s" % \
# (len(bl_cache), len(bl), need_sync)
From d600ef514bb9c52133fbd092b2dfb4ec1a3351f9 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Mon, 17 May 2010 17:17:42 -0600
Subject: [PATCH 125/324] Re-organize code in gui2.library module
---
src/calibre/gui2/dialogs/scheduler.py | 3 +-
src/calibre/gui2/library/__init__.py | 9 +
src/calibre/gui2/library/delegates.py | 311 +++++++++
.../gui2/{library.py => library/models.py} | 607 ++----------------
src/calibre/gui2/library/views.py | 242 +++++++
src/calibre/gui2/lrf_renderer/main.py | 2 +-
src/calibre/gui2/main.ui | 4 +-
src/calibre/gui2/search_box.py | 11 +-
src/calibre/gui2/tag_view.py | 3 +-
src/calibre/gui2/ui.py | 26 +-
src/calibre/gui2/viewer/main.py | 2 +-
11 files changed, 632 insertions(+), 588 deletions(-)
create mode 100644 src/calibre/gui2/library/__init__.py
create mode 100644 src/calibre/gui2/library/delegates.py
rename src/calibre/gui2/{library.py => library/models.py} (62%)
create mode 100644 src/calibre/gui2/library/views.py
diff --git a/src/calibre/gui2/dialogs/scheduler.py b/src/calibre/gui2/dialogs/scheduler.py
index 74ae400524..7e2d75e9e7 100644
--- a/src/calibre/gui2/dialogs/scheduler.py
+++ b/src/calibre/gui2/dialogs/scheduler.py
@@ -32,8 +32,7 @@ class SchedulerDialog(QDialog, Ui_Dialog):
self.search.setMinimumContentsLength(25)
self.search.initialize('scheduler_search_history')
self.recipe_box.layout().insertWidget(0, self.search)
- self.connect(self.search, SIGNAL('search(PyQt_PyObject,PyQt_PyObject)'),
- self.recipe_model.search)
+ self.search.search.connect(self.recipe_model.search)
self.connect(self.recipe_model, SIGNAL('searched(PyQt_PyObject)'),
self.search.search_done)
self.connect(self.recipe_model, SIGNAL('searched(PyQt_PyObject)'),
diff --git a/src/calibre/gui2/library/__init__.py b/src/calibre/gui2/library/__init__.py
new file mode 100644
index 0000000000..0080175bfa
--- /dev/null
+++ b/src/calibre/gui2/library/__init__.py
@@ -0,0 +1,9 @@
+#!/usr/bin/env python
+# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai
+
+__license__ = 'GPL v3'
+__copyright__ = '2010, Kovid Goyal '
+__docformat__ = 'restructuredtext en'
+
+
+
diff --git a/src/calibre/gui2/library/delegates.py b/src/calibre/gui2/library/delegates.py
new file mode 100644
index 0000000000..c1e4915db1
--- /dev/null
+++ b/src/calibre/gui2/library/delegates.py
@@ -0,0 +1,311 @@
+#!/usr/bin/env python
+# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai
+
+__license__ = 'GPL v3'
+__copyright__ = '2010, Kovid Goyal '
+__docformat__ = 'restructuredtext en'
+
+import sys
+from math import cos, sin, pi
+
+from PyQt4.Qt import QColor, Qt, QModelIndex, QSize, \
+ QPainterPath, QLinearGradient, QBrush, \
+ QPen, QStyle, QPainter, QStyleOptionViewItemV4, \
+ QIcon, QDoubleSpinBox, QVariant, QSpinBox, \
+ QStyledItemDelegate, QCompleter, \
+ QComboBox
+
+from calibre.gui2 import UNDEFINED_QDATE
+from calibre.gui2.widgets import EnLineEdit, TagsLineEdit
+from calibre.utils.date import now
+from calibre.utils.config import tweaks
+from calibre.gui2.dialogs.comments_dialog import CommentsDialog
+
+class RatingDelegate(QStyledItemDelegate): # {{{
+ COLOR = QColor("blue")
+ SIZE = 16
+ PEN = QPen(COLOR, 1, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin)
+
+ def __init__(self, parent):
+ QStyledItemDelegate.__init__(self, parent)
+ self._parent = parent
+ self.dummy = QModelIndex()
+ self.star_path = QPainterPath()
+ self.star_path.moveTo(90, 50)
+ for i in range(1, 5):
+ self.star_path.lineTo(50 + 40 * cos(0.8 * i * pi), \
+ 50 + 40 * sin(0.8 * i * pi))
+ self.star_path.closeSubpath()
+ self.star_path.setFillRule(Qt.WindingFill)
+ gradient = QLinearGradient(0, 0, 0, 100)
+ gradient.setColorAt(0.0, self.COLOR)
+ gradient.setColorAt(1.0, self.COLOR)
+ self.brush = QBrush(gradient)
+ self.factor = self.SIZE/100.
+
+ def sizeHint(self, option, index):
+ #num = index.model().data(index, Qt.DisplayRole).toInt()[0]
+ return QSize(5*(self.SIZE), self.SIZE+4)
+
+ def paint(self, painter, option, index):
+ style = self._parent.style()
+ option = QStyleOptionViewItemV4(option)
+ self.initStyleOption(option, self.dummy)
+ num = index.model().data(index, Qt.DisplayRole).toInt()[0]
+ def draw_star():
+ painter.save()
+ painter.scale(self.factor, self.factor)
+ painter.translate(50.0, 50.0)
+ painter.rotate(-20)
+ painter.translate(-50.0, -50.0)
+ painter.drawPath(self.star_path)
+ painter.restore()
+
+ painter.save()
+ if hasattr(QStyle, 'CE_ItemViewItem'):
+ style.drawControl(QStyle.CE_ItemViewItem, option,
+ painter, self._parent)
+ elif option.state & QStyle.State_Selected:
+ painter.fillRect(option.rect, option.palette.highlight())
+ try:
+ painter.setRenderHint(QPainter.Antialiasing)
+ painter.setClipRect(option.rect)
+ y = option.rect.center().y()-self.SIZE/2.
+ x = option.rect.left()
+ painter.setPen(self.PEN)
+ painter.setBrush(self.brush)
+ painter.translate(x, y)
+ i = 0
+ while i < num:
+ draw_star()
+ painter.translate(self.SIZE, 0)
+ i += 1
+ except:
+ import traceback
+ traceback.print_exc()
+ painter.restore()
+
+ def createEditor(self, parent, option, index):
+ sb = QStyledItemDelegate.createEditor(self, parent, option, index)
+ sb.setMinimum(0)
+ sb.setMaximum(5)
+ return sb
+# }}}
+
+class DateDelegate(QStyledItemDelegate): # {{{
+
+ def displayText(self, val, locale):
+ d = val.toDate()
+ if d == UNDEFINED_QDATE:
+ return ''
+ return d.toString('dd MMM yyyy')
+
+ def createEditor(self, parent, option, index):
+ qde = QStyledItemDelegate.createEditor(self, parent, option, index)
+ stdformat = unicode(qde.displayFormat())
+ if 'yyyy' not in stdformat:
+ stdformat = stdformat.replace('yy', 'yyyy')
+ qde.setDisplayFormat(stdformat)
+ qde.setMinimumDate(UNDEFINED_QDATE)
+ qde.setSpecialValueText(_('Undefined'))
+ qde.setCalendarPopup(True)
+ return qde
+# }}}
+
+class PubDateDelegate(QStyledItemDelegate): # {{{
+
+ def displayText(self, val, locale):
+ d = val.toDate()
+ if d == UNDEFINED_QDATE:
+ return ''
+ format = tweaks['gui_pubdate_display_format']
+ if format is None:
+ format = 'MMM yyyy'
+ return d.toString(format)
+
+ def createEditor(self, parent, option, index):
+ qde = QStyledItemDelegate.createEditor(self, parent, option, index)
+ qde.setDisplayFormat('MM yyyy')
+ qde.setMinimumDate(UNDEFINED_QDATE)
+ qde.setSpecialValueText(_('Undefined'))
+ qde.setCalendarPopup(True)
+ return qde
+
+# }}}
+
+class TextDelegate(QStyledItemDelegate): # {{{
+ def __init__(self, parent):
+ '''
+ Delegate for text data. If auto_complete_function needs to return a list
+ of text items to auto-complete with. The funciton is None no
+ auto-complete will be used.
+ '''
+ QStyledItemDelegate.__init__(self, parent)
+ self.auto_complete_function = None
+
+ def set_auto_complete_function(self, f):
+ self.auto_complete_function = f
+
+ def createEditor(self, parent, option, index):
+ editor = EnLineEdit(parent)
+ if self.auto_complete_function:
+ complete_items = [i[1] for i in self.auto_complete_function()]
+ completer = QCompleter(complete_items, self)
+ completer.setCaseSensitivity(Qt.CaseInsensitive)
+ completer.setCompletionMode(QCompleter.InlineCompletion)
+ editor.setCompleter(completer)
+ return editor
+#}}}
+
+class TagsDelegate(QStyledItemDelegate): # {{{
+ def __init__(self, parent):
+ QStyledItemDelegate.__init__(self, parent)
+ self.db = None
+
+ def set_database(self, db):
+ self.db = db
+
+ def createEditor(self, parent, option, index):
+ if self.db:
+ 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 CcDateDelegate(QStyledItemDelegate): # {{{
+ '''
+ Delegate for custom columns dates. Because this delegate stores the
+ format as an instance variable, a new instance must be created for each
+ column. This differs from all the other delegates.
+ '''
+
+ def set_format(self, format):
+ if not format:
+ self.format = 'dd MMM yyyy'
+ else:
+ self.format = format
+
+ def displayText(self, val, locale):
+ d = val.toDate()
+ if d == UNDEFINED_QDATE:
+ return ''
+ return d.toString(self.format)
+
+ def createEditor(self, parent, option, index):
+ qde = QStyledItemDelegate.createEditor(self, parent, option, index)
+ qde.setDisplayFormat(self.format)
+ qde.setMinimumDate(UNDEFINED_QDATE)
+ qde.setSpecialValueText(_('Undefined'))
+ qde.setCalendarPopup(True)
+ return qde
+
+ 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 val is None:
+ val = now()
+ editor.setDate(val)
+
+ def setModelData(self, editor, model, index):
+ val = editor.date()
+ if val == UNDEFINED_QDATE:
+ val = None
+ model.setData(index, QVariant(val), Qt.EditRole)
+
+# }}}
+
+class CcTextDelegate(QStyledItemDelegate): # {{{
+ '''
+ Delegate for text/int/float data.
+ '''
+
+ def createEditor(self, parent, option, index):
+ m = index.model()
+ col = m.column_map[index.column()]
+ typ = m.custom_columns[col]['datatype']
+ if typ == 'int':
+ editor = QSpinBox(parent)
+ editor.setRange(-100, sys.maxint)
+ editor.setSpecialValueText(_('Undefined'))
+ editor.setSingleStep(1)
+ elif typ == 'float':
+ editor = QDoubleSpinBox(parent)
+ editor.setSpecialValueText(_('Undefined'))
+ editor.setRange(-100., float(sys.maxint))
+ editor.setDecimals(2)
+ else:
+ editor = EnLineEdit(parent)
+ 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): # {{{
+ '''
+ Delegate for comments data.
+ '''
+
+ 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.toPlainText()), 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):
+ editor = QComboBox(parent)
+ items = [_('Y'), _('N'), ' ']
+ icons = [I('ok.svg'), I('list_remove.svg'), I('blank.svg')]
+ if tweaks['bool_custom_columns_are_tristate'] == 'no':
+ items = items[:-1]
+ icons = icons[:-1]
+ for icon, text in zip(icons, items):
+ editor.addItem(QIcon(icon), text)
+ return editor
+
+ def setModelData(self, editor, model, index):
+ val = {0:True, 1:False, 2:None}[editor.currentIndex()]
+ model.setData(index, QVariant(val), 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 = 1 if not val else 0
+ else:
+ val = 2 if val is None else 1 if not val else 0
+ editor.setCurrentIndex(val)
+
+
+# }}}
+
diff --git a/src/calibre/gui2/library.py b/src/calibre/gui2/library/models.py
similarity index 62%
rename from src/calibre/gui2/library.py
rename to src/calibre/gui2/library/models.py
index 806b5851bc..abff227ae3 100644
--- a/src/calibre/gui2/library.py
+++ b/src/calibre/gui2/library/models.py
@@ -1,324 +1,42 @@
+#!/usr/bin/env python
+# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai
+
__license__ = 'GPL v3'
-__copyright__ = '2008, Kovid Goyal '
+__copyright__ = '2010, Kovid Goyal '
+__docformat__ = 'restructuredtext en'
-import os, textwrap, traceback, re, shutil, functools, sys
-
-from operator import attrgetter
-from math import cos, sin, pi
+import shutil, functools, re, os, traceback
from contextlib import closing
+from operator import attrgetter
-from PyQt4.QtGui import QTableView, QAbstractItemView, QColor, \
- QPainterPath, QLinearGradient, QBrush, \
- QPen, QStyle, QPainter, QStyleOptionViewItemV4, \
- QIcon, QImage, QMenu, QSpinBox, QDoubleSpinBox, \
- QStyledItemDelegate, QCompleter, \
- QComboBox
-from PyQt4.QtCore import QAbstractTableModel, QVariant, Qt, pyqtSignal, \
- SIGNAL, QObject, QSize, QModelIndex, QDate
+from PyQt4.Qt import QAbstractTableModel, Qt, pyqtSignal, QIcon, QImage, \
+ QModelIndex, QVariant, QDate
-from calibre import strftime
+from calibre.gui2 import NONE, config, UNDEFINED_QDATE
+from calibre.utils.pyparsing import ParseException
from calibre.ebooks.metadata import fmt_sidx, authors_to_string, string_to_authors
-from calibre.ebooks.metadata.meta import set_metadata as _set_metadata
-from calibre.gui2 import NONE, config, error_dialog, UNDEFINED_QDATE
-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
-from calibre.utils.date import dt_factory, qt_to_dt, isoformat, now
-from calibre.utils.pyparsing import ParseException
+from calibre.utils.date import dt_factory, qt_to_dt, isoformat
+from calibre.ebooks.metadata.meta import set_metadata as _set_metadata
from calibre.utils.search_query_parser import SearchQueryParser
+from calibre.library.caches import _match, CONTAINS_MATCH, EQUALS_MATCH, REGEXP_MATCH
+from calibre import strftime
-# Delegates {{{
+def human_readable(size, precision=1):
+ """ Convert a size in bytes into megabytes """
+ return ('%.'+str(precision)+'f') % ((size/(1024.*1024.)),)
-class DummyDelegate(QStyledItemDelegate):
-
- def sizeHint(self, option, index):
- return QSize(0, 0)
-
- def paint(self, painter, option, index):
- pass
-
-class RatingDelegate(QStyledItemDelegate):
- COLOR = QColor("blue")
- SIZE = 16
- PEN = QPen(COLOR, 1, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin)
-
- def __init__(self, parent):
- QStyledItemDelegate.__init__(self, parent)
- self._parent = parent
- self.dummy = QModelIndex()
- self.star_path = QPainterPath()
- self.star_path.moveTo(90, 50)
- for i in range(1, 5):
- self.star_path.lineTo(50 + 40 * cos(0.8 * i * pi), \
- 50 + 40 * sin(0.8 * i * pi))
- self.star_path.closeSubpath()
- self.star_path.setFillRule(Qt.WindingFill)
- gradient = QLinearGradient(0, 0, 0, 100)
- gradient.setColorAt(0.0, self.COLOR)
- gradient.setColorAt(1.0, self.COLOR)
- self.brush = QBrush(gradient)
- self.factor = self.SIZE/100.
-
- def sizeHint(self, option, index):
- #num = index.model().data(index, Qt.DisplayRole).toInt()[0]
- return QSize(5*(self.SIZE), self.SIZE+4)
-
- def paint(self, painter, option, index):
- style = self._parent.style()
- option = QStyleOptionViewItemV4(option)
- self.initStyleOption(option, self.dummy)
- num = index.model().data(index, Qt.DisplayRole).toInt()[0]
- def draw_star():
- painter.save()
- painter.scale(self.factor, self.factor)
- painter.translate(50.0, 50.0)
- painter.rotate(-20)
- painter.translate(-50.0, -50.0)
- painter.drawPath(self.star_path)
- painter.restore()
-
- painter.save()
- if hasattr(QStyle, 'CE_ItemViewItem'):
- style.drawControl(QStyle.CE_ItemViewItem, option,
- painter, self._parent)
- elif option.state & QStyle.State_Selected:
- painter.fillRect(option.rect, option.palette.highlight())
- try:
- painter.setRenderHint(QPainter.Antialiasing)
- painter.setClipRect(option.rect)
- y = option.rect.center().y()-self.SIZE/2.
- x = option.rect.left()
- painter.setPen(self.PEN)
- painter.setBrush(self.brush)
- painter.translate(x, y)
- i = 0
- while i < num:
- draw_star()
- painter.translate(self.SIZE, 0)
- i += 1
- except:
- traceback.print_exc()
- painter.restore()
-
- def createEditor(self, parent, option, index):
- sb = QStyledItemDelegate.createEditor(self, parent, option, index)
- sb.setMinimum(0)
- sb.setMaximum(5)
- return sb
-
-class DateDelegate(QStyledItemDelegate):
-
- def displayText(self, val, locale):
- d = val.toDate()
- if d == UNDEFINED_QDATE:
- return ''
- return d.toString('dd MMM yyyy')
-
- def createEditor(self, parent, option, index):
- qde = QStyledItemDelegate.createEditor(self, parent, option, index)
- stdformat = unicode(qde.displayFormat())
- if 'yyyy' not in stdformat:
- stdformat = stdformat.replace('yy', 'yyyy')
- qde.setDisplayFormat(stdformat)
- qde.setMinimumDate(UNDEFINED_QDATE)
- qde.setSpecialValueText(_('Undefined'))
- qde.setCalendarPopup(True)
- return qde
-
-class PubDateDelegate(QStyledItemDelegate):
-
- def displayText(self, val, locale):
- d = val.toDate()
- if d == UNDEFINED_QDATE:
- return ''
- format = tweaks['gui_pubdate_display_format']
- if format is None:
- format = 'MMM yyyy'
- return d.toString(format)
-
- def createEditor(self, parent, option, index):
- qde = QStyledItemDelegate.createEditor(self, parent, option, index)
- qde.setDisplayFormat('MM yyyy')
- qde.setMinimumDate(UNDEFINED_QDATE)
- qde.setSpecialValueText(_('Undefined'))
- qde.setCalendarPopup(True)
- return qde
-
-class TextDelegate(QStyledItemDelegate):
- def __init__(self, parent):
- '''
- Delegate for text data. If auto_complete_function needs to return a list
- of text items to auto-complete with. The funciton is None no
- auto-complete will be used.
- '''
- QStyledItemDelegate.__init__(self, parent)
- self.auto_complete_function = None
-
- def set_auto_complete_function(self, f):
- self.auto_complete_function = f
-
- def createEditor(self, parent, option, index):
- editor = EnLineEdit(parent)
- if self.auto_complete_function:
- complete_items = [i[1] for i in self.auto_complete_function()]
- completer = QCompleter(complete_items, self)
- completer.setCaseSensitivity(Qt.CaseInsensitive)
- completer.setCompletionMode(QCompleter.InlineCompletion)
- editor.setCompleter(completer)
- return editor
-
-class TagsDelegate(QStyledItemDelegate):
- def __init__(self, parent):
- QStyledItemDelegate.__init__(self, parent)
- self.db = None
-
- def set_database(self, db):
- self.db = db
-
- def createEditor(self, parent, option, index):
- if self.db:
- 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 CcDateDelegate(QStyledItemDelegate):
- '''
- Delegate for custom columns dates. Because this delegate stores the
- format as an instance variable, a new instance must be created for each
- column. This differs from all the other delegates.
- '''
-
- def set_format(self, format):
- if not format:
- self.format = 'dd MMM yyyy'
- else:
- self.format = format
-
- def displayText(self, val, locale):
- d = val.toDate()
- if d == UNDEFINED_QDATE:
- return ''
- return d.toString(self.format)
-
- def createEditor(self, parent, option, index):
- qde = QStyledItemDelegate.createEditor(self, parent, option, index)
- qde.setDisplayFormat(self.format)
- qde.setMinimumDate(UNDEFINED_QDATE)
- qde.setSpecialValueText(_('Undefined'))
- qde.setCalendarPopup(True)
- return qde
-
- 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 val is None:
- val = now()
- editor.setDate(val)
-
- def setModelData(self, editor, model, index):
- val = editor.date()
- if val == UNDEFINED_QDATE:
- val = None
- model.setData(index, QVariant(val), Qt.EditRole)
-
-class CcTextDelegate(QStyledItemDelegate):
- '''
- Delegate for text/int/float data.
- '''
-
- def createEditor(self, parent, option, index):
- m = index.model()
- col = m.column_map[index.column()]
- typ = m.custom_columns[col]['datatype']
- if typ == 'int':
- editor = QSpinBox(parent)
- editor.setRange(-100, sys.maxint)
- editor.setSpecialValueText(_('Undefined'))
- editor.setSingleStep(1)
- elif typ == 'float':
- editor = QDoubleSpinBox(parent)
- editor.setSpecialValueText(_('Undefined'))
- editor.setRange(-100., float(sys.maxint))
- editor.setDecimals(2)
- else:
- editor = EnLineEdit(parent)
- 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):
- '''
- Delegate for comments data.
- '''
-
- 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.toPlainText()), 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):
- editor = QComboBox(parent)
- items = [_('Y'), _('N'), ' ']
- icons = [I('ok.svg'), I('list_remove.svg'), I('blank.svg')]
- if tweaks['bool_custom_columns_are_tristate'] == 'no':
- items = items[:-1]
- icons = icons[:-1]
- for icon, text in zip(icons, items):
- editor.addItem(QIcon(icon), text)
- return editor
-
- def setModelData(self, editor, model, index):
- val = {0:True, 1:False, 2:None}[editor.currentIndex()]
- model.setData(index, QVariant(val), 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 = 1 if not val else 0
- else:
- val = 2 if val is None else 1 if not val else 0
- editor.setCurrentIndex(val)
-
-# }}}
+TIME_FMT = '%d %b %Y'
class BooksModel(QAbstractTableModel): # {{{
- about_to_be_sorted = pyqtSignal(object, name='aboutToBeSorted')
- sorting_done = pyqtSignal(object, name='sortingDone')
- database_changed = pyqtSignal(object, name='databaseChanged')
+ about_to_be_sorted = pyqtSignal(object, name='aboutToBeSorted')
+ sorting_done = pyqtSignal(object, name='sortingDone')
+ database_changed = pyqtSignal(object, name='databaseChanged')
+ new_bookdisplay_data = pyqtSignal(object)
+ count_changed_signal = pyqtSignal(int)
+ searched = pyqtSignal(object)
orig_headers = {
'title' : _("Title"),
@@ -408,7 +126,7 @@ class BooksModel(QAbstractTableModel): # {{{
id = self.db.id(row)
self.cover_cache.refresh([id])
if row == current_row:
- self.emit(SIGNAL('new_bookdisplay_data(PyQt_PyObject)'),
+ self.new_bookdisplay_data.emit(
self.get_book_display_info(row))
self.dataChanged.emit(self.index(row, 0), self.index(row,
self.columnCount(QModelIndex())-1))
@@ -435,7 +153,7 @@ class BooksModel(QAbstractTableModel): # {{{
return ret
def count_changed(self, *args):
- self.emit(SIGNAL('count_changed(int)'), self.db.count())
+ self.count_changed_signal.emit(self.db.count())
def row_indices(self, index):
''' Return list indices of all cells in index.row()'''
@@ -478,14 +196,14 @@ class BooksModel(QAbstractTableModel): # {{{
try:
self.db.search(text)
except ParseException:
- self.emit(SIGNAL('searched(PyQt_PyObject)'), False)
+ self.searched.emit(False)
return
self.last_search = text
if reset:
self.clear_caches()
self.reset()
if self.last_search:
- self.emit(SIGNAL('searched(PyQt_PyObject)'), True)
+ self.searched.emit(True)
def sort(self, col, order, reset=True):
@@ -584,7 +302,7 @@ class BooksModel(QAbstractTableModel): # {{{
self.set_cache(idx)
data = self.get_book_display_info(idx)
if emit_signal:
- self.emit(SIGNAL('new_bookdisplay_data(PyQt_PyObject)'), data)
+ self.new_bookdisplay_data.emit(data)
else:
return data
@@ -981,8 +699,7 @@ class BooksModel(QAbstractTableModel): # {{{
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)
+ self.dataChanged.emit(index, index)
return True
def set_search_restriction(self, s):
@@ -990,241 +707,7 @@ class BooksModel(QAbstractTableModel): # {{{
# }}}
-class BooksView(QTableView): # {{{
- TIME_FMT = '%d %b %Y'
- wrapper = textwrap.TextWrapper(width=20)
-
- @classmethod
- def wrap(cls, s, width=20):
- cls.wrapper.width = width
- return cls.wrapper.fill(s)
-
- @classmethod
- def human_readable(cls, size, precision=1):
- """ Convert a size in bytes into megabytes """
- return ('%.'+str(precision)+'f') % ((size/(1024.*1024.)),)
-
- def __init__(self, parent, modelcls=BooksModel):
- QTableView.__init__(self, parent)
- 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.text_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)
- self.setSelectionBehavior(QAbstractItemView.SelectRows)
- self.setSortingEnabled(True)
- self.selectionModel().currentRowChanged.connect(self._model.current_changed)
- self.column_header = self.horizontalHeader()
- self._model.database_changed.connect(self.database_changed)
- hv = self.verticalHeader()
- hv.setClickable(True)
- hv.setCursor(Qt.PointingHandCursor)
- self.selected_ids = []
- self._model.about_to_be_sorted.connect(self.about_to_be_sorted)
- self._model.sorting_done.connect(self.sorting_done)
-
- def about_to_be_sorted(self, idc):
- selected_rows = [r.row() for r in self.selectionModel().selectedRows()]
- self.selected_ids = [idc(r) for r in selected_rows]
-
- def sorting_done(self, indexc):
- if self.selected_ids:
- indices = [self.model().index(indexc(i), 0) for i in
- self.selected_ids]
- sm = self.selectionModel()
- for idx in indices:
- sm.select(idx, sm.Select|sm.Rows)
- self.selected_ids = []
-
- def set_ondevice_column_visibility(self):
- m = self._model
- self.column_header.setSectionHidden(m.column_map.index('ondevice'),
- not m.device_connected)
-
- def set_device_connected(self, is_connected):
- self._model.set_device_connected(is_connected)
- self.set_ondevice_column_visibility()
-
- def database_changed(self, db):
- for i in range(self.model().columnCount(None)):
- if self.itemDelegateForColumn(i) in (self.rating_delegate,
- self.timestamp_delegate, self.pubdate_delegate):
- self.setItemDelegateForColumn(i, self.itemDelegate())
-
- cm = self._model.column_map
- self.set_ondevice_column_visibility()
-
- for colhead in cm:
- if self._model.is_custom_column(colhead):
- cc = self._model.custom_columns[colhead]
- if cc['datatype'] == 'datetime':
- delegate = CcDateDelegate(self)
- delegate.set_format(cc['display'].get('date_format',''))
- self.setItemDelegateForColumn(cm.index(colhead), 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)
- else:
- dattr = colhead+'_delegate'
- delegate = colhead if hasattr(self, dattr) else 'text'
- self.setItemDelegateForColumn(cm.index(colhead), getattr(self,
- delegate+'_delegate'))
-
- def set_context_menu(self, edit_metadata, send_to_device, convert, view,
- save, open_folder, book_details, delete, similar_menu=None):
- self.setContextMenuPolicy(Qt.DefaultContextMenu)
- self.context_menu = QMenu(self)
- if edit_metadata is not None:
- self.context_menu.addAction(edit_metadata)
- if send_to_device is not None:
- self.context_menu.addAction(send_to_device)
- if convert is not None:
- self.context_menu.addAction(convert)
- self.context_menu.addAction(view)
- self.context_menu.addAction(save)
- if open_folder is not None:
- self.context_menu.addAction(open_folder)
- if delete is not None:
- self.context_menu.addAction(delete)
- if book_details is not None:
- self.context_menu.addAction(book_details)
- if similar_menu is not None:
- self.context_menu.addMenu(similar_menu)
-
- def contextMenuEvent(self, event):
- 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)
- except ValueError:
- idx = 0
- QTableView.sortByColumn(self, idx, order)
-
- @classmethod
- def paths_from_event(cls, event):
- '''
- Accept a drop event and return a list of paths that can be read from
- and represent files with extensions.
- '''
- if event.mimeData().hasFormat('text/uri-list'):
- urls = [unicode(u.toLocalFile()) for u in event.mimeData().urls()]
- return [u for u in urls if os.path.splitext(u)[1] and os.access(u, os.R_OK)]
-
- def dragEnterEvent(self, event):
- if int(event.possibleActions() & Qt.CopyAction) + \
- int(event.possibleActions() & Qt.MoveAction) == 0:
- return
- paths = self.paths_from_event(event)
-
- if paths:
- event.acceptProposedAction()
-
- def dragMoveEvent(self, event):
- event.acceptProposedAction()
-
- def dropEvent(self, event):
- paths = self.paths_from_event(event)
- event.setDropAction(Qt.CopyAction)
- 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)
- self.authors_delegate.set_auto_complete_function(db.all_authors)
- self.series_delegate.set_auto_complete_function(db.all_series)
- self.publisher_delegate.set_auto_complete_function(db.all_publishers)
-
- def close(self):
- self._model.close()
-
- def set_editable(self, editable):
- self._model.set_editable(editable)
-
- def connect_to_search_box(self, sb, search_done):
- QObject.connect(sb, SIGNAL('search(PyQt_PyObject, PyQt_PyObject)'),
- self._model.search)
- self._search_done = search_done
- 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) # must be synchronous (not queued)
-
- def connect_to_book_display(self, bd):
- QObject.connect(self._model, SIGNAL('new_bookdisplay_data(PyQt_PyObject)'),
- bd)
-
- def search_done(self, ok):
- self._search_done(self, ok)
-
- def row_count(self):
- return self._model.count()
-
-# }}}
-
-class DeviceBooksView(BooksView):
-
- def __init__(self, parent):
- BooksView.__init__(self, parent, DeviceBooksModel)
- self.columns_resized = False
- self.resize_on_select = False
- self.rating_delegate = None
- for i in range(10):
- self.setItemDelegateForColumn(i, TextDelegate(self))
- self.setDragDropMode(self.NoDragDrop)
- self.setAcceptDrops(False)
-
- def set_database(self, db):
- self._model.set_database(db)
-
- def resizeColumnsToContents(self):
- QTableView.resizeColumnsToContents(self)
- self.columns_resized = True
-
- def connect_dirtied_signal(self, slot):
- QObject.connect(self._model, SIGNAL('booklist_dirtied()'), slot)
-
- def sortByColumn(self, col, order):
- QTableView.sortByColumn(self, col, order)
-
- def dropEvent(self, *args):
- error_dialog(self, _('Not allowed'),
- _('Dropping onto a device is not supported. First add the book to the calibre library.')).exec_()
-
-class OnDeviceSearch(SearchQueryParser):
+class OnDeviceSearch(SearchQueryParser): # {{{
def __init__(self, model):
SearchQueryParser.__init__(self)
@@ -1282,8 +765,11 @@ class OnDeviceSearch(SearchQueryParser):
traceback.print_exc()
return matches
+# }}}
-class DeviceBooksModel(BooksModel):
+class DeviceBooksModel(BooksModel): # {{{
+
+ booklist_dirtied = pyqtSignal()
def __init__(self, parent):
BooksModel.__init__(self, parent)
@@ -1300,7 +786,7 @@ class DeviceBooksModel(BooksModel):
self.marked_for_deletion[job] = self.indices(rows)
for row in rows:
indices = self.row_indices(row)
- self.emit(SIGNAL('dataChanged(QModelIndex, QModelIndex)'), indices[0], indices[-1])
+ self.dataChanged.emit(indices[0], indices[-1])
def deletion_done(self, job, succeeded=True):
if not self.marked_for_deletion.has_key(job):
@@ -1309,7 +795,7 @@ class DeviceBooksModel(BooksModel):
for row in rows:
if not succeeded:
indices = self.row_indices(self.index(row, 0))
- self.emit(SIGNAL('dataChanged(QModelIndex, QModelIndex)'), indices[0], indices[-1])
+ self.dataChanged.emit(indices[0], indices[-1])
def paths_deleted(self, paths):
self.map = list(range(0, len(self.db)))
@@ -1339,7 +825,7 @@ class DeviceBooksModel(BooksModel):
try:
matches = self.search_engine.parse(text)
except ParseException:
- self.emit(SIGNAL('searched(PyQt_PyObject)'), False)
+ self.searched.emit(False)
return
self.map = []
@@ -1351,7 +837,7 @@ class DeviceBooksModel(BooksModel):
self.reset()
self.last_search = text
if self.last_search:
- self.emit(SIGNAL('searched(PyQt_PyObject)'), True)
+ self.searched.emit(False)
def resort(self, reset):
@@ -1443,7 +929,7 @@ class DeviceBooksModel(BooksModel):
dt = dt_factory(item.datetime, assume_utc=True)
data[_('Timestamp')] = isoformat(dt, sep=' ', as_utc=False)
data[_('Tags')] = ', '.join(item.tags)
- self.emit(SIGNAL('new_bookdisplay_data(PyQt_PyObject)'), data)
+ self.new_bookdisplay_data.emit(data)
def paths(self, rows):
return [self.db[self.map[r.row()]].path for r in rows ]
@@ -1471,11 +957,11 @@ class DeviceBooksModel(BooksModel):
return QVariant(authors_to_string(au))
elif col == 2:
size = self.db[self.map[row]].size
- return QVariant(BooksView.human_readable(size))
+ return QVariant(human_readable(size))
elif col == 3:
dt = self.db[self.map[row]].datetime
dt = dt_factory(dt, assume_utc=True, as_utc=False)
- return QVariant(strftime(BooksView.TIME_FMT, dt.timetuple()))
+ return QVariant(strftime(TIME_FMT, dt.timetuple()))
elif col == 4:
tags = self.db[self.map[row]].tags
if tags:
@@ -1526,8 +1012,8 @@ class DeviceBooksModel(BooksModel):
tags = [i.strip() for i in val.split(',')]
tags = [t for t in tags if t]
self.db.set_tags(self.db[idx], tags)
- self.emit(SIGNAL("dataChanged(QModelIndex, QModelIndex)"), index, index)
- self.emit(SIGNAL('booklist_dirtied()'))
+ self.dataChanged.emit(index, index)
+ self.booklist_dirtied.emit()
if col == self.sorted_on[0]:
self.sort(col, self.sorted_on[1])
done = True
@@ -1538,3 +1024,6 @@ class DeviceBooksModel(BooksModel):
def set_search_restriction(self, s):
pass
+
+# }}}
+
diff --git a/src/calibre/gui2/library/views.py b/src/calibre/gui2/library/views.py
new file mode 100644
index 0000000000..9f9532687c
--- /dev/null
+++ b/src/calibre/gui2/library/views.py
@@ -0,0 +1,242 @@
+#!/usr/bin/env python
+# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai
+
+__license__ = 'GPL v3'
+__copyright__ = '2010, Kovid Goyal '
+__docformat__ = 'restructuredtext en'
+
+import os
+
+from PyQt4.Qt import QTableView, Qt, QAbstractItemView, QMenu, pyqtSignal
+
+from calibre.gui2.library.delegates import RatingDelegate, PubDateDelegate, \
+ TextDelegate, DateDelegate, TagsDelegate, CcTextDelegate, \
+ CcBoolDelegate, CcCommentsDelegate, CcDateDelegate
+from calibre.gui2.library.models import BooksModel, DeviceBooksModel
+from calibre.utils.config import tweaks
+from calibre.gui2 import error_dialog
+
+
+class BooksView(QTableView): # {{{
+
+ files_dropped = pyqtSignal(object)
+
+ def __init__(self, parent, modelcls=BooksModel):
+ QTableView.__init__(self, parent)
+ 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.text_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)
+ self.setSelectionBehavior(QAbstractItemView.SelectRows)
+ self.setSortingEnabled(True)
+ self.selectionModel().currentRowChanged.connect(self._model.current_changed)
+ self.column_header = self.horizontalHeader()
+ self._model.database_changed.connect(self.database_changed)
+ hv = self.verticalHeader()
+ hv.setClickable(True)
+ hv.setCursor(Qt.PointingHandCursor)
+ self.selected_ids = []
+ self._model.about_to_be_sorted.connect(self.about_to_be_sorted)
+ self._model.sorting_done.connect(self.sorting_done)
+
+ def about_to_be_sorted(self, idc):
+ selected_rows = [r.row() for r in self.selectionModel().selectedRows()]
+ self.selected_ids = [idc(r) for r in selected_rows]
+
+ def sorting_done(self, indexc):
+ if self.selected_ids:
+ indices = [self.model().index(indexc(i), 0) for i in
+ self.selected_ids]
+ sm = self.selectionModel()
+ for idx in indices:
+ sm.select(idx, sm.Select|sm.Rows)
+ self.selected_ids = []
+
+ def set_ondevice_column_visibility(self):
+ m = self._model
+ self.column_header.setSectionHidden(m.column_map.index('ondevice'),
+ not m.device_connected)
+
+ def set_device_connected(self, is_connected):
+ self._model.set_device_connected(is_connected)
+ self.set_ondevice_column_visibility()
+
+ def database_changed(self, db):
+ for i in range(self.model().columnCount(None)):
+ if self.itemDelegateForColumn(i) in (self.rating_delegate,
+ self.timestamp_delegate, self.pubdate_delegate):
+ self.setItemDelegateForColumn(i, self.itemDelegate())
+
+ cm = self._model.column_map
+ self.set_ondevice_column_visibility()
+
+ for colhead in cm:
+ if self._model.is_custom_column(colhead):
+ cc = self._model.custom_columns[colhead]
+ if cc['datatype'] == 'datetime':
+ delegate = CcDateDelegate(self)
+ delegate.set_format(cc['display'].get('date_format',''))
+ self.setItemDelegateForColumn(cm.index(colhead), 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)
+ else:
+ dattr = colhead+'_delegate'
+ delegate = colhead if hasattr(self, dattr) else 'text'
+ self.setItemDelegateForColumn(cm.index(colhead), getattr(self,
+ delegate+'_delegate'))
+
+ def set_context_menu(self, edit_metadata, send_to_device, convert, view,
+ save, open_folder, book_details, delete, similar_menu=None):
+ self.setContextMenuPolicy(Qt.DefaultContextMenu)
+ self.context_menu = QMenu(self)
+ if edit_metadata is not None:
+ self.context_menu.addAction(edit_metadata)
+ if send_to_device is not None:
+ self.context_menu.addAction(send_to_device)
+ if convert is not None:
+ self.context_menu.addAction(convert)
+ self.context_menu.addAction(view)
+ self.context_menu.addAction(save)
+ if open_folder is not None:
+ self.context_menu.addAction(open_folder)
+ if delete is not None:
+ self.context_menu.addAction(delete)
+ if book_details is not None:
+ self.context_menu.addAction(book_details)
+ if similar_menu is not None:
+ self.context_menu.addMenu(similar_menu)
+
+ def contextMenuEvent(self, event):
+ 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)
+ except ValueError:
+ idx = 0
+ QTableView.sortByColumn(self, idx, order)
+
+ @classmethod
+ def paths_from_event(cls, event):
+ '''
+ Accept a drop event and return a list of paths that can be read from
+ and represent files with extensions.
+ '''
+ if event.mimeData().hasFormat('text/uri-list'):
+ urls = [unicode(u.toLocalFile()) for u in event.mimeData().urls()]
+ return [u for u in urls if os.path.splitext(u)[1] and os.access(u, os.R_OK)]
+
+ def dragEnterEvent(self, event):
+ if int(event.possibleActions() & Qt.CopyAction) + \
+ int(event.possibleActions() & Qt.MoveAction) == 0:
+ return
+ paths = self.paths_from_event(event)
+
+ if paths:
+ event.acceptProposedAction()
+
+ def dragMoveEvent(self, event):
+ event.acceptProposedAction()
+
+ def dropEvent(self, event):
+ paths = self.paths_from_event(event)
+ event.setDropAction(Qt.CopyAction)
+ event.accept()
+ self.files_dropped.emit(paths)
+
+ def set_database(self, db):
+ self._model.set_database(db)
+ self.tags_delegate.set_database(db)
+ self.authors_delegate.set_auto_complete_function(db.all_authors)
+ self.series_delegate.set_auto_complete_function(db.all_series)
+ self.publisher_delegate.set_auto_complete_function(db.all_publishers)
+
+ def close(self):
+ self._model.close()
+
+ def set_editable(self, editable):
+ self._model.set_editable(editable)
+
+ def connect_to_search_box(self, sb, search_done):
+ sb.search.connect(self._model.search)
+ self._search_done = search_done
+ self._model.searched.connect(self.search_done)
+
+ def connect_to_restriction_set(self, tv):
+ # must be synchronous (not queued)
+ tv.restriction_set.connect(self._model.set_search_restriction)
+
+ def connect_to_book_display(self, bd):
+ self._model.new_bookdisplay_data.connect(bd)
+
+ def search_done(self, ok):
+ self._search_done(self, ok)
+
+ def row_count(self):
+ return self._model.count()
+
+# }}}
+
+class DeviceBooksView(BooksView): # {{{
+
+ def __init__(self, parent):
+ BooksView.__init__(self, parent, DeviceBooksModel)
+ self.columns_resized = False
+ self.resize_on_select = False
+ self.rating_delegate = None
+ for i in range(10):
+ self.setItemDelegateForColumn(i, TextDelegate(self))
+ self.setDragDropMode(self.NoDragDrop)
+ self.setAcceptDrops(False)
+
+ def set_database(self, db):
+ self._model.set_database(db)
+
+ def resizeColumnsToContents(self):
+ QTableView.resizeColumnsToContents(self)
+ self.columns_resized = True
+
+ def connect_dirtied_signal(self, slot):
+ self._model.booklist_dirtied.connect(slot)
+
+ def sortByColumn(self, col, order):
+ QTableView.sortByColumn(self, col, order)
+
+ def dropEvent(self, *args):
+ error_dialog(self, _('Not allowed'),
+ _('Dropping onto a device is not supported. First add the book to the calibre library.')).exec_()
+
+# }}}
+
diff --git a/src/calibre/gui2/lrf_renderer/main.py b/src/calibre/gui2/lrf_renderer/main.py
index 1e27137580..2b76ab0fea 100644
--- a/src/calibre/gui2/lrf_renderer/main.py
+++ b/src/calibre/gui2/lrf_renderer/main.py
@@ -81,7 +81,7 @@ class Main(MainWindow, Ui_MainWindow):
self.search = SearchBox2(self)
self.search.initialize('lrf_viewer_search_history')
self.search_action = self.tool_bar.addWidget(self.search)
- QObject.connect(self.search, SIGNAL('search(PyQt_PyObject, PyQt_PyObject)'), self.find)
+ self.search.search.connect(self.find)
self.action_next_page.setShortcuts([QKeySequence.MoveToNextPage, QKeySequence(Qt.Key_Space)])
self.action_previous_page.setShortcuts([QKeySequence.MoveToPreviousPage, QKeySequence(Qt.Key_Backspace)])
diff --git a/src/calibre/gui2/main.ui b/src/calibre/gui2/main.ui
index 29292747f8..b7f797f1e0 100644
--- a/src/calibre/gui2/main.ui
+++ b/src/calibre/gui2/main.ui
@@ -793,7 +793,7 @@
BooksViewQTableView
- library.h
+ calibre/gui2/library/views.hLocationView
@@ -803,7 +803,7 @@
DeviceBooksViewQTableView
- library.h
+ calibre/gui2/library/views.hTagsView
diff --git a/src/calibre/gui2/search_box.py b/src/calibre/gui2/search_box.py
index 776127b698..230debd598 100644
--- a/src/calibre/gui2/search_box.py
+++ b/src/calibre/gui2/search_box.py
@@ -6,7 +6,8 @@ __license__ = 'GPL v3'
__copyright__ = '2009, Kovid Goyal '
__docformat__ = 'restructuredtext en'
-from PyQt4.Qt import QComboBox, SIGNAL, Qt, QLineEdit, QStringList, pyqtSlot
+from PyQt4.Qt import QComboBox, Qt, QLineEdit, QStringList, pyqtSlot, \
+ pyqtSignal, SIGNAL
from PyQt4.QtGui import QCompleter
from calibre.gui2 import config
@@ -56,6 +57,8 @@ class SearchBox2(QComboBox):
INTERVAL = 1500 #: Time to wait before emitting search signal
MAX_COUNT = 25
+ search = pyqtSignal(object, object)
+
def __init__(self, parent=None):
QComboBox.__init__(self, parent)
self.normal_background = 'rgb(255, 255, 255, 0%)'
@@ -108,7 +111,7 @@ class SearchBox2(QComboBox):
def clear(self):
self.clear_to_help()
- self.emit(SIGNAL('search(PyQt_PyObject, PyQt_PyObject)'), '', False)
+ self.search.emit('', False)
def search_done(self, ok):
if not unicode(self.currentText()).strip():
@@ -155,7 +158,7 @@ class SearchBox2(QComboBox):
self.help_state = False
refinement = text.startswith(self.prev_search) and ':' not in text
self.prev_search = text
- self.emit(SIGNAL('search(PyQt_PyObject, PyQt_PyObject)'), text, refinement)
+ self.search.emit(text, refinement)
idx = self.findText(text, Qt.MatchFixedString)
self.block_signals(True)
@@ -187,7 +190,7 @@ class SearchBox2(QComboBox):
def set_search_string(self, txt):
self.normalize_state()
self.setEditText(txt)
- self.emit(SIGNAL('search(PyQt_PyObject, PyQt_PyObject)'), txt, False)
+ self.search.emit(txt, False)
self.line_edit.end(False)
self.initial_state = False
diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py
index 5d85dec0cb..22658291f5 100644
--- a/src/calibre/gui2/tag_view.py
+++ b/src/calibre/gui2/tag_view.py
@@ -20,6 +20,7 @@ from calibre.library.database2 import Tag
class TagsView(QTreeView):
need_refresh = pyqtSignal()
+ restriction_set = pyqtSignal(object)
def __init__(self, *args):
QTreeView.__init__(self, *args)
@@ -66,7 +67,7 @@ class TagsView(QTreeView):
else:
self.search_restriction = 'search:"%s"' % unicode(s).strip()
self.model().set_search_restriction(self.search_restriction)
- self.emit(SIGNAL('restriction_set(PyQt_PyObject)'), self.search_restriction)
+ self.restriction_set.emit(self.search_restriction)
self.recount() # Must happen after the emission of the restriction_set signal
self.emit(SIGNAL('tags_marked(PyQt_PyObject, PyQt_PyObject)'),
self._model.tokens(), self.match_all)
diff --git a/src/calibre/gui2/ui.py b/src/calibre/gui2/ui.py
index c65a1ba81c..ff063800d5 100644
--- a/src/calibre/gui2/ui.py
+++ b/src/calibre/gui2/ui.py
@@ -507,9 +507,7 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
self.card_b_view.set_context_menu(None, None, None,
self.action_view, self.action_save, None, None, self.action_del)
- QObject.connect(self.library_view,
- SIGNAL('files_dropped(PyQt_PyObject)'),
- self.files_dropped, Qt.QueuedConnection)
+ self.library_view.files_dropped.connect(self.files_dropped, type=Qt.QueuedConnection)
for func, args in [
('connect_to_search_box', (self.search,
self.search_done)),
@@ -544,24 +542,16 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
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)
+ for x in (self.saved_search.clear_to_help, self.mark_restriction_set):
+ self.tags_view.restriction_set.connect(x)
self.connect(self.tags_view,
SIGNAL('tags_marked(PyQt_PyObject, PyQt_PyObject)'),
self.saved_search.clear_to_help)
- self.connect(self.search,
- SIGNAL('search(PyQt_PyObject, PyQt_PyObject)'),
- self.tags_view.model().reinit)
- self.connect(self.library_view.model(),
- 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.library_view.model(), SIGNAL('count_changed(int)'),
- self.restriction_count_changed, Qt.QueuedConnection)
+ self.search.search.connect(self.tags_view.model().reinit)
+ for x in (self.location_view.count_changed, self.tags_view.recount,
+ self.restriction_count_changed):
+ self.library_view.model().count_changed_signal.connect(x)
+
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):
diff --git a/src/calibre/gui2/viewer/main.py b/src/calibre/gui2/viewer/main.py
index 77d7269e17..06abb7181c 100644
--- a/src/calibre/gui2/viewer/main.py
+++ b/src/calibre/gui2/viewer/main.py
@@ -244,7 +244,7 @@ class EbookViewer(MainWindow, Ui_EbookViewer):
self.pos.editingFinished.connect(self.goto_page_num)
self.connect(self.vertical_scrollbar, SIGNAL('valueChanged(int)'),
lambda x: self.goto_page(x/100.))
- self.connect(self.search, SIGNAL('search(PyQt_PyObject, PyQt_PyObject)'), self.find)
+ self.search.search.connect(self.find)
self.connect(self.toc, SIGNAL('clicked(QModelIndex)'), self.toc_clicked)
self.connect(self.reference, SIGNAL('goto(PyQt_PyObject)'), self.goto)
From e4b0f51363fb0bb391fdafa27c1a210214980a21 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Mon, 17 May 2010 19:33:00 -0600
Subject: [PATCH 126/324] Fix the booklist delete algorithm
---
src/calibre/devices/usbms/driver.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/calibre/devices/usbms/driver.py b/src/calibre/devices/usbms/driver.py
index 361ee2300b..5273ffe579 100644
--- a/src/calibre/devices/usbms/driver.py
+++ b/src/calibre/devices/usbms/driver.py
@@ -85,10 +85,10 @@ class USBMS(CLI, Device):
lpath = lpath.replace('\\', '/')
idx = bl_cache.get(lpath, None)
if idx is not None:
+ bl_cache[lpath] = None
if self.update_metadata_item(bl[idx]):
#print 'update_metadata_item returned true'
changed = True
- bl_cache[lpath] = None
else:
#print "adding new book", lpath
if bl.add_book(self.book_from_path(prefix, lpath),
@@ -130,7 +130,7 @@ class USBMS(CLI, Device):
# Remove books that are no longer in the filesystem. Cache contains
# indices into the booklist if book not in filesystem, None otherwise
# Do the operation in reverse order so indices remain valid
- for idx in bl_cache.itervalues().reversed():
+ for idx in sorted(bl_cache.itervalues(), reverse=True):
if idx is not None:
need_sync = True
del bl[idx]
From 61e78b6a173848bccaaf0cd24c226ef5aae23d22 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Mon, 17 May 2010 20:51:46 -0600
Subject: [PATCH 127/324] Right click menu for hiding/showing/sorting columns
---
src/calibre/gui2/library/views.py | 113 +++++++++++++++++++++++++++++-
1 file changed, 111 insertions(+), 2 deletions(-)
diff --git a/src/calibre/gui2/library/views.py b/src/calibre/gui2/library/views.py
index 9f9532687c..8734e7582a 100644
--- a/src/calibre/gui2/library/views.py
+++ b/src/calibre/gui2/library/views.py
@@ -6,6 +6,7 @@ __copyright__ = '2010, Kovid Goyal '
__docformat__ = 'restructuredtext en'
import os
+from functools import partial
from PyQt4.Qt import QTableView, Qt, QAbstractItemView, QMenu, pyqtSignal
@@ -40,7 +41,15 @@ class BooksView(QTableView): # {{{
self.setSelectionBehavior(QAbstractItemView.SelectRows)
self.setSortingEnabled(True)
self.selectionModel().currentRowChanged.connect(self._model.current_changed)
+
+ # {{{ Column Header setup
self.column_header = self.horizontalHeader()
+ self.column_header.setMovable(True)
+ self.column_header.sectionMoved.connect(self.save_state)
+ self.column_header.setContextMenuPolicy(Qt.CustomContextMenu)
+ self.column_header.customContextMenuRequested.connect(self.show_column_header_context_menu)
+
+ # }}}
self._model.database_changed.connect(self.database_changed)
hv = self.verticalHeader()
hv.setClickable(True)
@@ -49,6 +58,69 @@ class BooksView(QTableView): # {{{
self._model.about_to_be_sorted.connect(self.about_to_be_sorted)
self._model.sorting_done.connect(self.sorting_done)
+ def column_header_context_handler(self, action=None, column=None):
+ if not action or not column:
+ return
+ try:
+ idx = self.column_map.index(column)
+ except:
+ return
+ h = self.column_header
+
+ if action == 'hide':
+ h.setSectionHidden(idx, True)
+ elif action == 'show':
+ h.setSectionHidden(idx, False)
+ elif action == 'ascending':
+ self._model.sort(idx, Qt.AscendingOrder)
+ h.setSortIndicator(idx, Qt.AscendingOrder)
+ elif action == 'descending':
+ self._model.sort(idx, Qt.DescendingOrder)
+ h.setSortIndicator(idx, Qt.DescendingOrder)
+
+ self.save_state()
+
+ def show_column_header_context_menu(self, pos):
+ idx = self.column_header.logicalIndexAt(pos)
+ if idx > -1 and idx < len(self.column_map):
+ col = self.column_map[idx]
+ name = unicode(self.model().headerData(idx, Qt.Horizontal,
+ Qt.DisplayRole).toString())
+ self.column_header_context_menu = QMenu(self)
+ if col != 'ondevice':
+ self.column_header_context_menu.addAction(_('Hide column %s') %
+ name,
+ partial(self.column_header_context_handler, action='hide',
+ column=col))
+ self.column_header_context_menu.addAction(
+ _('Sort on column %s (ascending)') % name,
+ partial(self.column_header_context_handler,
+ action='ascending', column=col))
+ self.column_header_context_menu.addAction(
+ _('Sort on column %s (descending)') % name,
+ partial(self.column_header_context_handler,
+ action='descending', column=col))
+
+ hidden_cols = [self.column_map[i] for i in
+ range(self.column_header.count()) if
+ self.column_header.isSectionHidden(i)]
+ try:
+ hidden_cols.remove('ondevice')
+ except:
+ pass
+ if hidden_cols:
+ self.column_header_context_menu.addSeparator()
+ m = self.column_header_context_menu.addMenu(_('Show column'))
+ for col in hidden_cols:
+ hidx = self.column_map.index(col)
+ name = unicode(self.model().headerData(hidx, Qt.Horizontal,
+ Qt.DisplayRole).toString())
+ m.addAction(name,
+ partial(self.column_header_context_handler,
+ action='show', column=col))
+ self.column_header_context_menu.popup(self.column_header.mapToGlobal(pos))
+
+
def about_to_be_sorted(self, idc):
selected_rows = [r.row() for r in self.selectionModel().selectedRows()]
self.selected_ids = [idc(r) for r in selected_rows]
@@ -71,14 +143,47 @@ class BooksView(QTableView): # {{{
self._model.set_device_connected(is_connected)
self.set_ondevice_column_visibility()
+ def get_state(self):
+ h = self.column_header
+ cm = self.column_map
+ state = {}
+ state['hidden_columns'] = [cm[i] for i in range(h.count())
+ if h.isSectionHidden(i) and cm[i] != 'ondevice']
+ state['column_positions'] = {}
+ state['column_sizes'] = {}
+ for i in range(h.count()):
+ name = cm[i]
+ state['column_positions'][name] = h.visualIndex(i)
+ if name != 'ondevice':
+ state['column_sizes'][name] = h.sectionSize(i)
+ import pprint
+ pprint.pprint(state)
+ return state
+
+ def save_state(self):
+ # Only save if we have been initialized (set_database called)
+ if len(self.column_map) > 0:
+ state = self.get_state()
+ state
+
+ def apply_state(self, state):
+ pass
+
+ def restore_state(self):
+ pass
+
+
+ @property
+ def column_map(self):
+ return self._model.column_map
+
def database_changed(self, db):
for i in range(self.model().columnCount(None)):
if self.itemDelegateForColumn(i) in (self.rating_delegate,
self.timestamp_delegate, self.pubdate_delegate):
self.setItemDelegateForColumn(i, self.itemDelegate())
- cm = self._model.column_map
- self.set_ondevice_column_visibility()
+ cm = self.column_map
for colhead in cm:
if self._model.is_custom_column(colhead):
@@ -106,6 +211,9 @@ class BooksView(QTableView): # {{{
self.setItemDelegateForColumn(cm.index(colhead), getattr(self,
delegate+'_delegate'))
+ self.restore_state()
+ self.set_ondevice_column_visibility()
+
def set_context_menu(self, edit_metadata, send_to_device, convert, view,
save, open_folder, book_details, delete, similar_menu=None):
self.setContextMenuPolicy(Qt.DefaultContextMenu)
@@ -177,6 +285,7 @@ class BooksView(QTableView): # {{{
self.files_dropped.emit(paths)
def set_database(self, db):
+ self.save_state()
self._model.set_database(db)
self.tags_delegate.set_database(db)
self.authors_delegate.set_auto_complete_function(db.all_authors)
From 428cebd36505617f885702100968df37c7b66439 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Mon, 17 May 2010 22:41:53 -0600
Subject: [PATCH 128/324] Framework for saving/restoring state in the table
views. Needs to be linked up fully.
---
src/calibre/gui2/library/__init__.py | 3 +-
src/calibre/gui2/library/models.py | 79 ++++++++++++---------
src/calibre/gui2/library/views.py | 102 +++++++++++++++++++--------
src/calibre/gui2/ui.py | 8 ---
4 files changed, 120 insertions(+), 72 deletions(-)
diff --git a/src/calibre/gui2/library/__init__.py b/src/calibre/gui2/library/__init__.py
index 0080175bfa..8aa897b413 100644
--- a/src/calibre/gui2/library/__init__.py
+++ b/src/calibre/gui2/library/__init__.py
@@ -5,5 +5,6 @@ __license__ = 'GPL v3'
__copyright__ = '2010, Kovid Goyal '
__docformat__ = 'restructuredtext en'
+from PyQt4.Qt import Qt
-
+DEFAULT_SORT = ('timestamp', Qt.AscendingOrder)
diff --git a/src/calibre/gui2/library/models.py b/src/calibre/gui2/library/models.py
index abff227ae3..97e2317dce 100644
--- a/src/calibre/gui2/library/models.py
+++ b/src/calibre/gui2/library/models.py
@@ -22,6 +22,7 @@ from calibre.ebooks.metadata.meta import set_metadata as _set_metadata
from calibre.utils.search_query_parser import SearchQueryParser
from calibre.library.caches import _match, CONTAINS_MATCH, EQUALS_MATCH, REGEXP_MATCH
from calibre import strftime
+from calibre.gui2.library import DEFAULT_SORT
def human_readable(size, precision=1):
""" Convert a size in bytes into megabytes """
@@ -58,7 +59,7 @@ class BooksModel(QAbstractTableModel): # {{{
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.sorted_on = DEFAULT_SORT
self.sort_history = [self.sorted_on]
self.last_search = '' # The last search performed on this model
self.column_map = []
@@ -217,7 +218,6 @@ class BooksModel(QAbstractTableModel): # {{{
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):
@@ -776,7 +776,19 @@ class DeviceBooksModel(BooksModel): # {{{
self.db = []
self.map = []
self.sorted_map = []
+ self.sorted_on = DEFAULT_SORT
+ self.sort_history = [self.sorted_on]
self.unknown = _('Unknown')
+ self.column_map = ['inlibrary', 'title', 'authors', 'timestamp', 'size',
+ 'tags']
+ self.headers = {
+ 'inlibrary' : _('In Library'),
+ 'title' : _('Title'),
+ 'authors' : _('Author(s)'),
+ 'timestamp' : _('Date'),
+ 'size' : _('Size'),
+ 'tags' : _('Tags')
+ }
self.marked_for_deletion = {}
self.search_engine = OnDeviceSearch(self)
self.editable = True
@@ -813,7 +825,8 @@ class DeviceBooksModel(BooksModel): # {{{
return Qt.ItemIsUserCheckable # Can't figure out how to get the disabled flag in python
flags = QAbstractTableModel.flags(self, index)
if index.isValid() and self.editable:
- if index.column() in [0, 1] or (index.column() == 4 and self.db.supports_tags()):
+ cname = self.column_map[index.column()]
+ if cname in ('title', 'authors') or (cname == 'tags' and self.db.supports_tags()):
flags |= Qt.ItemIsEditable
return flags
@@ -881,22 +894,30 @@ class DeviceBooksModel(BooksModel): # {{{
x, y = authors_to_string(self.db[x].authors), \
authors_to_string(self.db[y].authors)
return cmp(x, y)
- fcmp = strcmp('title_sorter') if col == 0 else authorcmp if col == 1 else \
- sizecmp if col == 2 else datecmp if col == 3 else tagscmp if col == 4 else libcmp
+ cname = self.column_map[col]
+ fcmp = {
+ 'title': strcmp('title_sorter'),
+ 'authors' : authorcmp,
+ 'size' : sizecmp,
+ 'timestamp': datecmp,
+ 'tags': tagscmp,
+ 'inlibrary': libcmp,
+ }[cname]
self.map.sort(cmp=fcmp, reverse=descending)
if len(self.map) == len(self.db):
self.sorted_map = list(self.map)
else:
self.sorted_map = list(range(len(self.db)))
self.sorted_map.sort(cmp=fcmp, reverse=descending)
- self.sorted_on = (col, order)
+ self.sorted_on = (self.column_map[col], order)
+ self.sort_history.insert(0, self.sorted_on)
if reset:
self.reset()
def columnCount(self, parent):
if parent and parent.isValid():
return 0
- return 6
+ return len(self.column_map)
def rowCount(self, parent):
if parent and parent.isValid():
@@ -942,39 +963,35 @@ class DeviceBooksModel(BooksModel): # {{{
def data(self, index, role):
row, col = index.row(), index.column()
+ cname = self.column_map[col]
if role == Qt.DisplayRole or role == Qt.EditRole:
- if col == 0:
+ if cname == 'title':
text = self.db[self.map[row]].title
if not text:
text = self.unknown
return QVariant(text)
- elif col == 1:
+ elif cname == 'authors':
au = self.db[self.map[row]].authors
if not au:
au = self.unknown
-# if role == Qt.EditRole:
-# return QVariant(au)
return QVariant(authors_to_string(au))
- elif col == 2:
+ elif cname == 'size':
size = self.db[self.map[row]].size
return QVariant(human_readable(size))
- elif col == 3:
+ elif cname == 'timestamp':
dt = self.db[self.map[row]].datetime
dt = dt_factory(dt, assume_utc=True, as_utc=False)
return QVariant(strftime(TIME_FMT, dt.timetuple()))
- elif col == 4:
+ elif cname == 'tags':
tags = self.db[self.map[row]].tags
if tags:
return QVariant(', '.join(tags))
- elif role == Qt.TextAlignmentRole and index.column() in [2, 3]:
- return QVariant(Qt.AlignRight | Qt.AlignVCenter)
elif role == Qt.ToolTipRole and index.isValid():
- if self.map[index.row()] in self.indices_to_be_deleted():
- return QVariant('Marked for deletion')
- col = index.column()
- if col in [0, 1] or (col == 4 and self.db.supports_tags()):
+ if self.map[row] in self.indices_to_be_deleted():
+ return QVariant(_('Marked for deletion'))
+ if cname in ['title', 'authors'] or (cname == 'tags' and self.db.supports_tags()):
return QVariant(_("Double click to edit me
"))
- elif role == Qt.DecorationRole and col == 5:
+ elif role == Qt.DecorationRole and cname == 'inlibrary':
if self.db[self.map[row]].in_library:
return QVariant(self.bool_yes_icon)
@@ -983,14 +1000,9 @@ class DeviceBooksModel(BooksModel): # {{{
def headerData(self, section, orientation, role):
if role != Qt.DisplayRole:
return NONE
- text = ""
if orientation == Qt.Horizontal:
- if section == 0: text = _("Title")
- elif section == 1: text = _("Author(s)")
- elif section == 2: text = _("Size (MB)")
- elif section == 3: text = _("Date")
- elif section == 4: text = _("Tags")
- elif section == 5: text = _("In Library")
+ cname = self.column_map[section]
+ text = self.headers[cname]
return QVariant(text)
else:
return QVariant(section+1)
@@ -999,23 +1011,22 @@ class DeviceBooksModel(BooksModel): # {{{
done = False
if role == Qt.EditRole:
row, col = index.row(), index.column()
- if col in [2, 3]:
+ cname = self.column_map[col]
+ if cname in ('size', 'timestamp', 'inlibrary'):
return False
val = unicode(value.toString()).strip()
idx = self.map[row]
- if col == 0:
+ if cname == 'title' :
self.db[idx].title = val
self.db[idx].title_sorter = val
- elif col == 1:
+ elif cname == 'authors':
self.db[idx].authors = string_to_authors(val)
- elif col == 4:
+ elif cname == 'tags':
tags = [i.strip() for i in val.split(',')]
tags = [t for t in tags if t]
self.db.set_tags(self.db[idx], tags)
self.dataChanged.emit(index, index)
self.booklist_dirtied.emit()
- if col == self.sorted_on[0]:
- self.sort(col, self.sorted_on[1])
done = True
return done
diff --git a/src/calibre/gui2/library/views.py b/src/calibre/gui2/library/views.py
index 8734e7582a..ee7ab5e838 100644
--- a/src/calibre/gui2/library/views.py
+++ b/src/calibre/gui2/library/views.py
@@ -15,7 +15,8 @@ from calibre.gui2.library.delegates import RatingDelegate, PubDateDelegate, \
CcBoolDelegate, CcCommentsDelegate, CcDateDelegate
from calibre.gui2.library.models import BooksModel, DeviceBooksModel
from calibre.utils.config import tweaks
-from calibre.gui2 import error_dialog
+from calibre.gui2 import error_dialog, gprefs
+from calibre.gui2.library import DEFAULT_SORT
class BooksView(QTableView): # {{{
@@ -72,11 +73,9 @@ class BooksView(QTableView): # {{{
elif action == 'show':
h.setSectionHidden(idx, False)
elif action == 'ascending':
- self._model.sort(idx, Qt.AscendingOrder)
- h.setSortIndicator(idx, Qt.AscendingOrder)
+ self.sortByColumn(idx, Qt.AscendingOrder)
elif action == 'descending':
- self._model.sort(idx, Qt.DescendingOrder)
- h.setSortIndicator(idx, Qt.DescendingOrder)
+ self.sortByColumn(idx, Qt.DescendingOrder)
self.save_state()
@@ -143,12 +142,15 @@ class BooksView(QTableView): # {{{
self._model.set_device_connected(is_connected)
self.set_ondevice_column_visibility()
+ # Save/Restore State {{{
def get_state(self):
h = self.column_header
cm = self.column_map
state = {}
state['hidden_columns'] = [cm[i] for i in range(h.count())
if h.isSectionHidden(i) and cm[i] != 'ondevice']
+ state['sort_history'] = \
+ self.cleanup_sort_history(self.model().sort_history)
state['column_positions'] = {}
state['column_sizes'] = {}
for i in range(h.count()):
@@ -156,22 +158,83 @@ class BooksView(QTableView): # {{{
state['column_positions'][name] = h.visualIndex(i)
if name != 'ondevice':
state['column_sizes'][name] = h.sectionSize(i)
- import pprint
- pprint.pprint(state)
return state
def save_state(self):
# Only save if we have been initialized (set_database called)
if len(self.column_map) > 0:
state = self.get_state()
- state
+ name = unicode(self.objectName())
+ if name:
+ gprefs.set(name + ' books view state', state)
+
+ def cleanup_sort_history(self, sort_history):
+ history = []
+ for col, order in sort_history:
+ if col in self.column_map and (not history or history[0][0] != col):
+ history.append([col, order])
+ return history
+
+ def apply_sort_history(self, saved_history):
+ if not saved_history:
+ return
+ for col, order in reversed(self.cleanup_sort_history(saved_history)[:3]):
+ self.sortByColumn(self.column_map.index(col), order)
+ #self.model().sort_history = saved_history
def apply_state(self, state):
- pass
+ h = self.column_header
+ cmap = {}
+ hidden = state.get('hidden_columns', [])
+ for i, c in enumerate(self.column_map):
+ cmap[c] = i
+ if c != 'ondevice':
+ h.setSectionHidden(i, c in hidden)
+
+ positions = state.get('column_positions', {})
+ pmap = {}
+ for col, pos in positions.items():
+ if col in cmap:
+ pmap[pos] = col
+ for pos in sorted(pmap.keys(), reverse=True):
+ col = pmap[pos]
+ idx = cmap[col]
+ current_pos = h.visualIndex(idx)
+ if current_pos != pos:
+ h.moveSection(current_pos, pos)
+
+ sizes = state.get('column_sizes', {})
+ for col, size in sizes.items():
+ if col in cmap:
+ h.resizeSection(cmap[col], sizes[col])
+ self.apply_sort_history(state.get('sort_history', None))
def restore_state(self):
- pass
+ name = unicode(self.objectName())
+ old_state = None
+ if name:
+ old_state = gprefs.get(name + ' books view state', None)
+ if old_state is None:
+ # Default layout
+ old_state = {'hidden_columns': [],
+ 'sort_history':[DEFAULT_SORT],
+ 'column_positions': {},
+ 'column_sizes': {}}
+ h = self.column_header
+ cm = self.column_map
+ for i in range(h.count()):
+ name = cm[i]
+ old_state['column_positions'][name] = h.logicalIndex(i)
+ if name != 'ondevice':
+ old_state['column_sizes'][name] = \
+ max(self.sizeHintForColumn(i), h.sectionSizeHint(i))
+ if tweaks['sort_columns_at_startup'] is not None:
+ old_state['sort_history'] = tweaks['sort_columns_at_startup']
+
+ self.apply_state(old_state)
+
+ # }}}
@property
def column_map(self):
@@ -239,22 +302,6 @@ class BooksView(QTableView): # {{{
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)
- except ValueError:
- idx = 0
- QTableView.sortByColumn(self, idx, order)
@classmethod
def paths_from_event(cls, event):
@@ -340,9 +387,6 @@ class DeviceBooksView(BooksView): # {{{
def connect_dirtied_signal(self, slot):
self._model.booklist_dirtied.connect(slot)
- def sortByColumn(self, col, order):
- QTableView.sortByColumn(self, col, order)
-
def dropEvent(self, *args):
error_dialog(self, _('Not allowed'),
_('Dropping onto a device is not supported. First add the book to the calibre library.')).exec_()
diff --git a/src/calibre/gui2/ui.py b/src/calibre/gui2/ui.py
index ff063800d5..536c68f77d 100644
--- a/src/calibre/gui2/ui.py
+++ b/src/calibre/gui2/ui.py
@@ -532,7 +532,6 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
self.library_view.set_database(db)
self.library_view.model().set_book_on_device_func(self.book_on_device)
prefs['library_path'] = self.library_path
- self.library_view.restore_sort_at_startup(dynamic.get('sort_history', [('timestamp', Qt.DescendingOrder)]))
self.search.setFocus(Qt.OtherFocusReason)
self.cover_cache = CoverCache(self.library_path)
self.cover_cache.start()
@@ -1017,12 +1016,6 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
self.card_a_view.set_editable(self.device_manager.device.CAN_SET_METADATA)
self.card_b_view.set_database(cardblist)
self.card_b_view.set_editable(self.device_manager.device.CAN_SET_METADATA)
- for view in (self.memory_view, self.card_a_view, self.card_b_view):
- view.sortByColumn(3, Qt.DescendingOrder)
- if view.model().rowCount(None) > 1:
- view.resizeRowToContents(0)
- height = view.rowHeight(0)
- view.verticalHeader().setDefaultSectionSize(height)
self.sync_news()
self.sync_catalogs()
self.refresh_ondevice_info(device_connected = True)
@@ -2284,7 +2277,6 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
self.status_bar.clearMessage()
self.search.clear_to_help()
self.status_bar.reset_info()
- self.library_view.sortByColumn(3, Qt.DescendingOrder)
self.library_view.model().count_changed()
############################################################################
From e0e0093fe552ddacca4d4ffc709e4ff16389186d Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Mon, 17 May 2010 23:08:30 -0600
Subject: [PATCH 129/324] Link up save/restore column layout functionality
---
src/calibre/gui2/library/__init__.py | 2 +-
src/calibre/gui2/library/models.py | 5 +---
src/calibre/gui2/library/views.py | 43 +++++++++++++++++++---------
src/calibre/gui2/ui.py | 3 ++
src/calibre/library/database2.py | 9 +++---
5 files changed, 39 insertions(+), 23 deletions(-)
diff --git a/src/calibre/gui2/library/__init__.py b/src/calibre/gui2/library/__init__.py
index 8aa897b413..d7180de99a 100644
--- a/src/calibre/gui2/library/__init__.py
+++ b/src/calibre/gui2/library/__init__.py
@@ -7,4 +7,4 @@ __docformat__ = 'restructuredtext en'
from PyQt4.Qt import Qt
-DEFAULT_SORT = ('timestamp', Qt.AscendingOrder)
+DEFAULT_SORT = ('timestamp', Qt.DescendingOrder)
diff --git a/src/calibre/gui2/library/models.py b/src/calibre/gui2/library/models.py
index 97e2317dce..f5fbc822b8 100644
--- a/src/calibre/gui2/library/models.py
+++ b/src/calibre/gui2/library/models.py
@@ -231,7 +231,7 @@ class BooksModel(QAbstractTableModel): # {{{
def resort(self, reset=True):
try:
col = self.column_map.index(self.sorted_on[0])
- except:
+ except ValueError:
col = 0
self.sort(col, self.sorted_on[1], reset=reset)
@@ -853,9 +853,6 @@ class DeviceBooksModel(BooksModel): # {{{
self.searched.emit(False)
- def resort(self, reset):
- self.sort(self.sorted_on[0], self.sorted_on[1], reset=reset)
-
def sort(self, col, order, reset=True):
descending = order != Qt.AscendingOrder
def strcmp(attr):
diff --git a/src/calibre/gui2/library/views.py b/src/calibre/gui2/library/views.py
index ee7ab5e838..70a0e05a47 100644
--- a/src/calibre/gui2/library/views.py
+++ b/src/calibre/gui2/library/views.py
@@ -49,8 +49,8 @@ class BooksView(QTableView): # {{{
self.column_header.sectionMoved.connect(self.save_state)
self.column_header.setContextMenuPolicy(Qt.CustomContextMenu)
self.column_header.customContextMenuRequested.connect(self.show_column_header_context_menu)
-
# }}}
+
self._model.database_changed.connect(self.database_changed)
hv = self.verticalHeader()
hv.setClickable(True)
@@ -76,6 +76,8 @@ class BooksView(QTableView): # {{{
self.sortByColumn(idx, Qt.AscendingOrder)
elif action == 'descending':
self.sortByColumn(idx, Qt.DescendingOrder)
+ elif action == 'defaults':
+ self.apply_state(self.get_default_state())
self.save_state()
@@ -117,6 +119,13 @@ class BooksView(QTableView): # {{{
m.addAction(name,
partial(self.column_header_context_handler,
action='show', column=col))
+
+ self.column_header_context_menu.addSeparator()
+ self.column_header_context_menu.addAction(
+ _('Restore default layout'),
+ partial(self.column_header_context_handler,
+ action='defaults', column=col))
+
self.column_header_context_menu.popup(self.column_header.mapToGlobal(pos))
@@ -209,25 +218,30 @@ class BooksView(QTableView): # {{{
h.resizeSection(cmap[col], sizes[col])
self.apply_sort_history(state.get('sort_history', None))
+ def get_default_state(self):
+ old_state = {'hidden_columns': [],
+ 'sort_history':[DEFAULT_SORT],
+ 'column_positions': {},
+ 'column_sizes': {}}
+ h = self.column_header
+ cm = self.column_map
+ for i in range(h.count()):
+ name = cm[i]
+ old_state['column_positions'][name] = h.logicalIndex(i)
+ if name != 'ondevice':
+ old_state['column_sizes'][name] = \
+ max(self.sizeHintForColumn(i), h.sectionSizeHint(i))
+ if name == 'timestamp':
+ old_state['column_sizes'][name] += 12
+ return old_state
+
def restore_state(self):
name = unicode(self.objectName())
old_state = None
if name:
old_state = gprefs.get(name + ' books view state', None)
if old_state is None:
- # Default layout
- old_state = {'hidden_columns': [],
- 'sort_history':[DEFAULT_SORT],
- 'column_positions': {},
- 'column_sizes': {}}
- h = self.column_header
- cm = self.column_map
- for i in range(h.count()):
- name = cm[i]
- old_state['column_positions'][name] = h.logicalIndex(i)
- if name != 'ondevice':
- old_state['column_sizes'][name] = \
- max(self.sizeHintForColumn(i), h.sectionSizeHint(i))
+ old_state = self.get_default_state()
if tweaks['sort_columns_at_startup'] is not None:
old_state['sort_history'] = tweaks['sort_columns_at_startup']
@@ -379,6 +393,7 @@ class DeviceBooksView(BooksView): # {{{
def set_database(self, db):
self._model.set_database(db)
+ self.restore_state()
def resizeColumnsToContents(self):
QTableView.resizeColumnsToContents(self)
diff --git a/src/calibre/gui2/ui.py b/src/calibre/gui2/ui.py
index 536c68f77d..c8f1ae5ded 100644
--- a/src/calibre/gui2/ui.py
+++ b/src/calibre/gui2/ui.py
@@ -2408,6 +2408,9 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
config.set('main_window_geometry', self.saveGeometry())
dynamic.set('sort_history', self.library_view.model().sort_history)
self.sidebar.save_state()
+ for view in ('library_view', 'memory_view', 'card_a_view',
+ 'card_b_view'):
+ getattr(self, view).save_state()
def restart(self):
self.quit(restart=True)
diff --git a/src/calibre/library/database2.py b/src/calibre/library/database2.py
index 5971333078..ed56d35bdc 100644
--- a/src/calibre/library/database2.py
+++ b/src/calibre/library/database2.py
@@ -182,13 +182,13 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
columns = ['id', 'title',
# col table link_col query
('authors', 'authors', 'author', 'sortconcat(link.id, name)'),
- ('publisher', 'publishers', 'publisher', 'name'),
- ('rating', 'ratings', 'rating', 'ratings.rating'),
'timestamp',
'(SELECT MAX(uncompressed_size) FROM data WHERE book=books.id) size',
+ ('rating', 'ratings', 'rating', 'ratings.rating'),
('tags', 'tags', 'tag', 'group_concat(name)'),
'(SELECT text FROM comments WHERE book=books.id) comments',
('series', 'series', 'series', 'name'),
+ ('publisher', 'publishers', 'publisher', 'name'),
'series_index',
'sort',
'author_sort',
@@ -212,8 +212,9 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
custom_cols = list(sorted(custom_map.keys()))
lines.extend([custom_map[x] for x in custom_cols])
- self.FIELD_MAP = {'id':0, 'title':1, 'authors':2, 'publisher':3, 'rating':4, 'timestamp':5,
- 'size':6, 'tags':7, 'comments':8, 'series':9, 'series_index':10,
+ self.FIELD_MAP = {'id':0, 'title':1, 'authors':2, 'timestamp':3,
+ 'size':4, 'rating':5, 'tags':6, 'comments':7, 'series':8,
+ 'publisher':9, 'series_index':10,
'sort':11, 'author_sort':12, 'formats':13, 'isbn':14, 'path':15,
'lccn':16, 'pubdate':17, 'flags':18, 'uuid':19}
From 98163086fc67b137f8dc805ad48195b0394d10b5 Mon Sep 17 00:00:00 2001
From: Charles Haley <>
Date: Tue, 18 May 2010 06:23:38 +0100
Subject: [PATCH 130/324] First pass at winutil.c to associate usb vendor ids
with drives
---
src/calibre/utils/windows/winutil.c | 1 +
1 file changed, 1 insertion(+)
diff --git a/src/calibre/utils/windows/winutil.c b/src/calibre/utils/windows/winutil.c
index 2f176043b2..42b6462313 100644
--- a/src/calibre/utils/windows/winutil.c
+++ b/src/calibre/utils/windows/winutil.c
@@ -560,6 +560,7 @@ get_device_ancestors(HDEVINFO hDevInfo, DWORD index, PyObject *candidates, BOOL
return NULL;
}
interfaceDetailData->cbSize = sizeof (SP_INTERFACE_DEVICE_DETAIL_DATA);
+ devInfoData.cbSize = sizeof(SP_DEVINFO_DATA);
status = SetupDiGetDeviceInterfaceDetail (
hDevInfo, // Interface Device info handle
From ff2fa666d44c848a82d7bd29cf83246d1fa16b23 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Mon, 17 May 2010 23:41:23 -0600
Subject: [PATCH 131/324] Workaround bug in Qt that causes column header to not
update when scrolling the view
---
src/calibre/gui2/library/views.py | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/src/calibre/gui2/library/views.py b/src/calibre/gui2/library/views.py
index 70a0e05a47..0be288ba16 100644
--- a/src/calibre/gui2/library/views.py
+++ b/src/calibre/gui2/library/views.py
@@ -142,6 +142,11 @@ class BooksView(QTableView): # {{{
sm.select(idx, sm.Select|sm.Rows)
self.selected_ids = []
+ def scrollContentsBy(self, dx, dy):
+ # Needed as Qt bug causes headerview to not always update when scrolling
+ QTableView.scrollContentsBy(self, dx, dy)
+ self.column_header.update()
+
def set_ondevice_column_visibility(self):
m = self._model
self.column_header.setSectionHidden(m.column_map.index('ondevice'),
From ee374cac1387096f6075d36f05a5c1e4ae1db766 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Tue, 18 May 2010 00:44:55 -0600
Subject: [PATCH 132/324] ...
---
src/calibre/gui2/library/views.py | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/src/calibre/gui2/library/views.py b/src/calibre/gui2/library/views.py
index 0be288ba16..201f473b1e 100644
--- a/src/calibre/gui2/library/views.py
+++ b/src/calibre/gui2/library/views.py
@@ -145,7 +145,8 @@ class BooksView(QTableView): # {{{
def scrollContentsBy(self, dx, dy):
# Needed as Qt bug causes headerview to not always update when scrolling
QTableView.scrollContentsBy(self, dx, dy)
- self.column_header.update()
+ if dy != 0:
+ self.column_header.update()
def set_ondevice_column_visibility(self):
m = self._model
@@ -220,7 +221,10 @@ class BooksView(QTableView): # {{{
sizes = state.get('column_sizes', {})
for col, size in sizes.items():
if col in cmap:
- h.resizeSection(cmap[col], sizes[col])
+ sz = sizes[col]
+ if sz < 3:
+ sz = h.sectionSizeHint(cmap[col])
+ h.resizeSection(cmap[col], sz)
self.apply_sort_history(state.get('sort_history', None))
def get_default_state(self):
From ab33f4eb29e334a8feecebe5899db72f95da68a7 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Tue, 18 May 2010 09:23:08 -0600
Subject: [PATCH 133/324] Allow user to set text alignment for columns. Cleanup
and fix default column alignment
---
src/calibre/gui2/library/models.py | 26 +++++++--
src/calibre/gui2/library/views.py | 87 +++++++++++++++++++++---------
2 files changed, 84 insertions(+), 29 deletions(-)
diff --git a/src/calibre/gui2/library/models.py b/src/calibre/gui2/library/models.py
index f5fbc822b8..802e23e90c 100644
--- a/src/calibre/gui2/library/models.py
+++ b/src/calibre/gui2/library/models.py
@@ -30,6 +30,9 @@ def human_readable(size, precision=1):
TIME_FMT = '%d %b %Y'
+ALIGNMENT_MAP = {'left': Qt.AlignLeft, 'right': Qt.AlignRight, 'center':
+ Qt.AlignHCenter}
+
class BooksModel(QAbstractTableModel): # {{{
about_to_be_sorted = pyqtSignal(object, name='aboutToBeSorted')
@@ -64,6 +67,7 @@ class BooksModel(QAbstractTableModel): # {{{
self.last_search = '' # The last search performed on this model
self.column_map = []
self.headers = {}
+ self.alignment_map = {}
self.buffer_size = buffer
self.cover_cache = None
self.bool_yes_icon = QIcon(I('ok.svg'))
@@ -72,6 +76,19 @@ class BooksModel(QAbstractTableModel): # {{{
self.device_connected = False
self.read_config()
+ def change_alignment(self, colname, alignment):
+ if colname in self.column_map and alignment in ('left', 'right', 'center'):
+ old = self.alignment_map.get(colname, 'left')
+ if old == alignment:
+ return
+ self.alignment_map.pop(colname, None)
+ if alignment != 'left':
+ self.alignment_map[colname] = alignment
+ col = self.column_map.index(colname)
+ for row in xrange(self.rowCount(QModelIndex())):
+ self.dataChanged.emit(self.index(row, col), self.index(row,
+ col))
+
def is_custom_column(self, cc_label):
return cc_label in self.custom_columns
@@ -593,14 +610,17 @@ class BooksModel(QAbstractTableModel): # {{{
# 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
+ return NONE
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.TextAlignmentRole:
+ cname = self.column_map[index.column()]
+ ans = Qt.AlignVCenter | ALIGNMENT_MAP[self.alignment_map.get(cname,
+ 'left')]
+ return QVariant(ans)
#elif role == Qt.ToolTipRole and index.isValid():
# if self.column_map[index.column()] in self.editable_cols:
# return QVariant(_("Double click to edit me
"))
diff --git a/src/calibre/gui2/library/views.py b/src/calibre/gui2/library/views.py
index 201f473b1e..d3c7be433e 100644
--- a/src/calibre/gui2/library/views.py
+++ b/src/calibre/gui2/library/views.py
@@ -59,6 +59,7 @@ class BooksView(QTableView): # {{{
self._model.about_to_be_sorted.connect(self.about_to_be_sorted)
self._model.sorting_done.connect(self.sorting_done)
+ # Column Header Context Menu {{{
def column_header_context_handler(self, action=None, column=None):
if not action or not column:
return
@@ -78,6 +79,9 @@ class BooksView(QTableView): # {{{
self.sortByColumn(idx, Qt.DescendingOrder)
elif action == 'defaults':
self.apply_state(self.get_default_state())
+ elif action.startswith('align_'):
+ alignment = action.partition('_')[-1]
+ self._model.change_alignment(column, alignment)
self.save_state()
@@ -93,14 +97,22 @@ class BooksView(QTableView): # {{{
name,
partial(self.column_header_context_handler, action='hide',
column=col))
- self.column_header_context_menu.addAction(
- _('Sort on column %s (ascending)') % name,
+ m = self.column_header_context_menu.addMenu(
+ _('Sort on %s') % name)
+ m.addAction(_('Ascending'),
partial(self.column_header_context_handler,
action='ascending', column=col))
- self.column_header_context_menu.addAction(
- _('Sort on column %s (descending)') % name,
+ m.addAction(_('Descending'),
partial(self.column_header_context_handler,
action='descending', column=col))
+ m = self.column_header_context_menu.addMenu(
+ _('Change text alignment for %s') % name)
+ for x, t in (('left', _('Left')), ('right', _('Right')), ('center',
+ _('Center'))):
+ m.addAction(t,
+ partial(self.column_header_context_handler,
+ action='align_'+x, column=col))
+
hidden_cols = [self.column_map[i] for i in
range(self.column_header.count()) if
@@ -120,6 +132,7 @@ class BooksView(QTableView): # {{{
partial(self.column_header_context_handler,
action='show', column=col))
+
self.column_header_context_menu.addSeparator()
self.column_header_context_menu.addAction(
_('Restore default layout'),
@@ -127,8 +140,9 @@ class BooksView(QTableView): # {{{
action='defaults', column=col))
self.column_header_context_menu.popup(self.column_header.mapToGlobal(pos))
+ # }}}
-
+ # Sorting {{{
def about_to_be_sorted(self, idc):
selected_rows = [r.row() for r in self.selectionModel().selectedRows()]
self.selected_ids = [idc(r) for r in selected_rows]
@@ -141,13 +155,9 @@ class BooksView(QTableView): # {{{
for idx in indices:
sm.select(idx, sm.Select|sm.Rows)
self.selected_ids = []
+ # }}}
- def scrollContentsBy(self, dx, dy):
- # Needed as Qt bug causes headerview to not always update when scrolling
- QTableView.scrollContentsBy(self, dx, dy)
- if dy != 0:
- self.column_header.update()
-
+ # Ondevice column {{{
def set_ondevice_column_visibility(self):
m = self._model
self.column_header.setSectionHidden(m.column_map.index('ondevice'),
@@ -156,6 +166,7 @@ class BooksView(QTableView): # {{{
def set_device_connected(self, is_connected):
self._model.set_device_connected(is_connected)
self.set_ondevice_column_visibility()
+ # }}}
# Save/Restore State {{{
def get_state(self):
@@ -168,6 +179,7 @@ class BooksView(QTableView): # {{{
self.cleanup_sort_history(self.model().sort_history)
state['column_positions'] = {}
state['column_sizes'] = {}
+ state['column_alignment'] = self._model.alignment_map
for i in range(h.count()):
name = cm[i]
state['column_positions'][name] = h.visualIndex(i)
@@ -211,7 +223,7 @@ class BooksView(QTableView): # {{{
for col, pos in positions.items():
if col in cmap:
pmap[pos] = col
- for pos in sorted(pmap.keys(), reverse=True):
+ for pos in sorted(pmap.keys()):
col = pmap[pos]
idx = cmap[col]
current_pos = h.visualIndex(idx)
@@ -225,18 +237,28 @@ class BooksView(QTableView): # {{{
if sz < 3:
sz = h.sectionSizeHint(cmap[col])
h.resizeSection(cmap[col], sz)
+
self.apply_sort_history(state.get('sort_history', None))
+ for col, alignment in state.get('column_alignment', {}).items():
+ self._model.change_alignment(col, alignment)
+
def get_default_state(self):
- old_state = {'hidden_columns': [],
+ old_state = {
+ 'hidden_columns': [],
'sort_history':[DEFAULT_SORT],
'column_positions': {},
- 'column_sizes': {}}
+ 'column_sizes': {},
+ 'column_alignment': {
+ 'size':'center',
+ 'timestamp':'center',
+ 'pubdate':'center'},
+ }
h = self.column_header
cm = self.column_map
for i in range(h.count()):
name = cm[i]
- old_state['column_positions'][name] = h.logicalIndex(i)
+ old_state['column_positions'][name] = i
if name != 'ondevice':
old_state['column_sizes'][name] = \
max(self.sizeHintForColumn(i), h.sectionSizeHint(i))
@@ -259,9 +281,15 @@ class BooksView(QTableView): # {{{
# }}}
- @property
- def column_map(self):
- return self._model.column_map
+ # Initialization/Delegate Setup {{{
+
+ def set_database(self, db):
+ self.save_state()
+ self._model.set_database(db)
+ self.tags_delegate.set_database(db)
+ self.authors_delegate.set_auto_complete_function(db.all_authors)
+ self.series_delegate.set_auto_complete_function(db.all_series)
+ self.publisher_delegate.set_auto_complete_function(db.all_publishers)
def database_changed(self, db):
for i in range(self.model().columnCount(None)):
@@ -299,7 +327,9 @@ class BooksView(QTableView): # {{{
self.restore_state()
self.set_ondevice_column_visibility()
+ #}}}
+ # Context Menu {{{
def set_context_menu(self, edit_metadata, send_to_device, convert, view,
save, open_folder, book_details, delete, similar_menu=None):
self.setContextMenuPolicy(Qt.DefaultContextMenu)
@@ -324,8 +354,9 @@ class BooksView(QTableView): # {{{
def contextMenuEvent(self, event):
self.context_menu.popup(event.globalPos())
event.accept()
+ # }}}
-
+ # Drag 'n Drop {{{
@classmethod
def paths_from_event(cls, event):
'''
@@ -354,13 +385,17 @@ class BooksView(QTableView): # {{{
event.accept()
self.files_dropped.emit(paths)
- def set_database(self, db):
- self.save_state()
- self._model.set_database(db)
- self.tags_delegate.set_database(db)
- self.authors_delegate.set_auto_complete_function(db.all_authors)
- self.series_delegate.set_auto_complete_function(db.all_series)
- self.publisher_delegate.set_auto_complete_function(db.all_publishers)
+ # }}}
+
+ @property
+ def column_map(self):
+ return self._model.column_map
+
+ def scrollContentsBy(self, dx, dy):
+ # Needed as Qt bug causes headerview to not always update when scrolling
+ QTableView.scrollContentsBy(self, dx, dy)
+ if dy != 0:
+ self.column_header.update()
def close(self):
self._model.close()
From 7fb8fa9323445dcd5614fef1d1c36642956c088a Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Tue, 18 May 2010 09:58:09 -0600
Subject: [PATCH 134/324] Indicate current setting in column header context
menu
---
src/calibre/gui2/library/views.py | 15 ++++++++++++---
1 file changed, 12 insertions(+), 3 deletions(-)
diff --git a/src/calibre/gui2/library/views.py b/src/calibre/gui2/library/views.py
index d3c7be433e..ad0e82110c 100644
--- a/src/calibre/gui2/library/views.py
+++ b/src/calibre/gui2/library/views.py
@@ -99,19 +99,28 @@ class BooksView(QTableView): # {{{
column=col))
m = self.column_header_context_menu.addMenu(
_('Sort on %s') % name)
- m.addAction(_('Ascending'),
+ a = m.addAction(_('Ascending'),
partial(self.column_header_context_handler,
action='ascending', column=col))
- m.addAction(_('Descending'),
+ d = m.addAction(_('Descending'),
partial(self.column_header_context_handler,
action='descending', column=col))
+ if self._model.sorted_on[0] == col:
+ ac = a if self._model.sorted_on[1] == Qt.AscendingOrder else d
+ ac.setCheckable(True)
+ ac.setChecked(True)
m = self.column_header_context_menu.addMenu(
_('Change text alignment for %s') % name)
+ al = self._model.alignment_map.get(col, 'left')
for x, t in (('left', _('Left')), ('right', _('Right')), ('center',
_('Center'))):
- m.addAction(t,
+ a = m.addAction(t,
partial(self.column_header_context_handler,
action='align_'+x, column=col))
+ if al == x:
+ a.setCheckable(True)
+ a.setChecked(True)
+
hidden_cols = [self.column_map[i] for i in
From 5fa9a5b935197d42f9a6d9a66dbf0c7139ee2f1f Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Tue, 18 May 2010 10:43:44 -0600
Subject: [PATCH 135/324] Re-organize Send to device menu
---
src/calibre/gui2/device.py | 88 ++++++++++++++++++++++----------------
1 file changed, 52 insertions(+), 36 deletions(-)
diff --git a/src/calibre/gui2/device.py b/src/calibre/gui2/device.py
index 1bc35b6a2b..f31a8d1cdb 100644
--- a/src/calibre/gui2/device.py
+++ b/src/calibre/gui2/device.py
@@ -327,15 +327,17 @@ class DeviceManager(Thread):
class DeviceAction(QAction):
+ a_s = pyqtSignal(object)
+
def __init__(self, dest, delete, specific, icon_path, text, parent=None):
- if delete:
- text += ' ' + _('and delete from library')
QAction.__init__(self, QIcon(icon_path), text, parent)
self.dest = dest
self.delete = delete
self.specific = specific
- self.connect(self, SIGNAL('triggered(bool)'),
- lambda x : self.emit(SIGNAL('a_s(QAction)'), self))
+ self.triggered.connect(self.emit_triggered)
+
+ def emit_triggered(self, *args):
+ self.a_s.emit(self)
def __repr__(self):
return self.__class__.__name__ + ':%s:%s:%s'%(self.dest, self.delete,
@@ -356,6 +358,7 @@ class DeviceMenu(QMenu):
self.set_default_menu = self.addMenu(_('Set default send to device'
' action'))
+ self.addSeparator()
opts = email_config().parse()
default_account = None
if opts.accounts:
@@ -379,51 +382,65 @@ class DeviceMenu(QMenu):
self.connect(action2, SIGNAL('a_s(QAction)'),
self.action_triggered)
- _actions = [
+ basic_actions = [
('main:', False, False, I('reader.svg'),
_('Send to main memory')),
('carda:0', False, False, I('sd.svg'),
_('Send to storage card A')),
('cardb:0', False, False, I('sd.svg'),
_('Send to storage card B')),
- '-----',
+ ]
+
+ delete_actions = [
('main:', True, False, I('reader.svg'),
- _('Send to main memory')),
+ _('Main Memory')),
('carda:0', True, False, I('sd.svg'),
- _('Send to storage card A')),
+ _('Storage Card A')),
('cardb:0', True, False, I('sd.svg'),
- _('Send to storage card B')),
- '-----',
+ _('Storage Card B')),
+ ]
+
+ specific_actions = [
('main:', False, True, I('reader.svg'),
- _('Send specific format to main memory')),
+ _('Main Memory')),
('carda:0', False, True, I('sd.svg'),
- _('Send specific format to storage card A')),
+ _('Storage Card A')),
('cardb:0', False, True, I('sd.svg'),
- _('Send specific format to storage card B')),
+ _('Storage Card B')),
+ ]
+
- ]
if default_account is not None:
- _actions.insert(2, default_account)
- _actions.insert(6, list(default_account))
- _actions[6][1] = True
- for round in (0, 1):
- for dest, delete, specific, icon, text in _actions:
- if dest == '-':
- (self.set_default_menu if round else self).addSeparator()
- continue
- action = DeviceAction(dest, delete, specific, icon, text, self)
- self._memory.append(action)
- if round == 1:
- action.setCheckable(True)
- action.setText(action.text())
- self.group.addAction(action)
- self.set_default_menu.addAction(action)
- else:
- self.connect(action, SIGNAL('a_s(QAction)'),
- self.action_triggered)
- self.actions.append(action)
- self.addAction(action)
+ for x in (basic_actions, delete_actions):
+ ac = list(default_account)
+ if x is delete_actions:
+ ac[1] = True
+ x.insert(1, tuple(ac))
+ for menu in (self, self.set_default_menu):
+ for actions, desc in (
+ (basic_actions, ''),
+ (delete_actions, _('Send and delete from library')),
+ (specific_actions, _('Send specific format'))
+ ):
+ mdest = menu
+ if actions is not basic_actions:
+ mdest = menu.addMenu(desc)
+ self._memory.append(mdest)
+
+ for dest, delete, specific, icon, text in actions:
+ action = DeviceAction(dest, delete, specific, icon, text, self)
+ self._memory.append(action)
+ if menu is self.set_default_menu:
+ action.setCheckable(True)
+ action.setText(action.text())
+ self.group.addAction(action)
+ else:
+ action.a_s.connect(self.action_triggered)
+ self.actions.append(action)
+ mdest.addAction(action)
+ if actions is not specific_actions:
+ menu.addSeparator()
da = config['default_send_to_device_action']
done = False
@@ -437,8 +454,7 @@ class DeviceMenu(QMenu):
action.setChecked(True)
config['default_send_to_device_action'] = repr(action)
- self.connect(self.group, SIGNAL('triggered(QAction*)'),
- self.change_default_action)
+ self.group.triggered.connect(self.change_default_action)
if opts.accounts:
self.addSeparator()
self.addMenu(self.email_to_menu)
From 7c65e0e63a2b18025dace99fa7ed84627eafc7c1 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Tue, 18 May 2010 11:06:47 -0600
Subject: [PATCH 136/324] Change icon for folder device and more re-arranging
of send to device menu
---
resources/images/devices/folder.svg | 553 ++++++++++++++++++++
src/calibre/devices/folder_device/driver.py | 2 +-
src/calibre/gui2/device.py | 8 +-
3 files changed, 559 insertions(+), 4 deletions(-)
create mode 100644 resources/images/devices/folder.svg
diff --git a/resources/images/devices/folder.svg b/resources/images/devices/folder.svg
new file mode 100644
index 0000000000..74c1d628e4
--- /dev/null
+++ b/resources/images/devices/folder.svg
@@ -0,0 +1,553 @@
+
+
+
diff --git a/src/calibre/devices/folder_device/driver.py b/src/calibre/devices/folder_device/driver.py
index 792de9ee0a..00f2db282d 100644
--- a/src/calibre/devices/folder_device/driver.py
+++ b/src/calibre/devices/folder_device/driver.py
@@ -42,7 +42,7 @@ class FOLDER_DEVICE(USBMS):
SUPPORTS_SUB_DIRS = True
#: Icon for this device
- icon = I('sd.svg')
+ icon = I('devices/folder.svg')
METADATA_CACHE = '.metadata.calibre'
_main_prefix = ''
diff --git a/src/calibre/gui2/device.py b/src/calibre/gui2/device.py
index f31a8d1cdb..37f1d9e513 100644
--- a/src/calibre/gui2/device.py
+++ b/src/calibre/gui2/device.py
@@ -356,9 +356,9 @@ class DeviceMenu(QMenu):
self.actions = []
self._memory = []
- self.set_default_menu = self.addMenu(_('Set default send to device'
- ' action'))
- self.addSeparator()
+ self.set_default_menu = QMenu(_('Set default send to device action'))
+ self.set_default_menu.setIcon(QIcon(I('config.svg')))
+
opts = email_config().parse()
default_account = None
if opts.accounts:
@@ -470,6 +470,8 @@ class DeviceMenu(QMenu):
mitem.triggered.connect(lambda x : self.disconnect_from_folder.emit())
self.disconnect_from_folder_action = mitem
+ self.addSeparator()
+ self.addMenu(self.set_default_menu)
self.addSeparator()
annot = self.addAction(_('Fetch annotations (experimental)'))
annot.setEnabled(False)
From 7491a8d20ebf7fa0fb6c9d65a50301124ff9cf68 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Tue, 18 May 2010 11:13:27 -0600
Subject: [PATCH 137/324] Fix text alignment in device views and only save
valid states
---
src/calibre/gui2/library/models.py | 6 ++++++
src/calibre/gui2/library/views.py | 4 +++-
2 files changed, 9 insertions(+), 1 deletion(-)
diff --git a/src/calibre/gui2/library/models.py b/src/calibre/gui2/library/models.py
index 802e23e90c..bd8fb20741 100644
--- a/src/calibre/gui2/library/models.py
+++ b/src/calibre/gui2/library/models.py
@@ -1011,6 +1011,12 @@ class DeviceBooksModel(BooksModel): # {{{
elif role == Qt.DecorationRole and cname == 'inlibrary':
if self.db[self.map[row]].in_library:
return QVariant(self.bool_yes_icon)
+ elif role == Qt.TextAlignmentRole:
+ cname = self.column_map[index.column()]
+ ans = Qt.AlignVCenter | ALIGNMENT_MAP[self.alignment_map.get(cname,
+ 'left')]
+ return QVariant(ans)
+
return NONE
diff --git a/src/calibre/gui2/library/views.py b/src/calibre/gui2/library/views.py
index ad0e82110c..e5c6ffd5f7 100644
--- a/src/calibre/gui2/library/views.py
+++ b/src/calibre/gui2/library/views.py
@@ -44,6 +44,7 @@ class BooksView(QTableView): # {{{
self.selectionModel().currentRowChanged.connect(self._model.current_changed)
# {{{ Column Header setup
+ self.was_restored = False
self.column_header = self.horizontalHeader()
self.column_header.setMovable(True)
self.column_header.sectionMoved.connect(self.save_state)
@@ -198,7 +199,7 @@ class BooksView(QTableView): # {{{
def save_state(self):
# Only save if we have been initialized (set_database called)
- if len(self.column_map) > 0:
+ if len(self.column_map) > 0 and self.was_restored:
state = self.get_state()
name = unicode(self.objectName())
if name:
@@ -287,6 +288,7 @@ class BooksView(QTableView): # {{{
old_state['sort_history'] = tweaks['sort_columns_at_startup']
self.apply_state(old_state)
+ self.was_restored = True
# }}}
From 49d70e0f9ee0c0d4a9e7100cf4bc05e1849aaa2a Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Tue, 18 May 2010 15:04:20 -0600
Subject: [PATCH 138/324] JSONConfig: Fix encoding of top level str objects.
Add support for encoding bytearray objects. Remove pointless TableView class
and have jobs view remember its column layout
---
src/calibre/gui2/__init__.py | 30 +------------------
src/calibre/gui2/dialogs/jobs.ui | 13 ++-------
src/calibre/gui2/jobs.py | 50 +++++++++++++++++++++++++++++---
src/calibre/gui2/sidebar.py | 4 ---
src/calibre/gui2/widgets.py | 41 ++------------------------
src/calibre/utils/config.py | 26 +++++++++++++++--
6 files changed, 76 insertions(+), 88 deletions(-)
diff --git a/src/calibre/gui2/__init__.py b/src/calibre/gui2/__init__.py
index 9cb68ea01a..0cf565c928 100644
--- a/src/calibre/gui2/__init__.py
+++ b/src/calibre/gui2/__init__.py
@@ -8,7 +8,7 @@ from PyQt4.QtCore import QVariant, QFileInfo, QObject, SIGNAL, QBuffer, Qt, QSiz
QByteArray, QTranslator, QCoreApplication, QThread, \
QEvent, QTimer, pyqtSignal, QDate
from PyQt4.QtGui import QFileDialog, QMessageBox, QPixmap, QFileIconProvider, \
- QIcon, QTableView, QApplication, QDialog, QPushButton
+ QIcon, QApplication, QDialog, QPushButton
ORG_NAME = 'KovidsBrain'
APP_UID = 'libprs500'
@@ -294,34 +294,6 @@ class GetMetadata(QObject):
mi = MetaInformation('', [_('Unknown')])
self.emit(SIGNAL('metadata(PyQt_PyObject, PyQt_PyObject)'), id, mi)
-class TableView(QTableView):
-
- def __init__(self, parent):
- QTableView.__init__(self, parent)
- self.read_settings()
-
- def read_settings(self):
- self.cw = dynamic[self.__class__.__name__+'column width map']
-
- def write_settings(self):
- m = dynamic[self.__class__.__name__+'column width map']
- if m is None:
- m = {}
- cmap = getattr(self.model(), 'column_map', None)
- if cmap is not None:
- for i,c in enumerate(cmap):
- m[c] = self.columnWidth(i)
- dynamic[self.__class__.__name__+'column width map'] = m
- self.cw = m
-
- def restore_column_widths(self):
- if self.cw and len(self.cw):
- for i,c in enumerate(self.model().column_map):
- if c in self.cw:
- self.setColumnWidth(i, self.cw[c])
- return True
- return False
-
class FileIconProvider(QFileIconProvider):
ICONS = {
diff --git a/src/calibre/gui2/dialogs/jobs.ui b/src/calibre/gui2/dialogs/jobs.ui
index e2e345ca28..1fb23269e6 100644
--- a/src/calibre/gui2/dialogs/jobs.ui
+++ b/src/calibre/gui2/dialogs/jobs.ui
@@ -14,12 +14,12 @@
Active Jobs
-
+ :/images/jobs.svg:/images/jobs.svg
-
+ Qt::NoContextMenu
@@ -66,15 +66,8 @@
-
-
- JobsView
- QTableView
- widgets.h
-
-
-
+
diff --git a/src/calibre/gui2/jobs.py b/src/calibre/gui2/jobs.py
index a801e0db28..437e65632c 100644
--- a/src/calibre/gui2/jobs.py
+++ b/src/calibre/gui2/jobs.py
@@ -15,10 +15,11 @@ from PyQt4.Qt import QAbstractTableModel, QVariant, QModelIndex, Qt, \
from calibre.utils.ipc.server import Server
from calibre.utils.ipc.job import ParallelJob
-from calibre.gui2 import Dispatcher, error_dialog, NONE, config
+from calibre.gui2 import Dispatcher, error_dialog, NONE, config, gprefs
from calibre.gui2.device import DeviceJob
from calibre.gui2.dialogs.jobs_ui import Ui_JobsDialog
from calibre import __appname__
+from calibre.gui2.dialogs.job_view_ui import Ui_Dialog
class JobManager(QAbstractTableModel):
@@ -243,7 +244,32 @@ class ProgressBarDelegate(QAbstractItemDelegate):
opts.text = QString(_('Unavailable') if percent == 0 else '%d%%'%percent)
QApplication.style().drawControl(QStyle.CE_ProgressBar, opts, painter)
+class DetailView(QDialog, Ui_Dialog):
+
+ def __init__(self, parent, job):
+ QDialog.__init__(self, parent)
+ self.setupUi(self)
+ self.setWindowTitle(job.description)
+ self.job = job
+ self.next_pos = 0
+ self.update()
+ self.timer = QTimer(self)
+ self.timer.timeout.connect(self.update)
+ self.timer.start(1000)
+
+
+ def update(self):
+ f = self.job.log_file
+ f.seek(self.next_pos)
+ more = f.read()
+ self.next_pos = f.tell()
+ if more:
+ self.log.appendPlainText(more.decode('utf-8', 'replace'))
+
+
+
class JobsDialog(QDialog, Ui_JobsDialog):
+
def __init__(self, window, model):
QDialog.__init__(self, window)
Ui_JobsDialog.__init__(self)
@@ -252,8 +278,6 @@ class JobsDialog(QDialog, Ui_JobsDialog):
self.model = model
self.setWindowModality(Qt.NonModal)
self.setWindowTitle(__appname__ + _(' - Jobs'))
- self.connect(self.jobs_view.model(), SIGNAL('modelReset()'),
- self.jobs_view.resizeColumnsToContents)
self.connect(self.kill_button, SIGNAL('clicked()'),
self.kill_job)
self.connect(self.details_button, SIGNAL('clicked()'),
@@ -264,7 +288,21 @@ class JobsDialog(QDialog, Ui_JobsDialog):
self.jobs_view.model().kill_job)
self.pb_delegate = ProgressBarDelegate(self)
self.jobs_view.setItemDelegateForColumn(2, self.pb_delegate)
+ self.jobs_view.doubleClicked.connect(self.show_job_details)
+ self.jobs_view.horizontalHeader().setMovable(True)
+ state = gprefs.get('jobs view column layout', None)
+ if state is not None:
+ try:
+ self.jobs_view.horizontalHeader().restoreState(bytes(state))
+ except:
+ pass
+ def show_job_details(self, index):
+ row = index.row()
+ job = self.jobs_view.model().row_to_job(row)
+ d = DetailView(self, job)
+ d.exec_()
+ d.timer.stop()
def kill_job(self):
for index in self.jobs_view.selectedIndexes():
@@ -281,5 +319,9 @@ class JobsDialog(QDialog, Ui_JobsDialog):
self.model.kill_all_jobs()
def closeEvent(self, e):
- self.jobs_view.write_settings()
+ try:
+ state = bytearray(self.jobs_view.horizontalHeader().saveState())
+ gprefs['jobs view column layout'] = state
+ except:
+ pass
e.accept()
diff --git a/src/calibre/gui2/sidebar.py b/src/calibre/gui2/sidebar.py
index d6b58f165d..bd305912a0 100644
--- a/src/calibre/gui2/sidebar.py
+++ b/src/calibre/gui2/sidebar.py
@@ -33,16 +33,12 @@ class JobsButton(QFrame):
def initialize(self, jobs_dialog):
self.jobs_dialog = jobs_dialog
- self.jobs_dialog.jobs_view.restore_column_widths()
def mouseReleaseEvent(self, event):
if self.jobs_dialog.isVisible():
- self.jobs_dialog.jobs_view.write_settings()
self.jobs_dialog.hide()
else:
- self.jobs_dialog.jobs_view.read_settings()
self.jobs_dialog.show()
- self.jobs_dialog.jobs_view.restore_column_widths()
@property
def is_running(self):
diff --git a/src/calibre/gui2/widgets.py b/src/calibre/gui2/widgets.py
index 4b61677b12..8083cd4ba0 100644
--- a/src/calibre/gui2/widgets.py
+++ b/src/calibre/gui2/widgets.py
@@ -7,15 +7,15 @@ import re, os, traceback
from PyQt4.Qt import QListView, QIcon, QFont, QLabel, QListWidget, \
QListWidgetItem, QTextCharFormat, QApplication, \
QSyntaxHighlighter, QCursor, QColor, QWidget, \
- QPixmap, QPalette, QTimer, QDialog, QSplitterHandle, \
+ QPixmap, QPalette, QSplitterHandle, \
QAbstractListModel, QVariant, Qt, SIGNAL, pyqtSignal, \
QRegExp, QSettings, QSize, QModelIndex, QSplitter, \
QAbstractButton, QPainter, QLineEdit, QComboBox, \
QMenu, QStringListModel, QCompleter, QStringList
-from calibre.gui2 import human_readable, NONE, TableView, \
+from calibre.gui2 import human_readable, NONE, \
error_dialog, pixmap_to_data, dynamic
-from calibre.gui2.dialogs.job_view_ui import Ui_Dialog
+
from calibre.gui2.filename_pattern_ui import Ui_Form
from calibre import fit_image
from calibre.utils.fonts import fontconfig
@@ -399,41 +399,6 @@ class EjectButton(QAbstractButton):
painter.drawPixmap(0, 0, image)
-class DetailView(QDialog, Ui_Dialog):
-
- def __init__(self, parent, job):
- QDialog.__init__(self, parent)
- self.setupUi(self)
- self.setWindowTitle(job.description)
- self.job = job
- self.next_pos = 0
- self.update()
- self.timer = QTimer(self)
- self.connect(self.timer, SIGNAL('timeout()'), self.update)
- self.timer.start(1000)
-
-
- def update(self):
- f = self.job.log_file
- f.seek(self.next_pos)
- more = f.read()
- self.next_pos = f.tell()
- if more:
- self.log.appendPlainText(more.decode('utf-8', 'replace'))
-
-
-class JobsView(TableView):
-
- def __init__(self, parent):
- TableView.__init__(self, parent)
- self.connect(self, SIGNAL('doubleClicked(QModelIndex)'), self.show_details)
-
- def show_details(self, index):
- row = index.row()
- job = self.model().row_to_job(row)
- d = DetailView(self, job)
- d.exec_()
- d.timer.stop()
class FontFamilyModel(QAbstractListModel):
diff --git a/src/calibre/utils/config.py b/src/calibre/utils/config.py
index cb17085071..559721c193 100644
--- a/src/calibre/utils/config.py
+++ b/src/calibre/utils/config.py
@@ -6,7 +6,7 @@ __docformat__ = 'restructuredtext en'
'''
Manage application-wide preferences.
'''
-import os, re, cPickle, textwrap, traceback, plistlib, json
+import os, re, cPickle, textwrap, traceback, plistlib, json, base64
from copy import deepcopy
from functools import partial
from optparse import OptionParser as _OptionParser
@@ -636,11 +636,31 @@ class JSONConfig(XMLConfig):
EXTENSION = '.json'
+ def to_json(self, obj):
+ if isinstance(obj, bytearray):
+ return {'__class__': 'bytearray',
+ '__value__': base64.standard_b64encode(bytes(obj))}
+ raise TypeError(repr(obj) + ' is not JSON serializable')
+
+ def from_json(self, obj):
+ if '__class__' in obj:
+ if obj['__class__'] == 'bytearray':
+ return bytearray(base64.standard_b64decode(obj['__value__']))
+ return obj
+
def raw_to_object(self, raw):
- return json.loads(raw.decode('utf-8'))
+ return json.loads(raw.decode('utf-8'), object_hook=self.from_json)
def to_raw(self):
- return json.dumps(self, indent=2)
+ return json.dumps(self, indent=2, default=self.to_json)
+
+ def __getitem__(self, key):
+ return dict.__getitem__(self, key)
+
+ def __setitem__(self, key, val):
+ dict.__setitem__(self, key, val)
+ self.commit()
+
def _prefs():
From 90459ef792f20c90b90823502791cc51e64d4846 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Tue, 18 May 2010 16:56:29 -0600
Subject: [PATCH 139/324] Fix column customization via preferences
---
src/calibre/gui2/dialogs/config/__init__.py | 125 +++++++++++-------
.../dialogs/config/create_custom_column.py | 2 +-
src/calibre/gui2/ui.py | 5 +-
3 files changed, 77 insertions(+), 55 deletions(-)
diff --git a/src/calibre/gui2/dialogs/config/__init__.py b/src/calibre/gui2/dialogs/config/__init__.py
index ff50ff7718..f92c52e204 100644
--- a/src/calibre/gui2/dialogs/config/__init__.py
+++ b/src/calibre/gui2/dialogs/config/__init__.py
@@ -1,6 +1,7 @@
__license__ = 'GPL v3'
__copyright__ = '2008, Kovid Goyal '
-import os, re, time, textwrap, copy
+
+import os, re, time, textwrap, copy, sys
from PyQt4.Qt import QDialog, QListWidgetItem, QIcon, \
QDesktopServices, QVBoxLayout, QLabel, QPlainTextEdit, \
@@ -10,7 +11,7 @@ from PyQt4.Qt import QDialog, QListWidgetItem, QIcon, \
QDialogButtonBox, QTabWidget, QBrush, QLineEdit, \
QProgressDialog
-from calibre.constants import iswindows, isosx, preferred_encoding
+from calibre.constants import iswindows, isosx
from calibre.gui2.dialogs.config.config_ui import Ui_Dialog
from calibre.gui2.dialogs.config.create_custom_column import CreateCustomColumn
from calibre.gui2 import choose_dir, error_dialog, config, \
@@ -330,7 +331,7 @@ class ConfigDialog(ResizableDialog, Ui_Dialog):
def category_current_changed(self, n, p):
self.stackedWidget.setCurrentIndex(n.row())
- def __init__(self, parent, model, server=None):
+ def __init__(self, parent, library_view, 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()
@@ -338,8 +339,9 @@ class ConfigDialog(ResizableDialog, Ui_Dialog):
self.category_view.currentChanged = self.category_current_changed
self.category_view.setModel(self._category_model)
self.parent = parent
- self.model = model
- self.db = model.db
+ self.library_view = library_view
+ self.model = library_view.model()
+ self.db = self.model.db
self.server = server
path = prefs['library_path']
self.location.setText(path if path else '')
@@ -364,26 +366,27 @@ class ConfigDialog(ResizableDialog, Ui_Dialog):
self.new_version_notification.setChecked(config['new_version_notification'])
# Set up columns
- # Make copies of maps so that internal changes aren't put into the real maps
- self.colmap = config['column_map'][:]
+ colmap = list(self.model.column_map)
+ state = self.library_view.get_state()
+ hidden_cols = state['hidden_columns']
+ positions = state['column_positions']
+ colmap.sort(cmp=lambda x,y: cmp(positions[x], positions[y]))
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.orig_headers[col], self.columns)
- else:
- item = QListWidgetItem(self.custcols[col]['name'], self.columns)
+ for col in colmap:
+ item = QListWidgetItem(self.model.headers[col], self.columns)
item.setData(Qt.UserRole, QVariant(col))
- item.setFlags(Qt.ItemIsEnabled|Qt.ItemIsUserCheckable|Qt.ItemIsSelectable)
- 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)
+ flags = Qt.ItemIsEnabled|Qt.ItemIsSelectable
+ if col != 'ondevice':
+ flags |= Qt.ItemIsUserCheckable
+ item.setFlags(flags)
+ if col != 'ondevice':
+ item.setCheckState(Qt.Unchecked if col in hidden_cols else
+ Qt.Checked)
+ self.column_up.clicked.connect(self.up_column)
+ self.column_down.clicked.connect(self.down_column)
+ self.del_custcol_button.clicked.connect(self.del_custcol)
+ self.add_custcol_button.clicked.connect(self.add_custcol)
+ self.edit_custcol_button.clicked.connect(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)
@@ -647,6 +650,7 @@ class ConfigDialog(ResizableDialog, Ui_Dialog):
self.input_order.insertItem(idx+1, self.input_order.takeItem(idx))
self.input_order.setCurrentRow(idx+1)
+ # Column settings {{{
def up_column(self):
idx = self.columns.currentRow()
if idx > 0:
@@ -683,6 +687,53 @@ class ConfigDialog(ResizableDialog, Ui_Dialog):
def edit_custcol(self):
CreateCustomColumn(self, True, self.model.orig_headers, ALL_COLUMNS)
+ def apply_custom_column_changes(self):
+ config_cols = [unicode(self.columns.item(i).data(Qt.UserRole).toString())\
+ for i in range(self.columns.count())]
+ if not config_cols:
+ config_cols = ['title']
+ removed_cols = set(self.model.column_map) - set(config_cols)
+ hidden_cols = set([unicode(self.columns.item(i).data(Qt.UserRole).toString())\
+ for i in range(self.columns.count()) \
+ if self.columns.item(i).checkState()==Qt.Unchecked])
+ hidden_cols = hidden_cols.union(removed_cols) # Hide removed cols
+ hidden_cols = list(hidden_cols.intersection(set(self.model.column_map)))
+ if 'ondevice' in hidden_cols:
+ hidden_cols.remove('ondevice')
+ def col_pos(x, y):
+ xidx = config_cols.index(x) if x in config_cols else sys.maxint
+ yidx = config_cols.index(y) if y in config_cols else sys.maxint
+ return cmp(xidx, yidx)
+ positions = {}
+ for i, col in enumerate((sorted(self.model.column_map, cmp=col_pos))):
+ positions[col] = i
+ state = {'hidden_columns': hidden_cols, 'column_positions':positions}
+ self.library_view.apply_state(state)
+ self.library_view.save_state()
+
+ 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'],
+ display = self.custcols[c]['display'])
+ 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'],
+ display = self.custcols[c]['display'])
+ if '*must_restart' in self.custcols[c]:
+ must_restart = True
+ return must_restart
+ # }}}
+
def view_server_logs(self):
from calibre.library.server import log_access_file, log_error_file
d = QDialog(self)
@@ -776,33 +827,7 @@ class ConfigDialog(ResizableDialog, Ui_Dialog):
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
- ####### Now deal with changes to columns
- 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]
- 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'],
- display = self.custcols[c]['display'])
- 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'],
- display = self.custcols[c]['display'])
- if '*must_restart' in self.custcols[c]:
- must_restart = True
+ must_restart = self.apply_custom_column_changes()
config['toolbar_icon_size'] = self.ICON_SIZES[self.toolbar_button_size.currentIndex()]
config['show_text_in_toolbar'] = bool(self.show_toolbar_text.isChecked())
diff --git a/src/calibre/gui2/dialogs/config/create_custom_column.py b/src/calibre/gui2/dialogs/config/create_custom_column.py
index 98aa3c99e0..5b470123a4 100644
--- a/src/calibre/gui2/dialogs/config/create_custom_column.py
+++ b/src/calibre/gui2/dialogs/config/create_custom_column.py
@@ -123,7 +123,7 @@ class CreateCustomColumn(QDialog, Ui_QCreateCustomColumn):
if ':' in col or ' ' in col or col.lower() != col:
return self.simple_error('', _('The lookup name must be lower case and cannot contain ":"s or spaces'))
- date_format = None
+ date_format = {}
if col_type == 'datetime':
if self.date_format_box.text():
date_format = {'date_format':unicode(self.date_format_box.text())}
diff --git a/src/calibre/gui2/ui.py b/src/calibre/gui2/ui.py
index c8f1ae5ded..3f67e4184c 100644
--- a/src/calibre/gui2/ui.py
+++ b/src/calibre/gui2/ui.py
@@ -2237,7 +2237,7 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
_('Cannot configure before calibre is restarted.'))
d.exec_()
return
- d = ConfigDialog(self, self.library_view.model(),
+ d = ConfigDialog(self, self.library_view,
server=self.content_server)
d.exec_()
@@ -2255,9 +2255,6 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
self.save_menu.actions()[3].setText(
_('Save only %s format to disk in a single directory')%
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()
From 17dde7b2065def6b8e1557000ae0a4003ef156f5 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Wed, 19 May 2010 00:09:17 -0600
Subject: [PATCH 140/324] Partial implementation of the new SONY drivers.
Updating sony cache not yet supported.
---
src/calibre/devices/prs505/books.py | 370 -----------------------
src/calibre/devices/prs505/driver.py | 47 +--
src/calibre/devices/prs505/sony_cache.py | 249 +++++++++++++++
3 files changed, 277 insertions(+), 389 deletions(-)
delete mode 100644 src/calibre/devices/prs505/books.py
create mode 100644 src/calibre/devices/prs505/sony_cache.py
diff --git a/src/calibre/devices/prs505/books.py b/src/calibre/devices/prs505/books.py
deleted file mode 100644
index 61f3e3c363..0000000000
--- a/src/calibre/devices/prs505/books.py
+++ /dev/null
@@ -1,370 +0,0 @@
-__license__ = 'GPL v3'
-__copyright__ = '2008, Kovid Goyal '
-'''
-'''
-import re, time, functools
-from uuid import uuid4 as _uuid
-import xml.dom.minidom as dom
-from base64 import b64encode as encode
-
-
-from calibre.devices.usbms.books import BookList as _BookList
-from calibre.devices import strftime as _strftime
-from calibre.devices.prs505 import MEDIA_XML, CACHE_XML
-from calibre.devices.errors import PathError
-
-strftime = functools.partial(_strftime, zone=time.gmtime)
-
-MIME_MAP = {
- "lrf" : "application/x-sony-bbeb",
- 'lrx' : 'application/x-sony-bbeb',
- "rtf" : "application/rtf",
- "pdf" : "application/pdf",
- "txt" : "text/plain" ,
- 'epub': 'application/epub+zip',
- }
-
-def uuid():
- return str(_uuid()).replace('-', '', 1).upper()
-
-def sortable_title(title):
- return re.sub('^\s*A\s+|^\s*The\s+|^\s*An\s+', '', title).rstrip()
-
-class BookList(_BookList):
-
- def __init__(self, oncard, prefix, settings):
- _BookList.__init__(self, oncard, prefix, settings)
- if prefix is None:
- return
- self.sony_id_cache = {}
- self.books_lpath_cache = {}
- opts = settings()
- self.collections = opts.extra_customization.split(',') if opts.extra_customization else []
- db = CACHE_XML if oncard else MEDIA_XML
- with open(prefix + db, 'rb') as xml_file:
- xml_file.seek(0)
- self.document = dom.parse(xml_file)
- self.root_element = self.document.documentElement
- self.mountpath = prefix
- records = self.root_element.getElementsByTagName('records')
-
- if records:
- self.prefix = 'xs1:'
- self.root_element = records[0]
- else:
- self.prefix = ''
- for child in self.root_element.childNodes:
- if child.nodeType == child.ELEMENT_NODE and child.hasAttribute("id"):
- self.sony_id_cache[child.getAttribute('id')] = child.getAttribute('path')
- # set the key to none. Will be filled in later when booklist is built
- self.books_lpath_cache[child.getAttribute('path')] = None
- self.tag_order = {}
-
- paths = self.purge_corrupted_files()
- for path in paths:
- try:
- self.del_file(path, end_session=False)
- except PathError: # Incase this is a refetch without a sync in between
- continue
-
-
- def max_id(self):
- max = 0
- for child in self.root_element.childNodes:
- if child.nodeType == child.ELEMENT_NODE and child.hasAttribute("id"):
- nid = int(child.getAttribute('id'))
- if nid > max:
- max = nid
- return max
-
- def is_id_valid(self, id):
- '''Return True iff there is an element with C{id==id}.'''
- id = str(id)
- for child in self.root_element.childNodes:
- if child.nodeType == child.ELEMENT_NODE and child.hasAttribute("id"):
- if child.getAttribute('id') == id:
- return True
- return False
-
- def supports_tags(self):
- return True
-
- def add_book(self, book, replace_metadata):
- # Add a node into the DOM tree, representing a book. Also add to booklist
- if book in self:
- # replacing metadata for book
- self.delete_node(book.lpath)
- else:
- self.append(book)
- if not replace_metadata:
- if self.books_lpath_cache.has_key(book.lpath):
- self.books_lpath_cache[book.lpath] = book
- return
- # Book not in metadata. Add it. Note that we don't need to worry about
- # extra books in the Sony metadata. The reader deletes them for us when
- # we disconnect. That said, if it becomes important one day, we can do
- # it by scanning the books_lpath_cache for None entries and removing the
- # corresponding nodes.
- self.books_lpath_cache[book.lpath] = book
- cid = self.max_id()+1
- node = self.document.createElement(self.prefix + "text")
- self.sony_id_cache[cid] = book.lpath
- mime = MIME_MAP.get(book.lpath.rpartition('.')[-1].lower(), MIME_MAP['epub'])
- try:
- sourceid = str(self[0].sourceid) if len(self) else '1'
- except:
- sourceid = '1'
- attrs = {
- "title" : book.title,
- 'titleSorter' : sortable_title(book.title),
- "author" : book.format_authors() if book.format_authors() else _('Unknown'),
- "page":"0", "part":"0", "scale":"0", \
- "sourceid":sourceid, "id":str(cid), "date":"", \
- "mime":mime, "path":book.lpath, "size":str(book.size)
- }
- for attr in attrs.keys():
- node.setAttributeNode(self.document.createAttribute(attr))
- node.setAttribute(attr, attrs[attr])
- try:
- w, h, data = book.thumbnail
- except:
- w, h, data = None, None, None
-
- if data:
- th = self.document.createElement(self.prefix + "thumbnail")
- th.setAttribute("width", str(w))
- th.setAttribute("height", str(h))
- jpeg = self.document.createElement(self.prefix + "jpeg")
- jpeg.appendChild(self.document.createTextNode(encode(data)))
- th.appendChild(jpeg)
- node.appendChild(th)
- self.root_element.appendChild(node)
-
- tags = []
- for item in self.collections:
- item = item.strip()
- mitem = getattr(book, item, None)
- titems = []
- if mitem:
- if isinstance(mitem, list):
- titems = mitem
- else:
- titems = [mitem]
- if item == 'tags' and titems:
- litems = []
- for i in titems:
- if not i.strip().startswith('[') and not i.strip().endswith(']'):
- litems.append(i)
- titems = litems
- tags.extend(titems)
- if tags:
- tags = list(set(tags))
- if hasattr(book, 'tag_order'):
- self.tag_order.update(book.tag_order)
- self.set_playlists(cid, tags)
- return True # metadata cache has changed. Must sync at end
-
- def _delete_node(self, node):
- nid = node.getAttribute('id')
- self.remove_from_playlists(nid)
- node.parentNode.removeChild(node)
- node.unlink()
-
- def delete_node(self, lpath):
- '''
- Remove DOM node corresponding to book with lpath.
- Also remove book from any collections it is part of.
- '''
- for child in self.root_element.childNodes:
- if child.nodeType == child.ELEMENT_NODE and child.hasAttribute("id"):
- if child.getAttribute('path') == lpath:
- self._delete_node(child)
- break
-
- def remove_book(self, book):
- '''
- Remove DOM node corresponding to book with C{path == path}.
- Also remove book from any collections it is part of, and remove
- from the booklist
- '''
- self.remove(book)
- self.delete_node(book.lpath)
-
- def playlists(self):
- ans = []
- for c in self.root_element.childNodes:
- if hasattr(c, 'tagName') and c.tagName.endswith('playlist'):
- ans.append(c)
- return ans
-
- def playlist_items(self):
- plitems = []
- for pl in self.playlists():
- for c in pl.childNodes:
- if hasattr(c, 'tagName') and c.tagName.endswith('item') and \
- hasattr(c, 'getAttribute'):
- try:
- c.getAttribute('id')
- except: # Unlinked node
- continue
- plitems.append(c)
- return plitems
-
- def purge_corrupted_files(self):
- if not self.root_element:
- return []
- corrupted = self.root_element.getElementsByTagName(self.prefix+'corrupted')
- paths = []
- for c in corrupted:
- paths.append(c.getAttribute('path'))
- c.parentNode.removeChild(c)
- c.unlink()
- return paths
-
- def purge_empty_playlists(self):
- ''' Remove all playlists that have no children. Also removes any invalid playlist items.'''
- for pli in self.playlist_items():
- try:
- if not self.is_id_valid(pli.getAttribute('id')):
- pli.parentNode.removeChild(pli)
- pli.unlink()
- except:
- continue
- for pl in self.playlists():
- empty = True
- for c in pl.childNodes:
- if hasattr(c, 'tagName') and c.tagName.endswith('item'):
- empty = False
- break
- if empty:
- pl.parentNode.removeChild(pl)
- pl.unlink()
-
- def playlist_by_title(self, title):
- for pl in self.playlists():
- if pl.getAttribute('title').lower() == title.lower():
- return pl
-
- def add_playlist(self, title):
- cid = self.max_id()+1
- pl = self.document.createElement(self.prefix+'playlist')
- pl.setAttribute('id', str(cid))
- pl.setAttribute('title', title)
- pl.setAttribute('uuid', uuid())
- self.root_element.insertBefore(pl, self.root_element.childNodes[-1])
- return pl
-
- def remove_from_playlists(self, id):
- for pli in self.playlist_items():
- if pli.getAttribute('id') == str(id):
- pli.parentNode.removeChild(pli)
- pli.unlink()
-
- def set_tags(self, book, tags):
- tags = [t for t in tags if t]
- book.tags = tags
- self.set_playlists(book.id, tags)
-
- def set_playlists(self, id, collections):
- self.remove_from_playlists(id)
- for collection in set(collections):
- coll = self.playlist_by_title(collection)
- if not coll:
- coll = self.add_playlist(collection)
- item = self.document.createElement(self.prefix+'item')
- item.setAttribute('id', str(id))
- coll.appendChild(item)
-
- def next_id(self):
- return self.document.documentElement.getAttribute('nextID')
-
- def set_next_id(self, id):
- self.document.documentElement.setAttribute('nextID', str(id))
-
- def write(self, stream):
- """ Write XML representation of DOM tree to C{stream} """
- src = self.document.toxml('utf-8') + '\n'
- stream.write(src.replace("'", '''))
-
- def reorder_playlists(self):
- for title in self.tag_order.keys():
- pl = self.playlist_by_title(title)
- if not pl:
- continue
- # make a list of the ids
- sony_ids = [id.getAttribute('id') \
- for id in pl.childNodes if hasattr(id, 'getAttribute')]
- # convert IDs in playlist to a list of lpaths
- sony_paths = [self.sony_id_cache[id] for id in sony_ids]
- # create list of books containing lpaths
- books = [self.books_lpath_cache.get(p, None) for p in sony_paths]
- # create dict of db_id -> sony_id
- imap = {}
- for book, sony_id in zip(books, sony_ids):
- if book is not None:
- db_id = book.application_id
- if db_id is None:
- db_id = book.db_id
- if db_id is not None:
- imap[book.application_id] = sony_id
- # filter the list, removing books not on device but on playlist
- books = [i for i in books if i is not None]
- # filter the order specification to the books we have
- ordered_ids = [db_id for db_id in self.tag_order[title] if db_id in imap]
-
- # rewrite the playlist in the correct order
- if len(ordered_ids) < len(pl.childNodes):
- continue
- children = [i for i in pl.childNodes if hasattr(i, 'getAttribute')]
- for child in children:
- pl.removeChild(child)
- child.unlink()
- for id in ordered_ids:
- item = self.document.createElement(self.prefix+'item')
- item.setAttribute('id', str(imap[id]))
- pl.appendChild(item)
-
-def fix_ids(main, carda, cardb):
- '''
- Adjust ids the XML databases.
- '''
- if hasattr(main, 'purge_empty_playlists'):
- main.purge_empty_playlists()
- if hasattr(carda, 'purge_empty_playlists'):
- carda.purge_empty_playlists()
- if hasattr(cardb, 'purge_empty_playlists'):
- cardb.purge_empty_playlists()
-
- def regen_ids(db):
- if not hasattr(db, 'root_element'):
- return
- id_map = {}
- db.purge_empty_playlists()
- cid = 0 if db == main else 1
- for child in db.root_element.childNodes:
- if child.nodeType == child.ELEMENT_NODE and child.hasAttribute('id'):
- id_map[child.getAttribute('id')] = str(cid)
- child.setAttribute("sourceid",
- '0' if getattr(child, 'tagName', '').endswith('playlist') else '1')
- child.setAttribute('id', str(cid))
- cid += 1
-
- for item in db.playlist_items():
- oid = item.getAttribute('id')
- try:
- item.setAttribute('id', id_map[oid])
- except KeyError:
- item.parentNode.removeChild(item)
- item.unlink()
- db.reorder_playlists()
- db.sony_id_cache = {}
- for child in db.root_element.childNodes:
- if child.nodeType == child.ELEMENT_NODE and child.hasAttribute("id"):
- db.sony_id_cache[child.getAttribute('id')] = child.getAttribute('path')
-
-
- regen_ids(main)
- regen_ids(carda)
- regen_ids(cardb)
-
- main.set_next_id(str(main.max_id()+1))
diff --git a/src/calibre/devices/prs505/driver.py b/src/calibre/devices/prs505/driver.py
index 9926e5f61c..0bf2a1de82 100644
--- a/src/calibre/devices/prs505/driver.py
+++ b/src/calibre/devices/prs505/driver.py
@@ -13,9 +13,9 @@ import os
import re
from calibre.devices.usbms.driver import USBMS
-from calibre.devices.prs505.books import BookList as PRS_BookList, fix_ids
from calibre.devices.prs505 import MEDIA_XML
from calibre.devices.prs505 import CACHE_XML
+from calibre.devices.prs505.sony_cache import XMLCache
from calibre import __appname__
class PRS505(USBMS):
@@ -27,8 +27,6 @@ class PRS505(USBMS):
supported_platforms = ['windows', 'osx', 'linux']
path_sep = '/'
- booklist_class = PRS_BookList # See usbms.driver for some explanation of this
-
FORMATS = ['epub', 'lrf', 'lrx', 'rtf', 'pdf', 'txt']
VENDOR_ID = [0x054c] #: SONY Vendor Id
@@ -72,23 +70,34 @@ class PRS505(USBMS):
fname = base + suffix + '.' + fname.rpartition('.')[-1]
return fname
- def sync_booklists(self, booklists, end_session=True):
- fix_ids(*booklists)
- if not os.path.exists(self._main_prefix):
- os.makedirs(self._main_prefix)
- with open(self._main_prefix + MEDIA_XML, 'wb') as f:
- booklists[0].write(f)
+ def initialize_XML_cache(self):
+ paths = {}
+ for prefix, path, source_id in [
+ ('main', MEDIA_XML, 0),
+ ('card_a', CACHE_XML, 1),
+ ('card_b', CACHE_XML, 2)
+ ]:
+ prefix = getattr(self, '_%s_prefix'%prefix)
+ if prefix is not None and os.path.exists(prefix):
+ paths[source_id] = os.path.join(prefix, *(path.split('/')))
+ d = os.path.dirname(paths[source_id])
+ if not os.path.exists(d):
+ os.makedirs(d)
+ return XMLCache(paths)
- def write_card_prefix(prefix, listid):
- if prefix is not None and hasattr(booklists[listid], 'write'):
- tgt = os.path.join(prefix, *(CACHE_XML.split('/')))
- base = os.path.dirname(tgt)
- if not os.path.exists(base):
- os.makedirs(base)
- with open(tgt, 'wb') as f:
- booklists[listid].write(f)
- write_card_prefix(self._card_a_prefix, 1)
- write_card_prefix(self._card_b_prefix, 2)
+ def books(self, oncard=None, end_session=True):
+ bl = USBMS.books(self, oncard=oncard, end_session=end_session)
+ c = self.initialize_XML_cache()
+ c.update_booklist(bl, {'carda':1, 'cardb':2}.get(oncard, 0))
+ return bl
+
+ def sync_booklists(self, booklists, end_session=True):
+ c = self.initialize_XML_cache()
+ blists = {}
+ for i in c.paths:
+ blists[i] = booklists[i]
+ c.update(blists)
+ c.write()
USBMS.sync_booklists(self, booklists, end_session)
diff --git a/src/calibre/devices/prs505/sony_cache.py b/src/calibre/devices/prs505/sony_cache.py
new file mode 100644
index 0000000000..f365bba3ab
--- /dev/null
+++ b/src/calibre/devices/prs505/sony_cache.py
@@ -0,0 +1,249 @@
+#!/usr/bin/env python
+# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai
+
+__license__ = 'GPL v3'
+__copyright__ = '2010, Kovid Goyal '
+__docformat__ = 'restructuredtext en'
+
+import os
+from pprint import pprint
+from base64 import b64decode
+
+from lxml import etree
+
+from calibre import prints
+from calibre.devices.errors import DeviceError
+from calibre.constants import DEBUG
+from calibre.ebooks.chardet import xml_to_unicode
+from calibre.ebooks.metadata import string_to_authors
+
+EMPTY_CARD_CACHE = '''\
+
+
+
+'''
+
+class XMLCache(object):
+
+ def __init__(self, paths):
+ if DEBUG:
+ pprint(paths)
+ self.paths = paths
+ parser = etree.XMLParser(recover=True)
+ self.roots = {}
+ for source_id, path in paths.items():
+ if source_id == 0:
+ if not os.path.exists(path):
+ raise DeviceError('The SONY XML cache media.xml does not exist. Try'
+ ' disconnecting and reconnecting your reader.')
+ with open(path, 'rb') as f:
+ raw = f.read()
+ else:
+ raw = EMPTY_CARD_CACHE
+ if os.access(path, os.R_OK):
+ with open(path, 'rb') as f:
+ raw = f.read()
+ self.roots[source_id] = etree.fromstring(xml_to_unicode(
+ raw, strip_encoding_pats=True, assume_utf8=True,
+ verbose=DEBUG)[0],
+ parser=parser)
+
+ recs = self.roots[0].xpath('//*[local-name()="records"]')
+ if not recs:
+ raise DeviceError('The SONY XML database is corrupted (no )')
+ self.record_roots = {}
+ self.record_roots.update(self.roots)
+ self.record_roots[0] = recs[0]
+
+ self.detect_namespaces()
+
+
+ # Playlist management {{{
+ def purge_broken_playlist_items(self, root):
+ for item in root.xpath(
+ '//*[local-name()="playlist"]/*[local-name()="item"]'):
+ id_ = item.get('id', None)
+ if id_ is None or not root.xpath(
+ '//*[local-name()!="item" and @id="%s"]'%id_):
+ if DEBUG:
+ prints('Purging broken playlist item:',
+ etree.tostring(item, with_tail=False))
+ item.getparent().remove(item)
+
+
+ def prune_empty_playlists(self):
+ for i, root in self.record_roots.items():
+ self.purge_broken_playlist_items(root)
+ for playlist in root.xpath('//*[local-name()="playlist"]'):
+ if len(playlist) == 0:
+ if DEBUG:
+ prints('Removing playlist:', playlist.get('id', None))
+ playlist.getparent().remove(playlist)
+
+ # }}}
+
+ def fix_ids(self): # {{{
+
+ def ensure_numeric_ids(root):
+ idmap = {}
+ for x in root.xpath('//*[@id]'):
+ id_ = x.get('id')
+ try:
+ id_ = int(id_)
+ except:
+ x.set('id', '-1')
+ idmap[id_] = '-1'
+
+ if DEBUG and idmap:
+ prints('Found non numeric ids:')
+ prints(list(idmap.keys()))
+ return idmap
+
+ def remap_playlist_references(root, idmap):
+ for playlist in root.xpath('//*[local-name()="playlist"]'):
+ for item in playlist.xpath(
+ 'descendant::*[@id and local-name()="item"]'):
+ id_ = item.get('id')
+ if id_ in idmap:
+ item.set('id', idmap[id_])
+ if DEBUG:
+ prints('Remapping id %s to %s'%(id_, idmap[id_]))
+
+ def ensure_media_xml_base_ids(root):
+ for num, tag in enumerate(('library', 'watchSpecial')):
+ for x in root.xpath('//*[local-name()="%s"]'%tag):
+ x.set('id', str(num))
+
+ def rebase_ids(root, base, sourceid, pl_sourceid):
+ 'Rebase all ids and also make them consecutive'
+ for item in root.xpath('//*[@sourceid]'):
+ sid = pl_sourceid if item.tag.endswith('playlist') else sourceid
+ item.set('sourceid', str(sid))
+ items = root.xpath('//*[@id]')
+ items.sort(cmp=lambda x,y:cmp(int(x.get('id')), int(y.get('id'))))
+ idmap = {}
+ for i, item in enumerate(items):
+ old = int(item.get('id'))
+ new = base + i
+ if old != new:
+ item.set('id', str(new))
+ idmap[old] = str(new)
+ return idmap
+
+ self.prune_empty_playlists()
+
+ for i in sorted(self.roots.keys()):
+ root = self.roots[i]
+ if i == 0:
+ ensure_media_xml_base_ids(root)
+
+ idmap = ensure_numeric_ids(root)
+ remap_playlist_references(root, idmap)
+ if i == 0:
+ sourceid, playlist_sid = 1, 0
+ base = 0
+ else:
+ previous = i-1
+ if previous not in self.roots:
+ previous = 0
+ max_id = self.max_id(self.roots[previous])
+ sourceid = playlist_sid = max_id + 1
+ base = max_id + 2
+ idmap = rebase_ids(root, base, sourceid, playlist_sid)
+ remap_playlist_references(root, idmap)
+
+ last_bl = max(self.roots.keys())
+ max_id = self.max_id(self.roots[last_bl])
+ self.roots[0].set('nextID', str(max_id+1))
+ # }}}
+
+ def update_booklist(self, bl, bl_index): # {{{
+ if bl_index not in self.record_roots:
+ return
+ root = self.record_roots[bl_index]
+ for book in bl:
+ record = self.book_by_lpath(book.lpath, root)
+ if record is not None:
+ title = record.get('title', None)
+ if title is not None and title != book.title:
+ if DEBUG:
+ prints('Renaming title', book.title, 'to', title)
+ book.title = title
+ authors = record.get('author', None)
+ if authors is not None:
+ authors = string_to_authors(authors)
+ if authors != book.authors:
+ if DEBUG:
+ prints('Renaming authors', book.authors, 'to',
+ authors)
+ book.authors = authors
+ for thumbnail in record.xpath(
+ 'descendant::*[local-name()="thumbnail"]'):
+ for img in thumbnail.xpath(
+ 'descendant::*[local-name()="jpeg"]|'
+ 'descendant::*[local-name()="png"]'):
+ if img.text:
+ raw = b64decode(img.text.strip())
+ ext = img.tag.split('}')[-1]
+ book.cover_data = [ext, raw]
+ break
+ break
+ # }}}
+
+ def update(self, booklists):
+ pass
+
+ def write(self):
+ return
+ for i, path in self.paths.items():
+ raw = etree.tostring(self.roots[i], encoding='utf-8',
+ xml_declaration=True)
+ with open(path, 'wb') as f:
+ f.write(raw)
+
+ def book_by_lpath(self, lpath, root):
+ matches = root.xpath(u'//*[local-name()="text" and @path="%s"]'%lpath)
+ if matches:
+ return matches[0]
+
+
+ def max_id(self, root):
+ ans = -1
+ for x in root.xpath('//*[@id]'):
+ id_ = x.get('id')
+ try:
+ num = int(id_)
+ if num > ans:
+ ans = num
+ except:
+ continue
+ return ans
+
+ def detect_namespaces(self):
+ self.nsmaps = {}
+ for i, root in self.roots.items():
+ self.nsmaps[i] = root.nsmap
+
+ self.namespaces = {}
+ for i in self.roots:
+ for c in ('library', 'text', 'image', 'playlist', 'thumbnail',
+ 'watchSpecial'):
+ matches = self.record_roots[i].xpath('//*[local-name()="%s"]'%c)
+ if matches:
+ e = matches[0]
+ self.namespaces[i] = e.nsmap[e.prefix]
+ break
+ if i not in self.namespaces:
+ ns = self.nsmaps[i].get(None, None)
+ for prefix in self.nsmaps[i]:
+ if prefix is not None:
+ ns = self.nsmaps[i][prefix]
+ break
+ self.namespaces[i] = ns
+
+ if DEBUG:
+ prints('Found nsmaps:')
+ pprint(self.nsmaps)
+ prints('Found namespaces:')
+ pprint(self.namespaces)
+
From 12374868318c6b39971ce4c6c54310f4ffc6e03a Mon Sep 17 00:00:00 2001
From: Charles Haley <>
Date: Wed, 19 May 2010 09:41:09 +0100
Subject: [PATCH 141/324] Fix prefix to be normalized. Apparently the python
file dialog returns front-slashed filenames, even on windows.
---
src/calibre/devices/folder_device/driver.py | 1 +
src/calibre/devices/usbms/driver.py | 1 -
2 files changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/calibre/devices/folder_device/driver.py b/src/calibre/devices/folder_device/driver.py
index 792de9ee0a..bb3c684099 100644
--- a/src/calibre/devices/folder_device/driver.py
+++ b/src/calibre/devices/folder_device/driver.py
@@ -54,6 +54,7 @@ class FOLDER_DEVICE(USBMS):
def __init__(self, path):
if not os.path.isdir(path):
raise IOError, 'Path is not a folder'
+ path = USBMS.normalize_path(path)
if path.endswith(os.sep):
self._main_prefix = path
else:
diff --git a/src/calibre/devices/usbms/driver.py b/src/calibre/devices/usbms/driver.py
index 5273ffe579..f519e8ce22 100644
--- a/src/calibre/devices/usbms/driver.py
+++ b/src/calibre/devices/usbms/driver.py
@@ -90,7 +90,6 @@ class USBMS(CLI, Device):
#print 'update_metadata_item returned true'
changed = True
else:
- #print "adding new book", lpath
if bl.add_book(self.book_from_path(prefix, lpath),
replace_metadata=False):
changed = True
From 9fcab76112491ee3a5b552390ec9254cc60648b7 Mon Sep 17 00:00:00 2001
From: Charles Haley <>
Date: Wed, 19 May 2010 11:31:08 +0100
Subject: [PATCH 142/324] Ensure that normalized paths and paths that are found
during a directory walk are unicode. In addition, fix matching of prefixes
when adding metadata to books on cards on windows machines.
---
src/calibre/devices/usbms/driver.py | 27 +++++++++++++++------------
1 file changed, 15 insertions(+), 12 deletions(-)
diff --git a/src/calibre/devices/usbms/driver.py b/src/calibre/devices/usbms/driver.py
index f519e8ce22..c75fc9f6a1 100644
--- a/src/calibre/devices/usbms/driver.py
+++ b/src/calibre/devices/usbms/driver.py
@@ -112,7 +112,8 @@ class USBMS(CLI, Device):
for path, dirs, files in os.walk(ebook_dir):
for filename in files:
if filename != self.METADATA_CACHE:
- flist.append({'filename':filename, 'path': path})
+ flist.append({'filename':unicode(filename),
+ 'path':unicode(path)})
for i, f in enumerate(flist):
self.report_progress(i/float(len(flist)), _('Getting list of books on device...'))
changed = update_booklist(f['filename'], f['path'], prefix)
@@ -122,7 +123,7 @@ class USBMS(CLI, Device):
paths = os.listdir(ebook_dir)
for i, filename in enumerate(paths):
self.report_progress((i+1) / float(len(paths)), _('Getting list of books on device...'))
- changed = update_booklist(filename, ebook_dir, prefix)
+ changed = update_booklist(unicode(filename), ebook_dir, prefix)
if changed:
need_sync = True
@@ -188,20 +189,22 @@ class USBMS(CLI, Device):
for i, location in enumerate(locations):
self.report_progress((i+1) / float(len(locations)), _('Adding books to device metadata listing...'))
info = metadata.next()
- path = location[0]
blist = 2 if location[1] == 'cardb' else 1 if location[1] == 'carda' else 0
+ # Extract the correct prefix from the pathname. To do this correctly,
+ # we must ensure that both the prefix and the path are normalized
+ # so that the comparison will work. Book's __init__ will fix up
+ # lpath, so we don't need to worry about that here.
+ path = self.normalize_path(location[0])
if self._main_prefix:
- # Normalize path and prefix
- if self._main_prefix.find('\\') >= 0:
- path = path.replace('/', '\\')
- else:
- path = path.replace('\\', '/')
- prefix = self._main_prefix if path.startswith(self._main_prefix) else None
+ prefix = self._main_prefix if \
+ path.startswith(self.normalize_path(self._main_prefix)) else None
if not prefix and self._card_a_prefix:
- prefix = self._card_a_prefix if path.startswith(self._card_a_prefix) else None
+ prefix = self._card_a_prefix if \
+ path.startswith(self.normalize_path(self._card_a_prefix)) else None
if not prefix and self._card_b_prefix:
- prefix = self._card_b_prefix if path.startswith(self._card_b_prefix) else None
+ prefix = self._card_b_prefix if \
+ path.startswith(self.normalize_path(self._card_b_prefix)) else None
if prefix is None:
prints('in add_books_to_metadata. Prefix is None!', path,
self._main_prefix)
@@ -273,7 +276,7 @@ class USBMS(CLI, Device):
path = path.replace('/', '\\')
else:
path = path.replace('\\', '/')
- return path
+ return unicode(path)
@classmethod
def parse_metadata_cache(cls, bl, prefix, name):
From 596ba46590fe546d304ce3730ddc9b4de4f45f37 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Wed, 19 May 2010 07:36:42 -0600
Subject: [PATCH 143/324] ...
---
src/calibre/devices/prs505/sony_cache.py | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/src/calibre/devices/prs505/sony_cache.py b/src/calibre/devices/prs505/sony_cache.py
index f365bba3ab..b81867dc7f 100644
--- a/src/calibre/devices/prs505/sony_cache.py
+++ b/src/calibre/devices/prs505/sony_cache.py
@@ -184,8 +184,7 @@ class XMLCache(object):
'descendant::*[local-name()="png"]'):
if img.text:
raw = b64decode(img.text.strip())
- ext = img.tag.split('}')[-1]
- book.cover_data = [ext, raw]
+ book.thumbnail = raw
break
break
# }}}
From 48f8a9c338dcaf133f832b22cad3d92f0c06da56 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Wed, 19 May 2010 08:53:07 -0600
Subject: [PATCH 144/324] Guarantee that metadata read from filenames is
unicode
---
src/calibre/ebooks/metadata/meta.py | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/src/calibre/ebooks/metadata/meta.py b/src/calibre/ebooks/metadata/meta.py
index 4f808e3fb0..f5a327a0d6 100644
--- a/src/calibre/ebooks/metadata/meta.py
+++ b/src/calibre/ebooks/metadata/meta.py
@@ -5,9 +5,9 @@ __copyright__ = '2008, Kovid Goyal '
import os, re, collections
from calibre.utils.config import prefs
-
+from calibre.constants import filesystem_encoding
from calibre.ebooks.metadata.opf2 import OPF
-
+from calibre import isbytestring
from calibre.customize.ui import get_file_type_metadata, set_file_type_metadata
from calibre.ebooks.metadata import MetaInformation, string_to_authors
@@ -131,6 +131,8 @@ def set_metadata(stream, mi, stream_type='lrf'):
def metadata_from_filename(name, pat=None):
+ if isbytestring(name):
+ name = name.decode(filesystem_encoding, 'replace')
name = name.rpartition('.')[0]
mi = MetaInformation(None, None)
if pat is None:
From b172b841196d155db92ba88b188d32ad745ab452 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Wed, 19 May 2010 08:57:59 -0600
Subject: [PATCH 145/324] Ensure encoding to JSON in BookList never blows up
because of non UTF-8 bytestrings
---
src/calibre/devices/usbms/books.py | 9 +++++++--
1 file changed, 7 insertions(+), 2 deletions(-)
diff --git a/src/calibre/devices/usbms/books.py b/src/calibre/devices/usbms/books.py
index e7462bdb73..5ae2c20df7 100644
--- a/src/calibre/devices/usbms/books.py
+++ b/src/calibre/devices/usbms/books.py
@@ -11,7 +11,8 @@ import time
from calibre.ebooks.metadata import MetaInformation
from calibre.devices.mime import mime_type_ext
from calibre.devices.interface import BookList as _BookList
-from calibre.constants import filesystem_encoding
+from calibre.constants import filesystem_encoding, preferred_encoding
+from calibre import isbytestring
class Book(MetaInformation):
@@ -105,7 +106,11 @@ class Book(MetaInformation):
def to_json(self):
json = {}
for attr in self.JSON_ATTRS:
- json[attr] = getattr(self, attr)
+ val = getattr(self, attr)
+ if isbytestring(val):
+ enc = filesystem_encoding if attr == 'lpath' else preferred_encoding
+ val = val.decode(enc, 'replace')
+ json[attr] = val
return json
class BookList(_BookList):
From 2c5fb72281b81814274d436de4b1e6496f27c8b7 Mon Sep 17 00:00:00 2001
From: Charles Haley <>
Date: Wed, 19 May 2010 17:24:44 +0100
Subject: [PATCH 146/324] Add filesystem_encoding to unicode calls
---
src/calibre/devices/usbms/driver.py | 21 ++++++++++++++-------
1 file changed, 14 insertions(+), 7 deletions(-)
diff --git a/src/calibre/devices/usbms/driver.py b/src/calibre/devices/usbms/driver.py
index c75fc9f6a1..4a020a24d4 100644
--- a/src/calibre/devices/usbms/driver.py
+++ b/src/calibre/devices/usbms/driver.py
@@ -16,6 +16,7 @@ import json
from itertools import cycle
from calibre import prints
+from calibre.constants import filesystem_encoding
from calibre.devices.usbms.cli import CLI
from calibre.devices.usbms.device import Device
from calibre.devices.usbms.books import BookList, Book
@@ -87,7 +88,6 @@ class USBMS(CLI, Device):
if idx is not None:
bl_cache[lpath] = None
if self.update_metadata_item(bl[idx]):
- #print 'update_metadata_item returned true'
changed = True
else:
if bl.add_book(self.book_from_path(prefix, lpath),
@@ -112,8 +112,8 @@ class USBMS(CLI, Device):
for path, dirs, files in os.walk(ebook_dir):
for filename in files:
if filename != self.METADATA_CACHE:
- flist.append({'filename':unicode(filename),
- 'path':unicode(path)})
+ flist.append({'filename':self.path_to_unicode(filename),
+ 'path':self.path_to_unicode(path)})
for i, f in enumerate(flist):
self.report_progress(i/float(len(flist)), _('Getting list of books on device...'))
changed = update_booklist(f['filename'], f['path'], prefix)
@@ -123,7 +123,8 @@ class USBMS(CLI, Device):
paths = os.listdir(ebook_dir)
for i, filename in enumerate(paths):
self.report_progress((i+1) / float(len(paths)), _('Getting list of books on device...'))
- changed = update_booklist(unicode(filename), ebook_dir, prefix)
+ changed = update_booklist(self.path_to_unicode(filename),
+ ebook_dir, prefix)
if changed:
need_sync = True
@@ -267,16 +268,22 @@ class USBMS(CLI, Device):
self.report_progress(1.0, _('Sending metadata to device...'))
+ @classmethod
+ def path_to_unicode(cls, path):
+ if isinstance(path, str): ## bytes is synonym for str as of python 2.6
+ print 'p2u: isString', path
+ return unicode(path, filesystem_encoding)
+ return path
+
@classmethod
def normalize_path(cls, path):
'Return path with platform native path separators'
if path is None:
return None
if os.sep == '\\':
- path = path.replace('/', '\\')
+ return cls.path_to_unicode(path.replace('/', '\\'))
else:
- path = path.replace('\\', '/')
- return unicode(path)
+ return cls.path_to_unicode(path.replace('\\', '/'))
@classmethod
def parse_metadata_cache(cls, bl, prefix, name):
From 057743f17707a207f76f90e58371304e018410ee Mon Sep 17 00:00:00 2001
From: Charles Haley <>
Date: Wed, 19 May 2010 18:26:24 +0100
Subject: [PATCH 147/324] Add path_to_unicode. Ensure os path walk results are
converted.
---
src/calibre/devices/usbms/driver.py | 19 +++++++++++--------
1 file changed, 11 insertions(+), 8 deletions(-)
diff --git a/src/calibre/devices/usbms/driver.py b/src/calibre/devices/usbms/driver.py
index cd6bddfc28..7b3531abf6 100644
--- a/src/calibre/devices/usbms/driver.py
+++ b/src/calibre/devices/usbms/driver.py
@@ -102,8 +102,7 @@ class USBMS(CLI, Device):
if isinstance(ebook_dirs, basestring):
ebook_dirs = [ebook_dirs]
for ebook_dir in ebook_dirs:
- if isbytestring(ebook_dir):
- ebook_dir = ebook_dir.decode(filesystem_encoding)
+ ebook_dir = self.path_to_unicode(filesystem_encoding)
ebook_dir = self.normalize_path( \
os.path.join(prefix, *(ebook_dir.split('/'))) \
if ebook_dir else prefix)
@@ -115,8 +114,8 @@ class USBMS(CLI, Device):
for path, dirs, files in os.walk(ebook_dir):
for filename in files:
if filename != self.METADATA_CACHE:
- flist.append({'filename':filename,
- 'path':path})
+ flist.append({'filename': self.path_to_unicode(filename),
+ 'path':self.path_to_unicode(path)})
for i, f in enumerate(flist):
self.report_progress(i/float(len(flist)), _('Getting list of books on device...'))
changed = update_booklist(f['filename'], f['path'], prefix)
@@ -126,7 +125,7 @@ class USBMS(CLI, Device):
paths = os.listdir(ebook_dir)
for i, filename in enumerate(paths):
self.report_progress((i+1) / float(len(paths)), _('Getting list of books on device...'))
- changed = update_booklist(filename, ebook_dir, prefix)
+ changed = update_booklist(self.path_to_unicode(filename), ebook_dir, prefix)
if changed:
need_sync = True
@@ -270,6 +269,12 @@ class USBMS(CLI, Device):
self.report_progress(1.0, _('Sending metadata to device...'))
+ @classmethod
+ def path_to_unicode(cls, path):
+ if isbytestring(path):
+ path = path.decode(filesystem_encoding)
+ return path
+
@classmethod
def normalize_path(cls, path):
'Return path with platform native path separators'
@@ -279,9 +284,7 @@ class USBMS(CLI, Device):
path = path.replace('/', '\\')
else:
path = path.replace('\\', '/')
- if isbytestring(path):
- path = path.decode(filesystem_encoding)
- return path
+ return cls.path_to_unicode(path)
@classmethod
def parse_metadata_cache(cls, bl, prefix, name):
From ec7167ef856b5126a607adf951ce0cbfa10e9016 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Wed, 19 May 2010 12:36:52 -0600
Subject: [PATCH 148/324] Remove PRS500 driver and initial implementation of
SONY XML cache update
---
src/calibre/customize/builtins.py | 2 -
src/calibre/devices/interface.py | 13 ++
src/calibre/devices/prs505/driver.py | 14 +-
src/calibre/devices/prs505/sony_cache.py | 202 +++++++++++++++++++++--
src/calibre/devices/usbms/books.py | 38 ++++-
src/calibre/gui2/wizard/__init__.py | 12 +-
6 files changed, 253 insertions(+), 28 deletions(-)
diff --git a/src/calibre/customize/builtins.py b/src/calibre/customize/builtins.py
index 17239256df..9a32774f5f 100644
--- a/src/calibre/customize/builtins.py
+++ b/src/calibre/customize/builtins.py
@@ -442,7 +442,6 @@ from calibre.devices.irexdr.driver import IREXDR1000, IREXDR800
from calibre.devices.jetbook.driver import JETBOOK
from calibre.devices.kindle.driver import KINDLE, KINDLE2, KINDLE_DX
from calibre.devices.nook.driver import NOOK
-from calibre.devices.prs500.driver import PRS500
from calibre.devices.prs505.driver import PRS505, PRS700
from calibre.devices.android.driver import ANDROID, S60
from calibre.devices.nokia.driver import N770, N810
@@ -512,7 +511,6 @@ plugins += [
NOOK,
PRS505,
PRS700,
- PRS500,
ANDROID,
S60,
N770,
diff --git a/src/calibre/devices/interface.py b/src/calibre/devices/interface.py
index 40cac4d615..be58bc9b0c 100644
--- a/src/calibre/devices/interface.py
+++ b/src/calibre/devices/interface.py
@@ -418,3 +418,16 @@ class BookList(list):
'''
raise NotImplementedError()
+ def get_collections(self, collection_attributes):
+ '''
+ Return a dictionary of collections created from collection_attributes.
+ Each entry in the dictionary is of the form collection name:[list of
+ books]
+
+ The list of books is sorted by book title, except for collections
+ created from series, in which case series_index is used.
+
+ :param collection_attributes: A list of attributes of the Book object
+ '''
+ raise NotImplementedError()
+
diff --git a/src/calibre/devices/prs505/driver.py b/src/calibre/devices/prs505/driver.py
index 0bf2a1de82..846ca9593d 100644
--- a/src/calibre/devices/prs505/driver.py
+++ b/src/calibre/devices/prs505/driver.py
@@ -71,7 +71,7 @@ class PRS505(USBMS):
return fname
def initialize_XML_cache(self):
- paths = {}
+ paths, prefixes = {}, {}
for prefix, path, source_id in [
('main', MEDIA_XML, 0),
('card_a', CACHE_XML, 1),
@@ -80,10 +80,11 @@ class PRS505(USBMS):
prefix = getattr(self, '_%s_prefix'%prefix)
if prefix is not None and os.path.exists(prefix):
paths[source_id] = os.path.join(prefix, *(path.split('/')))
+ prefixes[source_id] = prefix
d = os.path.dirname(paths[source_id])
if not os.path.exists(d):
os.makedirs(d)
- return XMLCache(paths)
+ return XMLCache(paths, prefixes)
def books(self, oncard=None, end_session=True):
bl = USBMS.books(self, oncard=oncard, end_session=end_session)
@@ -96,10 +97,15 @@ class PRS505(USBMS):
blists = {}
for i in c.paths:
blists[i] = booklists[i]
- c.update(blists)
+ opts = self.settings()
+ collections = ['series', 'tags']
+ if opts.extra_customization:
+ collections = opts.extra_customization.split(',')
+
+ c.update(blists, collections)
c.write()
- USBMS.sync_booklists(self, booklists, end_session)
+ USBMS.sync_booklists(self, booklists, end_session=end_session)
class PRS700(PRS505):
diff --git a/src/calibre/devices/prs505/sony_cache.py b/src/calibre/devices/prs505/sony_cache.py
index b81867dc7f..5b11b89a0a 100644
--- a/src/calibre/devices/prs505/sony_cache.py
+++ b/src/calibre/devices/prs505/sony_cache.py
@@ -5,17 +5,18 @@ __license__ = 'GPL v3'
__copyright__ = '2010, Kovid Goyal '
__docformat__ = 'restructuredtext en'
-import os
+import os, time
from pprint import pprint
from base64 import b64decode
+from uuid import uuid4
from lxml import etree
-from calibre import prints
+from calibre import prints, guess_type
from calibre.devices.errors import DeviceError
from calibre.constants import DEBUG
from calibre.ebooks.chardet import xml_to_unicode
-from calibre.ebooks.metadata import string_to_authors
+from calibre.ebooks.metadata import string_to_authors, authors_to_string
EMPTY_CARD_CACHE = '''\
@@ -23,12 +24,43 @@ EMPTY_CARD_CACHE = '''\
'''
+MIME_MAP = {
+ "lrf" : "application/x-sony-bbeb",
+ 'lrx' : 'application/x-sony-bbeb',
+ "rtf" : "application/rtf",
+ "pdf" : "application/pdf",
+ "txt" : "text/plain" ,
+ 'epub': 'application/epub+zip',
+ }
+
+DAY_MAP = dict(Sun=0, Mon=1, Tue=2, Wed=3, Thu=4, Fri=5, Sat=6)
+MONTH_MAP = dict(Jan=1, Feb=2, Mar=3, Apr=4, May=5, Jun=6, Jul=7, Aug=8, Sep=9, Oct=10, Nov=11, Dec=12)
+INVERSE_DAY_MAP = dict(zip(DAY_MAP.values(), DAY_MAP.keys()))
+INVERSE_MONTH_MAP = dict(zip(MONTH_MAP.values(), MONTH_MAP.keys()))
+
+def strptime(src):
+ src = src.strip()
+ src = src.split()
+ src[0] = str(DAY_MAP[src[0][:-1]])+','
+ src[2] = str(MONTH_MAP[src[2]])
+ return time.strptime(' '.join(src), '%w, %d %m %Y %H:%M:%S %Z')
+
+def strftime(epoch, zone=time.gmtime):
+ src = time.strftime("%w, %d %m %Y %H:%M:%S GMT", zone(epoch)).split()
+ src[0] = INVERSE_DAY_MAP[int(src[0][:-1])]+','
+ src[2] = INVERSE_MONTH_MAP[int(src[2])]
+ return ' '.join(src)
+
+def uuid():
+ return str(uuid4()).replace('-', '', 1).upper()
+
class XMLCache(object):
- def __init__(self, paths):
+ def __init__(self, paths, prefixes):
if DEBUG:
pprint(paths)
self.paths = paths
+ self.prefixes = prefixes
parser = etree.XMLParser(recover=True)
self.roots = {}
for source_id, path in paths.items():
@@ -50,7 +82,9 @@ class XMLCache(object):
recs = self.roots[0].xpath('//*[local-name()="records"]')
if not recs:
- raise DeviceError('The SONY XML database is corrupted (no )')
+ raise DeviceError('The SONY XML database is corrupted (no'
+ ' ). Try disconnecting an reconnecting'
+ ' your reader.')
self.record_roots = {}
self.record_roots.update(self.roots)
self.record_roots[0] = recs[0]
@@ -75,11 +109,63 @@ class XMLCache(object):
for i, root in self.record_roots.items():
self.purge_broken_playlist_items(root)
for playlist in root.xpath('//*[local-name()="playlist"]'):
- if len(playlist) == 0:
+ if len(playlist) == 0 or not playlist.get('title', None):
if DEBUG:
- prints('Removing playlist:', playlist.get('id', None))
+ prints('Removing playlist:', playlist.get('id', None),
+ playlist.get('title', None))
playlist.getparent().remove(playlist)
+ def ensure_unique_playlist_titles(self):
+ for i, root in self.record_roots.items():
+ seen = set([])
+ for playlist in root.xpath('//*[local-name()="playlist"]'):
+ title = playlist.get('title', None)
+ if title is None:
+ title = _('Unnamed')
+ playlist.set('title', title)
+ if title in seen:
+ for i in range(2, 1000):
+ if title+str(i) not in seen:
+ title = title+str(i)
+ playlist.set('title', title)
+ break
+ else:
+ seen.add(title)
+
+ def get_playlist_map(self):
+ ans = {}
+ self.ensure_unique_playlist_titles()
+ self.prune_empty_playlists()
+ for i, root in self.record_roots.items():
+ for playlist in root.xpath('//*[local-name()="playlist"]'):
+ items = []
+ for item in playlist:
+ id_ = item.get('id', None)
+ records = root.xpath(
+ '//*[local-name()="text" and @id="%s"]'%id_)
+ if records:
+ items.append(records[0])
+ ans[i] = {playlist.get('title'):items}
+ return ans
+
+ def get_or_create_playlist(self, bl_idx, title):
+ root = self.record_roots[bl_idx]
+ for playlist in root.xpath('//*[local-name()="playlist"]'):
+ if playlist.get('title', None) == title:
+ return playlist
+ ans = root.makelement('{%s}playlist'%self.namespaces[bl_idx],
+ nsmap=root.nsmap, attrib={
+ 'uuid' : uuid(),
+ 'title': title,
+ 'id' : str(self.max_id(root)+1),
+ 'sourceid': '1'
+ })
+ tail = '\n\t\t' if bl_idx == 0 else '\n\t'
+ ans.tail = tail
+ if len(root) > 0:
+ root.iterchildren(reversed=True).next().tail = tail
+ root.append(ans)
+ return ans
# }}}
def fix_ids(self): # {{{
@@ -189,11 +275,107 @@ class XMLCache(object):
break
# }}}
- def update(self, booklists):
- pass
+ # Update XML Cache {{{
+ def update(self, booklists, collections_attributes):
+ playlist_map = self.get_playlist_map()
+
+ for i, booklist in booklists.items():
+ root = self.record_roots[i]
+ for book in booklist:
+ path = os.path.join(self.prefixes[i], *(book.lpath.split('/')))
+ record = self.book_by_lpath(book.lpath, root)
+ if record is None:
+ record = self.create_text_record(root, i, book.lpath)
+ self.update_record(record, book, path, i)
+ bl_pmap = playlist_map[i]
+ self.update_playlists(i, root, booklist, bl_pmap,
+ collections_attributes)
+
+ tail = '\n\t' if i == 0 else '\n'
+ if len(root) > 0:
+ root.iterchildren(reversed=True).next().tail = tail
+
+ self.fix_ids()
+
+ def update_playlists(self, bl_index, root, booklist, playlist_map,
+ collections_attributes):
+ collections = booklist.get_collections(collections_attributes)
+ for category, books in collections:
+ records = [self.book_by_lpath(b.lpath) for b in books]
+ # Remove any books that were not found, although this
+ # *should* never happen
+ if DEBUG and None in records:
+ prints('WARNING: Some elements in the JSON cache were not'
+ 'found in the XML cache')
+ records = [x for x in records if x is not None]
+ for rec in records:
+ if rec.get('id', None) is None:
+ rec.set('id', str(self.max_id(root)+1))
+ ids = [x.get('id', None) for x in records]
+ if None in ids:
+ if DEBUG:
+ prints('WARNING: Some elements do not have ids')
+ ids = [x for x in ids if x is not None]
+
+ playlist = self.get_or_create_playlist(bl_index, category)
+ playlist_ids = []
+ for item in playlist:
+ id_ = item.get('id', None)
+ if id_ is not None:
+ playlist_ids.append(id_)
+ for item in list(playlist):
+ playlist.remove(item)
+
+ extra_ids = [x for x in playlist_ids if x not in ids]
+ tail = '\n\t\t\t' if bl_index == 0 else '\n\t\t'
+ playlist.tail = tail
+ for id_ in ids + extra_ids:
+ item = playlist.makeelement(
+ '{%s}item'%self.namespaces[bl_index],
+ nsmap=playlist.nsmap, attrib={'id':id_})
+ item.tail = tail
+ if len(playlist) > 0:
+ root.iterchildren(reversed=True).next().tail = tail[:-1]
+
+
+ def create_text_record(self, root, bl_id, lpath):
+ namespace = self.namespaces[bl_id]
+ id_ = self.max_id(root)+1
+ attrib = {
+ 'page':'0', 'part':'0','pageOffset':'0','scale':'0',
+ 'id':str(id_), 'sourceid':'1', 'path':lpath}
+ ans = root.makeelement('{%s}text'%namespace, attrib=attrib, nsmap=root.nsmap)
+ tail = '\n\t\t' if bl_id == 0 else '\n\t'
+ ans.tail = tail
+ if len(root) > 0:
+ root.iterchildren(reversed=True).next().tail = tail
+ root.append(ans)
+ return ans
+
+ def update_text_record(self, record, book, path, bl_index):
+ timestamp = 'ctime' if bl_index == 0 else 'mtime'
+ timestamp = getattr(os.path, 'get'+timestamp)(path)
+ date = strftime(timestamp)
+ record.set('date', date)
+ record.set('size', os.stat(path).st_size)
+ record.set('title', book.title)
+ record.set('author', authors_to_string(book.authors))
+ ext = os.path.splitext(path)[1]
+ if ext:
+ ext = ext[1:].lower()
+ mime = MIME_MAP.get(ext, None)
+ if mime is None:
+ mime = guess_type('a.'+ext)[0]
+ if mime is not None:
+ record.set('mime', mime)
+ if 'sourceid' not in record.attrib:
+ record.set('sourceid', '1')
+ if 'id' not in record.attrib:
+ num = self.max_id(record.getroottree().getroot())
+ record.set('id', str(num+1))
+ # }}}
def write(self):
- return
for i, path in self.paths.items():
raw = etree.tostring(self.roots[i], encoding='utf-8',
xml_declaration=True)
diff --git a/src/calibre/devices/usbms/books.py b/src/calibre/devices/usbms/books.py
index 5ae2c20df7..97c911283b 100644
--- a/src/calibre/devices/usbms/books.py
+++ b/src/calibre/devices/usbms/books.py
@@ -4,9 +4,7 @@ __license__ = 'GPL 3'
__copyright__ = '2009, John Schember '
__docformat__ = 'restructuredtext en'
-import os
-import re
-import time
+import os, re, time, sys
from calibre.ebooks.metadata import MetaInformation
from calibre.devices.mime import mime_type_ext
@@ -110,6 +108,9 @@ class Book(MetaInformation):
if isbytestring(val):
enc = filesystem_encoding if attr == 'lpath' else preferred_encoding
val = val.decode(enc, 'replace')
+ elif isinstance(val, (list, tuple)):
+ val = [x.decode(preferred_encoding, 'replace') if
+ isbytestring(x) else x for x in val]
json[attr] = val
return json
@@ -129,3 +130,34 @@ class BookList(_BookList):
def remove_book(self, book):
self.remove(book)
+
+ def get_collections(self, collection_attributes):
+ collections = {}
+ series_categories = set([])
+ for attr in collection_attributes:
+ for book in self:
+ val = getattr(book, attr, None)
+ if not val: continue
+ if isbytestring(val):
+ val = val.decode(preferred_encoding, 'replace')
+ if isinstance(val, (list, tuple)):
+ val = list(val)
+ elif isinstance(val, unicode):
+ val = [val]
+ for category in val:
+ if category not in collections:
+ collections[category] = []
+ collections[category].append(book)
+ if attr == 'series':
+ series_categories.add(category)
+ for category, books in collections.items():
+ def tgetter(x):
+ return getattr(x, 'title_sort', 'zzzz')
+ books.sort(cmp=lambda x,y:cmp(tgetter(x), tgetter(y)))
+ if category in series_categories:
+ # Ensures books are sub sorted by title
+ def getter(x):
+ return getattr(x, 'series_index', sys.maxint)
+ books.sort(cmp=lambda x,y:cmp(getter(x), getter(y)))
+ return collections
+
diff --git a/src/calibre/gui2/wizard/__init__.py b/src/calibre/gui2/wizard/__init__.py
index 0a395e9eb8..0ac6c0a00b 100644
--- a/src/calibre/gui2/wizard/__init__.py
+++ b/src/calibre/gui2/wizard/__init__.py
@@ -78,18 +78,12 @@ class KindleDX(Kindle):
name = 'Kindle DX'
id = 'kindledx'
-class Sony500(Device):
+class Sony505(Device):
output_profile = 'sony'
- name = 'SONY PRS 500'
- output_format = 'LRF'
- manufacturer = 'SONY'
- id = 'prs500'
-
-class Sony505(Sony500):
-
+ name = 'SONY Reader 6" and Touch Editions'
output_format = 'EPUB'
- name = 'SONY Reader 6" and Touch Edition'
+ manufacturer = 'SONY'
id = 'prs505'
class Kobo(Device):
From 6b9696867f1a46b764e8d61520068fc0a557bc3e Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Wed, 19 May 2010 13:35:56 -0600
Subject: [PATCH 149/324] Fix various typos
---
src/calibre/devices/prs505/driver.py | 3 ++-
src/calibre/devices/prs505/sony_cache.py | 11 ++++++-----
src/calibre/devices/usbms/driver.py | 3 +--
3 files changed, 9 insertions(+), 8 deletions(-)
diff --git a/src/calibre/devices/prs505/driver.py b/src/calibre/devices/prs505/driver.py
index 846ca9593d..794bf66600 100644
--- a/src/calibre/devices/prs505/driver.py
+++ b/src/calibre/devices/prs505/driver.py
@@ -96,7 +96,8 @@ class PRS505(USBMS):
c = self.initialize_XML_cache()
blists = {}
for i in c.paths:
- blists[i] = booklists[i]
+ if booklists[i] is not None:
+ blists[i] = booklists[i]
opts = self.settings()
collections = ['series', 'tags']
if opts.extra_customization:
diff --git a/src/calibre/devices/prs505/sony_cache.py b/src/calibre/devices/prs505/sony_cache.py
index 5b11b89a0a..61ae610c26 100644
--- a/src/calibre/devices/prs505/sony_cache.py
+++ b/src/calibre/devices/prs505/sony_cache.py
@@ -137,6 +137,7 @@ class XMLCache(object):
self.ensure_unique_playlist_titles()
self.prune_empty_playlists()
for i, root in self.record_roots.items():
+ ans[i] = {}
for playlist in root.xpath('//*[local-name()="playlist"]'):
items = []
for item in playlist:
@@ -153,7 +154,7 @@ class XMLCache(object):
for playlist in root.xpath('//*[local-name()="playlist"]'):
if playlist.get('title', None) == title:
return playlist
- ans = root.makelement('{%s}playlist'%self.namespaces[bl_idx],
+ ans = root.makeelement('{%s}playlist'%self.namespaces[bl_idx],
nsmap=root.nsmap, attrib={
'uuid' : uuid(),
'title': title,
@@ -286,7 +287,7 @@ class XMLCache(object):
record = self.book_by_lpath(book.lpath, root)
if record is None:
record = self.create_text_record(root, i, book.lpath)
- self.update_record(record, book, path, i)
+ self.update_text_record(record, book, path, i)
bl_pmap = playlist_map[i]
self.update_playlists(i, root, booklist, bl_pmap,
collections_attributes)
@@ -300,8 +301,8 @@ class XMLCache(object):
def update_playlists(self, bl_index, root, booklist, playlist_map,
collections_attributes):
collections = booklist.get_collections(collections_attributes)
- for category, books in collections:
- records = [self.book_by_lpath(b.lpath) for b in books]
+ for category, books in collections.items():
+ records = [self.book_by_lpath(b.lpath, root) for b in books]
# Remove any books that were not found, although this
# *should* never happen
if DEBUG and None in records:
@@ -357,7 +358,7 @@ class XMLCache(object):
timestamp = getattr(os.path, 'get'+timestamp)(path)
date = strftime(timestamp)
record.set('date', date)
- record.set('size', os.stat(path).st_size)
+ record.set('size', str(os.stat(path).st_size))
record.set('title', book.title)
record.set('author', authors_to_string(book.authors))
ext = os.path.splitext(path)[1]
diff --git a/src/calibre/devices/usbms/driver.py b/src/calibre/devices/usbms/driver.py
index 7b3531abf6..76996481a5 100644
--- a/src/calibre/devices/usbms/driver.py
+++ b/src/calibre/devices/usbms/driver.py
@@ -98,11 +98,10 @@ class USBMS(CLI, Device):
import traceback
traceback.print_exc()
return changed
-
if isinstance(ebook_dirs, basestring):
ebook_dirs = [ebook_dirs]
for ebook_dir in ebook_dirs:
- ebook_dir = self.path_to_unicode(filesystem_encoding)
+ ebook_dir = self.path_to_unicode(ebook_dir)
ebook_dir = self.normalize_path( \
os.path.join(prefix, *(ebook_dir.split('/'))) \
if ebook_dir else prefix)
From a1971fdfda37c84b3d1cfb480cab1ad684556a80 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Wed, 19 May 2010 14:16:39 -0600
Subject: [PATCH 150/324] Fix id rebasing and add helpful method for test
scripts
---
src/calibre/devices/__init__.py | 28 ++++++++++++++++++++++++
src/calibre/devices/prs505/sony_cache.py | 14 +++++++-----
2 files changed, 37 insertions(+), 5 deletions(-)
diff --git a/src/calibre/devices/__init__.py b/src/calibre/devices/__init__.py
index bcbd9b1640..fd6eaae79f 100644
--- a/src/calibre/devices/__init__.py
+++ b/src/calibre/devices/__init__.py
@@ -27,6 +27,34 @@ def strftime(epoch, zone=time.gmtime):
src[2] = INVERSE_MONTH_MAP[int(src[2])]
return ' '.join(src)
+def get_connected_device():
+ from calibre.customize.ui import device_plugins
+ from calibre.devices.scanner import DeviceScanner
+ dev = None
+ scanner = DeviceScanner()
+ scanner.scan()
+ connected_devices = []
+ for d in device_plugins():
+ ok, det = scanner.is_device_connected(d)
+ if ok:
+ dev = d
+ dev.reset(log_packets=False, detected_device=det)
+ connected_devices.append(dev)
+
+ if dev is None:
+ print >>sys.stderr, 'Unable to find a connected ebook reader.'
+ return
+
+ for d in connected_devices:
+ try:
+ d.open()
+ except:
+ continue
+ else:
+ dev = d
+ break
+ return dev
+
def debug(ioreg_to_tmp=False, buf=None):
from calibre.customize.ui import device_plugins
from calibre.devices.scanner import DeviceScanner, win_pnp_drives
diff --git a/src/calibre/devices/prs505/sony_cache.py b/src/calibre/devices/prs505/sony_cache.py
index 61ae610c26..5342ec5079 100644
--- a/src/calibre/devices/prs505/sony_cache.py
+++ b/src/calibre/devices/prs505/sony_cache.py
@@ -173,7 +173,7 @@ class XMLCache(object):
def ensure_numeric_ids(root):
idmap = {}
- for x in root.xpath('//*[@id]'):
+ for x in root.xpath('child::*[@id]'):
id_ = x.get('id')
try:
id_ = int(id_)
@@ -206,7 +206,9 @@ class XMLCache(object):
for item in root.xpath('//*[@sourceid]'):
sid = pl_sourceid if item.tag.endswith('playlist') else sourceid
item.set('sourceid', str(sid))
- items = root.xpath('//*[@id]')
+ # Only rebase ids of nodes that are immediate children of the
+ # record root (that way playlist/itemnodes are unaffected
+ items = root.xpath('child::*[@id]')
items.sort(cmp=lambda x,y:cmp(int(x.get('id')), int(y.get('id'))))
idmap = {}
for i, item in enumerate(items):
@@ -214,13 +216,13 @@ class XMLCache(object):
new = base + i
if old != new:
item.set('id', str(new))
- idmap[old] = str(new)
+ idmap[old] = str(new)
return idmap
self.prune_empty_playlists()
for i in sorted(self.roots.keys()):
- root = self.roots[i]
+ root = self.record_roots[i]
if i == 0:
ensure_media_xml_base_ids(root)
@@ -281,6 +283,8 @@ class XMLCache(object):
playlist_map = self.get_playlist_map()
for i, booklist in booklists.items():
+ if DEBUG:
+ prints('Updating booklist:', i)
root = self.record_roots[i]
for book in booklist:
path = os.path.join(self.prefixes[i], *(book.lpath.split('/')))
@@ -378,7 +382,7 @@ class XMLCache(object):
def write(self):
for i, path in self.paths.items():
- raw = etree.tostring(self.roots[i], encoding='utf-8',
+ raw = etree.tostring(self.roots[i], encoding='UTF-8',
xml_declaration=True)
with open(path, 'wb') as f:
f.write(raw)
From e124ef7513c045552114edd1edc473875c5671d1 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Wed, 19 May 2010 15:21:01 -0600
Subject: [PATCH 151/324] Whitespace in XML cleanup and actually commit
playlist that exists in JSON to XML
---
src/calibre/devices/prs505/sony_cache.py | 49 ++++++++++++++----------
1 file changed, 29 insertions(+), 20 deletions(-)
diff --git a/src/calibre/devices/prs505/sony_cache.py b/src/calibre/devices/prs505/sony_cache.py
index 5342ec5079..02afe6c10d 100644
--- a/src/calibre/devices/prs505/sony_cache.py
+++ b/src/calibre/devices/prs505/sony_cache.py
@@ -161,10 +161,6 @@ class XMLCache(object):
'id' : str(self.max_id(root)+1),
'sourceid': '1'
})
- tail = '\n\t\t' if bl_idx == 0 else '\n\t'
- ans.tail = tail
- if len(root) > 0:
- root.iterchildren(reversed=True).next().tail = tail
root.append(ans)
return ans
# }}}
@@ -296,10 +292,6 @@ class XMLCache(object):
self.update_playlists(i, root, booklist, bl_pmap,
collections_attributes)
- tail = '\n\t' if i == 0 else '\n'
- if len(root) > 0:
- root.iterchildren(reversed=True).next().tail = tail
-
self.fix_ids()
def update_playlists(self, bl_index, root, booklist, playlist_map,
@@ -332,15 +324,12 @@ class XMLCache(object):
playlist.remove(item)
extra_ids = [x for x in playlist_ids if x not in ids]
- tail = '\n\t\t\t' if bl_index == 0 else '\n\t\t'
- playlist.tail = tail
for id_ in ids + extra_ids:
item = playlist.makeelement(
'{%s}item'%self.namespaces[bl_index],
nsmap=playlist.nsmap, attrib={'id':id_})
- item.tail = tail
- if len(playlist) > 0:
- root.iterchildren(reversed=True).next().tail = tail[:-1]
+ playlist.append(item)
+
def create_text_record(self, root, bl_id, lpath):
@@ -350,18 +339,19 @@ class XMLCache(object):
'page':'0', 'part':'0','pageOffset':'0','scale':'0',
'id':str(id_), 'sourceid':'1', 'path':lpath}
ans = root.makeelement('{%s}text'%namespace, attrib=attrib, nsmap=root.nsmap)
- tail = '\n\t\t' if bl_id == 0 else '\n\t'
- ans.tail = tail
- if len(root) > 0:
- root.iterchildren(reversed=True).next().tail = tail
root.append(ans)
return ans
def update_text_record(self, record, book, path, bl_index):
- timestamp = 'ctime' if bl_index == 0 else 'mtime'
- timestamp = getattr(os.path, 'get'+timestamp)(path)
+ timestamp = os.path.getctime(path)
date = strftime(timestamp)
- record.set('date', date)
+ if date != record.get('date', None):
+ if DEBUG:
+ prints('Changing date of', path, 'from',
+ record.get('date', ''), 'to', date)
+ prints('\tctime', strftime(os.path.getctime(path)))
+ prints('\tmtime', strftime(os.path.getmtime(path)))
+ record.set('date', date)
record.set('size', str(os.stat(path).st_size))
record.set('title', book.title)
record.set('author', authors_to_string(book.authors))
@@ -380,12 +370,31 @@ class XMLCache(object):
record.set('id', str(num+1))
# }}}
+ # Writing the XML files {{{
+ def cleanup_whitespace(self, bl_index):
+ root = self.record_roots[bl_index]
+ level = 2 if bl_index == 0 else 1
+ if len(root) > 0:
+ root.text = '\n'+'\t'*level
+ for child in root:
+ child.tail = '\n'+'\t'*level
+ if len(child) > 0:
+ child.text = '\n'+'\t'*(level+1)
+ for gc in child:
+ gc.tail = '\n'+'\t'*(level+1)
+ child.iterchildren(reversed=True).next().tail = '\n'+'\t'*level
+ root.iterchildren(reversed=True).next().tail = '\n'+'\t'*(level-1)
+
def write(self):
for i, path in self.paths.items():
+ self.cleanup_whitespace(i)
raw = etree.tostring(self.roots[i], encoding='UTF-8',
xml_declaration=True)
+ raw = raw.replace("",
+ '')
with open(path, 'wb') as f:
f.write(raw)
+ # }}}
def book_by_lpath(self, lpath, root):
matches = root.xpath(u'//*[local-name()="text" and @path="%s"]'%lpath)
From 82534aeb0a057eca20871df1442613472372bead Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Wed, 19 May 2010 15:28:49 -0600
Subject: [PATCH 152/324] Documentation
---
src/calibre/devices/prs505/sony_cache.py | 13 +++++++++++--
1 file changed, 11 insertions(+), 2 deletions(-)
diff --git a/src/calibre/devices/prs505/sony_cache.py b/src/calibre/devices/prs505/sony_cache.py
index 02afe6c10d..7022e58350 100644
--- a/src/calibre/devices/prs505/sony_cache.py
+++ b/src/calibre/devices/prs505/sony_cache.py
@@ -18,6 +18,7 @@ from calibre.constants import DEBUG
from calibre.ebooks.chardet import xml_to_unicode
from calibre.ebooks.metadata import string_to_authors, authors_to_string
+# Utility functions {{{
EMPTY_CARD_CACHE = '''\
@@ -54,6 +55,8 @@ def strftime(epoch, zone=time.gmtime):
def uuid():
return str(uuid4()).replace('-', '', 1).upper()
+# }}}
+
class XMLCache(object):
def __init__(self, paths, prefixes):
@@ -61,6 +64,8 @@ class XMLCache(object):
pprint(paths)
self.paths = paths
self.prefixes = prefixes
+
+ # Parse XML files {{{
parser = etree.XMLParser(recover=True)
self.roots = {}
for source_id, path in paths.items():
@@ -79,6 +84,7 @@ class XMLCache(object):
raw, strip_encoding_pats=True, assume_utf8=True,
verbose=DEBUG)[0],
parser=parser)
+ # }}}
recs = self.roots[0].xpath('//*[local-name()="records"]')
if not recs:
@@ -242,7 +248,8 @@ class XMLCache(object):
self.roots[0].set('nextID', str(max_id+1))
# }}}
- def update_booklist(self, bl, bl_index): # {{{
+ # Update JSON from XML {{{
+ def update_booklist(self, bl, bl_index):
if bl_index not in self.record_roots:
return
root = self.record_roots[bl_index]
@@ -274,7 +281,7 @@ class XMLCache(object):
break
# }}}
- # Update XML Cache {{{
+ # Update XML from JSON {{{
def update(self, booklists, collections_attributes):
playlist_map = self.get_playlist_map()
@@ -396,6 +403,7 @@ class XMLCache(object):
f.write(raw)
# }}}
+ # Utility methods {{{
def book_by_lpath(self, lpath, root):
matches = root.xpath(u'//*[local-name()="text" and @path="%s"]'%lpath)
if matches:
@@ -441,4 +449,5 @@ class XMLCache(object):
pprint(self.nsmaps)
prints('Found namespaces:')
pprint(self.namespaces)
+ # }}}
From c0f70a782088a2fcdfc44838aebefa1684d62623 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Wed, 19 May 2010 16:05:46 -0600
Subject: [PATCH 153/324] Another typo
---
src/calibre/devices/prs505/sony_cache.py | 7 +++++--
1 file changed, 5 insertions(+), 2 deletions(-)
diff --git a/src/calibre/devices/prs505/sony_cache.py b/src/calibre/devices/prs505/sony_cache.py
index 7022e58350..674a2cbddd 100644
--- a/src/calibre/devices/prs505/sony_cache.py
+++ b/src/calibre/devices/prs505/sony_cache.py
@@ -61,6 +61,7 @@ class XMLCache(object):
def __init__(self, paths, prefixes):
if DEBUG:
+ prints('Building XMLCache...')
pprint(paths)
self.paths = paths
self.prefixes = prefixes
@@ -117,7 +118,7 @@ class XMLCache(object):
for playlist in root.xpath('//*[local-name()="playlist"]'):
if len(playlist) == 0 or not playlist.get('title', None):
if DEBUG:
- prints('Removing playlist:', playlist.get('id', None),
+ prints('Removing playlist id:', playlist.get('id', None),
playlist.get('title', None))
playlist.getparent().remove(playlist)
@@ -160,6 +161,8 @@ class XMLCache(object):
for playlist in root.xpath('//*[local-name()="playlist"]'):
if playlist.get('title', None) == title:
return playlist
+ if DEBUG:
+ prints('Creating playlist:', title)
ans = root.makeelement('{%s}playlist'%self.namespaces[bl_idx],
nsmap=root.nsmap, attrib={
'uuid' : uuid(),
@@ -218,7 +221,7 @@ class XMLCache(object):
new = base + i
if old != new:
item.set('id', str(new))
- idmap[old] = str(new)
+ idmap[str(old)] = str(new)
return idmap
self.prune_empty_playlists()
From ec186049493b278c14c7da0bb20061c64ef809a9 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Wed, 19 May 2010 16:31:42 -0600
Subject: [PATCH 154/324] Update tags in JSON cache based on collections in XML
cache
---
src/calibre/devices/prs505/sony_cache.py | 23 +++++++++++++++++++++--
1 file changed, 21 insertions(+), 2 deletions(-)
diff --git a/src/calibre/devices/prs505/sony_cache.py b/src/calibre/devices/prs505/sony_cache.py
index 674a2cbddd..14ac03c777 100644
--- a/src/calibre/devices/prs505/sony_cache.py
+++ b/src/calibre/devices/prs505/sony_cache.py
@@ -144,7 +144,7 @@ class XMLCache(object):
self.ensure_unique_playlist_titles()
self.prune_empty_playlists()
for i, root in self.record_roots.items():
- ans[i] = {}
+ ans[i] = []
for playlist in root.xpath('//*[local-name()="playlist"]'):
items = []
for item in playlist:
@@ -153,7 +153,7 @@ class XMLCache(object):
'//*[local-name()="text" and @id="%s"]'%id_)
if records:
items.append(records[0])
- ans[i] = {playlist.get('title'):items}
+ ans[i].append((playlist.get('title'), items))
return ans
def get_or_create_playlist(self, bl_idx, title):
@@ -256,6 +256,16 @@ class XMLCache(object):
if bl_index not in self.record_roots:
return
root = self.record_roots[bl_index]
+ pmap = self.get_playlist_map()[bl_index]
+ playlist_map = {}
+ for title, records in pmap:
+ for record in records:
+ path = record.get('path', None)
+ if path:
+ if path not in playlist_map:
+ playlist_map[path] = []
+ playlist_map[path].append(title)
+
for book in bl:
record = self.book_by_lpath(book.lpath, root)
if record is not None:
@@ -282,6 +292,15 @@ class XMLCache(object):
book.thumbnail = raw
break
break
+ if book.lpath in playlist_map:
+ tags = playlist_map[book.lpath]
+ if tags:
+ if DEBUG:
+ prints('Adding tags:', tags, 'to', book.title)
+ if not book.tags:
+ book.tags = []
+ book.tags = list(book.tags)
+ book.tags += tags
# }}}
# Update XML from JSON {{{
From b97141b2080bb5d04c0ec13bf1e0306ad325e0e5 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Wed, 19 May 2010 17:52:37 -0600
Subject: [PATCH 155/324] Change the tags column in the device view to a
Collections column that allows the user to directly edit collections on the
device. Note that if the user deletes a collection taht corresponds to some
data in the calibre library that would be turned intoa coleection, then the
deletion has no effect, on device reconnect
---
src/calibre/devices/interface.py | 8 ---
src/calibre/devices/prs505/sony_cache.py | 71 ++++++++++++++++++------
src/calibre/devices/usbms/books.py | 40 ++++---------
src/calibre/gui2/library/models.py | 6 +-
4 files changed, 67 insertions(+), 58 deletions(-)
diff --git a/src/calibre/devices/interface.py b/src/calibre/devices/interface.py
index be58bc9b0c..df2d5500e4 100644
--- a/src/calibre/devices/interface.py
+++ b/src/calibre/devices/interface.py
@@ -396,14 +396,6 @@ class BookList(list):
''' Return True if the the device supports tags (collections) for this book list. '''
raise NotImplementedError()
- def set_tags(self, book, tags):
- '''
- Set the tags for C{book} to C{tags}.
- @param tags: A list of strings. Can be empty.
- @param book: A book object that is in this BookList.
- '''
- raise NotImplementedError()
-
def add_book(self, book, replace_metadata):
'''
Add the book to the booklist. Intent is to maintain any device-internal
diff --git a/src/calibre/devices/prs505/sony_cache.py b/src/calibre/devices/prs505/sony_cache.py
index 14ac03c777..ec4c263cf9 100644
--- a/src/calibre/devices/prs505/sony_cache.py
+++ b/src/calibre/devices/prs505/sony_cache.py
@@ -101,16 +101,25 @@ class XMLCache(object):
# Playlist management {{{
def purge_broken_playlist_items(self, root):
- for item in root.xpath(
- '//*[local-name()="playlist"]/*[local-name()="item"]'):
- id_ = item.get('id', None)
- if id_ is None or not root.xpath(
- '//*[local-name()!="item" and @id="%s"]'%id_):
- if DEBUG:
- prints('Purging broken playlist item:',
- etree.tostring(item, with_tail=False))
- item.getparent().remove(item)
-
+ for pl in root.xpath('//*[local-name()="playlist"]'):
+ seen = set([])
+ for item in list(pl):
+ id_ = item.get('id', None)
+ if id_ is None or id_ in seen or not root.xpath(
+ '//*[local-name()!="item" and @id="%s"]'%id_):
+ if DEBUG:
+ if id_ is None:
+ cause = 'invalid id'
+ elif id_ in seen:
+ cause = 'duplicate item'
+ else:
+ cause = 'id not found'
+ prints('Purging broken playlist item:',
+ id_, 'from playlist:', pl.get('title', None),
+ 'because:', cause)
+ item.getparent().remove(item)
+ continue
+ seen.add(id_)
def prune_empty_playlists(self):
for i, root in self.record_roots.items():
@@ -175,6 +184,8 @@ class XMLCache(object):
# }}}
def fix_ids(self): # {{{
+ if DEBUG:
+ prints('Running fix_ids()')
def ensure_numeric_ids(root):
idmap = {}
@@ -294,13 +305,8 @@ class XMLCache(object):
break
if book.lpath in playlist_map:
tags = playlist_map[book.lpath]
- if tags:
- if DEBUG:
- prints('Adding tags:', tags, 'to', book.title)
- if not book.tags:
- book.tags = []
- book.tags = list(book.tags)
- book.tags += tags
+ book.device_collections = tags
+
# }}}
# Update XML from JSON {{{
@@ -359,7 +365,25 @@ class XMLCache(object):
nsmap=playlist.nsmap, attrib={'id':id_})
playlist.append(item)
-
+ # Delete playlist entries not in collections
+ for playlist in root.xpath('//*[local-name()="playlist"]'):
+ title = playlist.get('title', None)
+ if title not in collections:
+ if DEBUG:
+ prints('Deleting playlist:', playlist.get('title', ''))
+ playlist.getparent().remove(playlist)
+ continue
+ books = collections[title]
+ records = [self.book_by_lpath(b.lpath, root) for b in books]
+ records = [x for x in records if x is not None]
+ ids = [x.get('id', None) for x in records]
+ ids = [x for x in ids if x is not None]
+ for item in list(playlist):
+ if item.get('id', None) not in ids:
+ if DEBUG:
+ prints('Deleting item:', item.get('id', ''),
+ 'from playlist:', playlist.get('title', ''))
+ playlist.remove(item)
def create_text_record(self, root, bl_id, lpath):
namespace = self.namespaces[bl_id]
@@ -414,8 +438,19 @@ class XMLCache(object):
child.iterchildren(reversed=True).next().tail = '\n'+'\t'*level
root.iterchildren(reversed=True).next().tail = '\n'+'\t'*(level-1)
+ def move_playlists_to_bottom(self):
+ for root in self.record_roots.values():
+ seen = []
+ for pl in root.xpath('//*[local-name()="playlist"]'):
+ pl.getparent().remove(pl)
+ seen.append(pl)
+ for pl in seen:
+ root.append(pl)
+
+
def write(self):
for i, path in self.paths.items():
+ self.move_playlists_to_bottom()
self.cleanup_whitespace(i)
raw = etree.tostring(self.roots[i], encoding='UTF-8',
xml_declaration=True)
diff --git a/src/calibre/devices/usbms/books.py b/src/calibre/devices/usbms/books.py
index 97c911283b..a0e3dd01d2 100644
--- a/src/calibre/devices/usbms/books.py
+++ b/src/calibre/devices/usbms/books.py
@@ -14,14 +14,14 @@ from calibre import isbytestring
class Book(MetaInformation):
- BOOK_ATTRS = ['lpath', 'size', 'mime']
+ BOOK_ATTRS = ['lpath', 'size', 'mime', 'device_collections']
JSON_ATTRS = [
'lpath', 'title', 'authors', 'mime', 'size', 'tags', 'author_sort',
'title_sort', 'comments', 'category', 'publisher', 'series',
'series_index', 'rating', 'isbn', 'language', 'application_id',
'book_producer', 'lccn', 'lcc', 'ddc', 'rights', 'publication_type',
- 'uuid'
+ 'uuid',
]
def __init__(self, prefix, lpath, size=None, other=None):
@@ -29,6 +29,7 @@ class Book(MetaInformation):
MetaInformation.__init__(self, '')
+ self.device_collections = []
self.path = os.path.join(prefix, lpath)
if os.sep == '\\':
self.path = self.path.replace('/', '\\')
@@ -45,27 +46,7 @@ class Book(MetaInformation):
self.smart_update(other)
def __eq__(self, other):
- spath = self.path
- opath = other.path
-
- if not isinstance(self.path, unicode):
- try:
- spath = unicode(self.path)
- except:
- try:
- spath = self.path.decode(filesystem_encoding)
- except:
- spath = self.path
- if not isinstance(other.path, unicode):
- try:
- opath = unicode(other.path)
- except:
- try:
- opath = other.path.decode(filesystem_encoding)
- except:
- opath = other.path
-
- return spath == opath
+ return self.path == getattr(other, 'path', None)
@dynamic_property
def db_id(self):
@@ -119,9 +100,6 @@ class BookList(_BookList):
def supports_tags(self):
return True
- def set_tags(self, book, tags):
- book.tags = tags
-
def add_book(self, book, replace_metadata):
if book not in self:
self.append(book)
@@ -134,6 +112,7 @@ class BookList(_BookList):
def get_collections(self, collection_attributes):
collections = {}
series_categories = set([])
+ collection_attributes = list(collection_attributes)+['device_collections']
for attr in collection_attributes:
for book in self:
val = getattr(book, attr, None)
@@ -147,9 +126,12 @@ class BookList(_BookList):
for category in val:
if category not in collections:
collections[category] = []
- collections[category].append(book)
- if attr == 'series':
- series_categories.add(category)
+ if book not in collections[category]:
+ collections[category].append(book)
+ if attr == 'series':
+ series_categories.add(category)
+
+ # Sort collections
for category, books in collections.items():
def tgetter(x):
return getattr(x, 'title_sort', 'zzzz')
diff --git a/src/calibre/gui2/library/models.py b/src/calibre/gui2/library/models.py
index bd8fb20741..43816f3ea0 100644
--- a/src/calibre/gui2/library/models.py
+++ b/src/calibre/gui2/library/models.py
@@ -807,7 +807,7 @@ class DeviceBooksModel(BooksModel): # {{{
'authors' : _('Author(s)'),
'timestamp' : _('Date'),
'size' : _('Size'),
- 'tags' : _('Tags')
+ 'tags' : _('Collections')
}
self.marked_for_deletion = {}
self.search_engine = OnDeviceSearch(self)
@@ -1000,7 +1000,7 @@ class DeviceBooksModel(BooksModel): # {{{
dt = dt_factory(dt, assume_utc=True, as_utc=False)
return QVariant(strftime(TIME_FMT, dt.timetuple()))
elif cname == 'tags':
- tags = self.db[self.map[row]].tags
+ tags = self.db[self.map[row]].device_collections
if tags:
return QVariant(', '.join(tags))
elif role == Qt.ToolTipRole and index.isValid():
@@ -1047,7 +1047,7 @@ class DeviceBooksModel(BooksModel): # {{{
elif cname == 'tags':
tags = [i.strip() for i in val.split(',')]
tags = [t for t in tags if t]
- self.db.set_tags(self.db[idx], tags)
+ self.db[idx].device_collections = tags
self.dataChanged.emit(index, index)
self.booklist_dirtied.emit()
done = True
From b910e737c6f45ea3ed583f311a648a874460f3dc Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Wed, 19 May 2010 18:00:46 -0600
Subject: [PATCH 156/324] Don't turn tags surrounded by [] into collections
---
src/calibre/devices/usbms/books.py | 3 +++
1 file changed, 3 insertions(+)
diff --git a/src/calibre/devices/usbms/books.py b/src/calibre/devices/usbms/books.py
index a0e3dd01d2..f7ae6c4ef4 100644
--- a/src/calibre/devices/usbms/books.py
+++ b/src/calibre/devices/usbms/books.py
@@ -124,6 +124,9 @@ class BookList(_BookList):
elif isinstance(val, unicode):
val = [val]
for category in val:
+ if attr == 'tags' and len(category) > 1 and \
+ category[0] == '[' and category[-1] == ']':
+ continue
if category not in collections:
collections[category] = []
if book not in collections[category]:
From dfa38c41418c3ea9c627dbcf4c85d18c5b451c4a Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Wed, 19 May 2010 22:26:07 -0600
Subject: [PATCH 157/324] Fix broken series sorting
---
src/calibre/library/caches.py | 8 +++++---
1 file changed, 5 insertions(+), 3 deletions(-)
diff --git a/src/calibre/library/caches.py b/src/calibre/library/caches.py
index e280a2178b..17853b818f 100644
--- a/src/calibre/library/caches.py
+++ b/src/calibre/library/caches.py
@@ -578,12 +578,14 @@ class ResultCache(SearchQueryParser):
self._map_filtered = list(self._map)
def seriescmp(self, x, y):
+ sidx = self.FIELD_MAP['series']
try:
- ans = cmp(self._data[x][9].lower(), self._data[y][9].lower())
+ ans = cmp(self._data[x][sidx].lower(), self._data[y][sidx].lower())
except AttributeError: # Some entries may be None
- ans = cmp(self._data[x][9], self._data[y][9])
+ ans = cmp(self._data[x][sidx], self._data[y][sidx])
if ans != 0: return ans
- return cmp(self._data[x][10], self._data[y][10])
+ sidx = self.FIELD_MAP['series_index']
+ return cmp(self._data[x][sidx], self._data[y][sidx])
def cmp(self, loc, x, y, asstr=True, subsort=False):
try:
From 6dadf8cf0ea5515c5c04c8f6a6de58c6118fec52 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Wed, 19 May 2010 22:37:31 -0600
Subject: [PATCH 158/324] If last sort was ondevice, ersort on device
re-connect
---
src/calibre/gui2/library/models.py | 3 +++
1 file changed, 3 insertions(+)
diff --git a/src/calibre/gui2/library/models.py b/src/calibre/gui2/library/models.py
index 43816f3ea0..3f0dfc5065 100644
--- a/src/calibre/gui2/library/models.py
+++ b/src/calibre/gui2/library/models.py
@@ -102,6 +102,9 @@ class BooksModel(QAbstractTableModel): # {{{
def set_device_connected(self, is_connected):
self.device_connected = is_connected
self.db.refresh_ondevice()
+ if is_connected and self.sorted_on[0] == 'ondevice':
+ self.resort()
+
def set_book_on_device_func(self, func):
self.book_on_device = func
From a5204b6eac17c5200455fb6f34e7991fab13edea Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Wed, 19 May 2010 22:44:41 -0600
Subject: [PATCH 159/324] Fixes for device collections
---
src/calibre/gui2/library/models.py | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/src/calibre/gui2/library/models.py b/src/calibre/gui2/library/models.py
index 3f0dfc5065..66ebc4dc53 100644
--- a/src/calibre/gui2/library/models.py
+++ b/src/calibre/gui2/library/models.py
@@ -899,7 +899,8 @@ class DeviceBooksModel(BooksModel): # {{{
x, y = int(self.db[x].size), int(self.db[y].size)
return cmp(x, y)
def tagscmp(x, y):
- x, y = ','.join(self.db[x].tags), ','.join(self.db[y].tags)
+ x = ','.join(self.db[x].device_collections)
+ y = ','.join(self.db[y].device_collections)
return cmp(x, y)
def libcmp(x, y):
x, y = self.db[x].in_library, self.db[y].in_library
@@ -969,7 +970,7 @@ class DeviceBooksModel(BooksModel): # {{{
data[_('Path')] = item.path
dt = dt_factory(item.datetime, assume_utc=True)
data[_('Timestamp')] = isoformat(dt, sep=' ', as_utc=False)
- data[_('Tags')] = ', '.join(item.tags)
+ data[_('Collections')] = ', '.join(item.device_collections)
self.new_bookdisplay_data.emit(data)
def paths(self, rows):
From ff99f2af2ba8290d4f38fa6a402c012d739f6067 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Wed, 19 May 2010 22:50:43 -0600
Subject: [PATCH 160/324] Fix customizing driver plugin leads to tags not being
sent to device
---
src/calibre/devices/prs505/driver.py | 3 ++-
src/calibre/devices/usbms/books.py | 1 +
2 files changed, 3 insertions(+), 1 deletion(-)
diff --git a/src/calibre/devices/prs505/driver.py b/src/calibre/devices/prs505/driver.py
index 794bf66600..7277b24723 100644
--- a/src/calibre/devices/prs505/driver.py
+++ b/src/calibre/devices/prs505/driver.py
@@ -101,7 +101,8 @@ class PRS505(USBMS):
opts = self.settings()
collections = ['series', 'tags']
if opts.extra_customization:
- collections = opts.extra_customization.split(',')
+ collections = [x.strip() for x in
+ opts.extra_customization.split(',')]
c.update(blists, collections)
c.write()
diff --git a/src/calibre/devices/usbms/books.py b/src/calibre/devices/usbms/books.py
index f7ae6c4ef4..4de1341c41 100644
--- a/src/calibre/devices/usbms/books.py
+++ b/src/calibre/devices/usbms/books.py
@@ -114,6 +114,7 @@ class BookList(_BookList):
series_categories = set([])
collection_attributes = list(collection_attributes)+['device_collections']
for attr in collection_attributes:
+ attr = attr.strip()
for book in self:
val = getattr(book, attr, None)
if not val: continue
From 37bfe8109d9f641424f8ff122cea79b8ee9f279e Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Wed, 19 May 2010 23:18:20 -0600
Subject: [PATCH 161/324] Fix content server to always use FIELD_MAP
---
src/calibre/library/server.py | 111 ++++++++++++++++++----------------
1 file changed, 59 insertions(+), 52 deletions(-)
diff --git a/src/calibre/library/server.py b/src/calibre/library/server.py
index 7023d72f0c..1a15492da3 100644
--- a/src/calibre/library/server.py
+++ b/src/calibre/library/server.py
@@ -7,7 +7,7 @@ __docformat__ = 'restructuredtext en'
HTTP server for remote access to the calibre database.
'''
-import sys, textwrap, operator, os, re, logging, cStringIO
+import sys, textwrap, operator, os, re, logging, cStringIO, copy
import __builtin__
from itertools import repeat
from logging.handlers import RotatingFileHandler
@@ -63,21 +63,21 @@ class LibraryServer(object):
BOOK = textwrap.dedent('''\
${r[8] if r[8] else ''}
+ size="${r[FM['size']]}"
+ isbn="${r[FM['isbn']] if r[FM['isbn']] else ''}"
+ formats="${r[FM['formats']] if r[FM['formats']] else ''}"
+ series = "${r[FM['series']] if r[FM['series']] else ''}"
+ series_index="${r[FM['series_index']]}"
+ tags="${r[FM['tags']] if r[FM['tags']] else ''}"
+ publisher="${r[FM['publisher']] if r[FM['publisher']] else ''}">${r[FM['comments']] if r[FM['comments']] else ''}
''')
@@ -86,13 +86,13 @@ class LibraryServer(object):
MOBILE_BOOK = textwrap.dedent('''\
-
+
-
- ${format.lower()}
+
+ ${format.lower()}
- ${r[1]}${(' ['+r[9]+'-'+r[10]+']') if r[9] else ''} by ${authors} - ${r[6]/1024}k - ${r[3] if r[3] else ''} ${pubdate} ${'['+r[7]+']' if r[7] else ''}
+ ${r[FM['title']]}${(' ['+r[FM['series']]+'-'+r[FM['series_index']]+']') if r[FM['series']] else ''} by ${authors} - ${r[FM['size']]/1024}k - ${r[FM['publisher']] if r[FM['publisher']] else ''} ${pubdate} ${'['+r[FM['tags']]+']' if r[FM['tags']] else ''}
''')
@@ -628,22 +628,23 @@ class LibraryServer(object):
ids = self.db.data.parse(search) if search and search.strip() else self.db.data.universal_set()
record_list = list(iter(self.db))
+ FM = self.db.FIELD_MAP
# Sort the record list
if sortby == "bytitle" or authorid or tagid:
record_list.sort(lambda x, y:
- cmp(title_sort(x[self.db.FIELD_MAP['title']]),
- title_sort(y[self.db.FIELD_MAP['title']])))
+ cmp(title_sort(x[FM['title']]),
+ title_sort(y[FM['title']])))
elif seriesid:
record_list.sort(lambda x, y:
- cmp(x[self.db.FIELD_MAP['series_index']],
- y[self.db.FIELD_MAP['series_index']]))
+ cmp(x[FM['series_index']],
+ y[FM['series_index']]))
else: # Sort by date
record_list = reversed(record_list)
- fmts = self.db.FIELD_MAP['formats']
+ fmts = FM['formats']
pat = re.compile(r'EPUB|PDB', re.IGNORECASE)
- record_list = [x for x in record_list if x[0] in ids and
+ record_list = [x for x in record_list if x[FM['id']] in ids and
pat.search(x[fmts] if x[fmts] else '') is not None]
next_offset = offset + self.max_stanza_items
nrecord_list = record_list[offset:next_offset]
@@ -663,10 +664,10 @@ class LibraryServer(object):
) % '&'.join(q)
for record in nrecord_list:
- r = record[self.db.FIELD_MAP['formats']]
+ r = record[FM['formats']]
r = r.upper() if r else ''
- z = record[self.db.FIELD_MAP['authors']]
+ z = record[FM['authors']]
if not z:
z = _('Unknown')
authors = ' & '.join([i.replace('|', ',') for i in
@@ -674,19 +675,19 @@ class LibraryServer(object):
# Setup extra description
extra = []
- rating = record[self.db.FIELD_MAP['rating']]
+ rating = record[FM['rating']]
if rating > 0:
rating = ''.join(repeat('★', rating))
extra.append('RATING: %s '%rating)
- tags = record[self.db.FIELD_MAP['tags']]
+ tags = record[FM['tags']]
if tags:
extra.append('TAGS: %s '%\
prepare_string_for_xml(', '.join(tags.split(','))))
- series = record[self.db.FIELD_MAP['series']]
+ series = record[FM['series']]
if series:
extra.append('SERIES: %s [%s] '%\
(prepare_string_for_xml(series),
- fmt_sidx(float(record[self.db.FIELD_MAP['series_index']]))))
+ fmt_sidx(float(record[FM['series_index']]))))
fmt = 'epub' if 'EPUB' in r else 'pdb'
mimetype = guess_type('dummy.'+fmt)[0]
@@ -699,17 +700,18 @@ class LibraryServer(object):
authors=authors,
tags=tags,
series=series,
- FM=self.db.FIELD_MAP,
+ FM=FM,
extra='\n'.join(extra),
mimetype=mimetype,
fmt=fmt,
- urn=record[self.db.FIELD_MAP['uuid']],
- timestamp=strftime('%Y-%m-%dT%H:%M:%S+00:00', record[5])
+ urn=record[FM['uuid']],
+ timestamp=strftime('%Y-%m-%dT%H:%M:%S+00:00',
+ record[FM['timestamp']])
)
books.append(self.STANZA_ENTRY.generate(**data)\
.render('xml').decode('utf8'))
- return self.STANZA.generate(subtitle='', data=books, FM=self.db.FIELD_MAP,
+ return self.STANZA.generate(subtitle='', data=books, FM=FM,
next_link=next_link, updated=updated, id='urn:calibre:main').render('xml')
@@ -734,23 +736,25 @@ class LibraryServer(object):
raise cherrypy.HTTPError(400, 'num: %s is not an integer'%num)
ids = self.db.data.parse(search) if search and search.strip() else self.db.data.universal_set()
ids = sorted(ids)
- items = [r for r in iter(self.db) if r[0] in ids]
+ FM = self.db.FIELD_MAP
+ items = copy.deepcopy([r for r in iter(self.db) if r[FM['id']] in ids])
if sort is not None:
self.sort(items, sort, (order.lower().strip() == 'ascending'))
book, books = MarkupTemplate(self.MOBILE_BOOK), []
for record in items[(start-1):(start-1)+num]:
- if record[13] is None:
- record[13] = ''
- if record[6] is None:
- record[6] = 0
- aus = record[2] if record[2] else __builtin__._('Unknown')
+ if record[FM['formats']] is None:
+ record[FM['formats']] = ''
+ if record[FM['size']] is None:
+ record[FM['size']] = 0
+ aus = record[FM['authors']] if record[FM['authors']] else __builtin__._('Unknown')
authors = '|'.join([i.replace('|', ',') for i in aus.split(',')])
- record[10] = fmt_sidx(float(record[10]))
- ts, pd = strftime('%Y/%m/%d %H:%M:%S', record[5]), \
- strftime('%Y/%m/%d %H:%M:%S', record[self.db.FIELD_MAP['pubdate']])
+ record[FM['series_index']] = \
+ fmt_sidx(float(record[FM['series_index']]))
+ ts, pd = strftime('%Y/%m/%d %H:%M:%S', record[FM['timestamp']]), \
+ strftime('%Y/%m/%d %H:%M:%S', record[FM['pubdate']])
books.append(book.generate(r=record, authors=authors, timestamp=ts,
- pubdate=pd).render('xml').decode('utf-8'))
+ pubdate=pd, FM=FM).render('xml').decode('utf-8'))
updated = self.db.last_modified()
cherrypy.response.headers['Content-Type'] = 'text/html; charset=utf-8'
@@ -759,8 +763,9 @@ class LibraryServer(object):
url_base = "/mobile?search=" + search+";order="+order+";sort="+sort+";num="+str(num)
- return self.MOBILE.generate(books=books, start=start, updated=updated, search=search, sort=sort, order=order, num=num,
- total=len(ids), url_base=url_base).render('html')
+ return self.MOBILE.generate(books=books, start=start, updated=updated,
+ search=search, sort=sort, order=order, num=num, FM=FM,
+ total=len(ids), url_base=url_base).render('html')
@expose
@@ -785,25 +790,27 @@ class LibraryServer(object):
order = order.lower().strip() == 'ascending'
ids = self.db.data.parse(search) if search and search.strip() else self.db.data.universal_set()
ids = sorted(ids)
- items = [r for r in iter(self.db) if r[0] in ids]
+ FM = self.db.FIELD_MAP
+ items = copy.deepcopy([r for r in iter(self.db) if r[FM['id']] in ids])
if sort is not None:
self.sort(items, sort, order)
book, books = MarkupTemplate(self.BOOK), []
for record in items[start:start+num]:
- aus = record[2] if record[2] else __builtin__._('Unknown')
+ aus = record[FM['authors']] if record[FM['authors']] else __builtin__._('Unknown')
authors = '|'.join([i.replace('|', ',') for i in aus.split(',')])
- record[10] = fmt_sidx(float(record[10]))
- ts, pd = strftime('%Y/%m/%d %H:%M:%S', record[5]), \
- strftime('%Y/%m/%d %H:%M:%S', record[self.db.FIELD_MAP['pubdate']])
+ record[FM['series_index']] = \
+ fmt_sidx(float(record[FM['series_index']]))
+ ts, pd = strftime('%Y/%m/%d %H:%M:%S', record[FM['timestamp']]), \
+ strftime('%Y/%m/%d %H:%M:%S', record[FM['pubdate']])
books.append(book.generate(r=record, authors=authors, timestamp=ts,
- pubdate=pd).render('xml').decode('utf-8'))
+ pubdate=pd, FM=FM).render('xml').decode('utf-8'))
updated = self.db.last_modified()
cherrypy.response.headers['Content-Type'] = 'text/xml'
cherrypy.response.headers['Last-Modified'] = self.last_modified(updated)
return self.LIBRARY.generate(books=books, start=start, updated=updated,
- total=len(ids)).render('xml')
+ total=len(ids), FM=FM).render('xml')
@expose
def index(self, **kwargs):
From 676bf2b00a623537ce1212fb7a1c39e8af1dceea Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Thu, 20 May 2010 00:09:20 -0600
Subject: [PATCH 162/324] Consolidate all sony drivers into one class
---
src/calibre/customize/builtins.py | 3 +-
src/calibre/devices/prs505/driver.py | 51 +++++++++++-----------------
2 files changed, 20 insertions(+), 34 deletions(-)
diff --git a/src/calibre/customize/builtins.py b/src/calibre/customize/builtins.py
index 9a32774f5f..045d6289b7 100644
--- a/src/calibre/customize/builtins.py
+++ b/src/calibre/customize/builtins.py
@@ -442,7 +442,7 @@ from calibre.devices.irexdr.driver import IREXDR1000, IREXDR800
from calibre.devices.jetbook.driver import JETBOOK
from calibre.devices.kindle.driver import KINDLE, KINDLE2, KINDLE_DX
from calibre.devices.nook.driver import NOOK
-from calibre.devices.prs505.driver import PRS505, PRS700
+from calibre.devices.prs505.driver import PRS505
from calibre.devices.android.driver import ANDROID, S60
from calibre.devices.nokia.driver import N770, N810
from calibre.devices.eslick.driver import ESLICK
@@ -510,7 +510,6 @@ plugins += [
KINDLE_DX,
NOOK,
PRS505,
- PRS700,
ANDROID,
S60,
N770,
diff --git a/src/calibre/devices/prs505/driver.py b/src/calibre/devices/prs505/driver.py
index 7277b24723..74e1bf0a7e 100644
--- a/src/calibre/devices/prs505/driver.py
+++ b/src/calibre/devices/prs505/driver.py
@@ -1,12 +1,9 @@
-# -*- coding: utf-8 -*-
-
__license__ = 'GPL v3'
-__copyright__ = '2008, Kovid Goyal ' \
- '2009, John Schember '
+__copyright__ = '2008, Kovid Goyal '
__docformat__ = 'restructuredtext en'
'''
-Device driver for the SONY PRS-505
+Device driver for the SONY devices
'''
import os
@@ -20,27 +17,33 @@ from calibre import __appname__
class PRS505(USBMS):
- name = 'PRS-300/505 Device Interface'
+ name = 'SONY Device Interface'
gui_name = 'SONY Reader'
- description = _('Communicate with the Sony PRS-300/505/500 eBook reader.')
- author = 'Kovid Goyal and John Schember'
+ description = _('Communicate with all the Sony eBook readers.')
+ author = 'Kovid Goyal'
supported_platforms = ['windows', 'osx', 'linux']
path_sep = '/'
FORMATS = ['epub', 'lrf', 'lrx', 'rtf', 'pdf', 'txt']
VENDOR_ID = [0x054c] #: SONY Vendor Id
- PRODUCT_ID = [0x031e] #: Product Id for the PRS 300/505/new 500
- BCD = [0x229, 0x1000, 0x22a]
+ PRODUCT_ID = [0x031e]
+ BCD = [0x229, 0x1000, 0x22a, 0x31a]
VENDOR_NAME = 'SONY'
- WINDOWS_MAIN_MEM = re.compile('PRS-(505|300|500)')
- WINDOWS_CARD_A_MEM = re.compile(r'PRS-(505|500)[#/]\S+:MS')
- WINDOWS_CARD_B_MEM = re.compile(r'PRS-(505|500)[#/]\S+:SD')
+ WINDOWS_MAIN_MEM = re.compile(
+ r'(PRS-(505|300|500))|'
+ r'(PRS-((700[#/])|((6|9)00&)))'
+ )
+ WINDOWS_CARD_A_MEM = re.compile(
+ r'(PRS-(505|500)[#/]\S+:MS)|'
+ r'(PRS-((700[/#]\S+:)|((6|9)00[#_]))MS)'
+ )
+ WINDOWS_CARD_B_MEM = re.compile(
+ r'(PRS-(505|500)[#/]\S+:SD)|'
+ r'(PRS-((700[/#]\S+:)|((6|9)00[#_]))SD)'
+ )
- OSX_MAIN_MEM = re.compile(r'Sony PRS-(((505|300|500)/[^:]+)|(300)) Media')
- OSX_CARD_A_MEM = re.compile(r'Sony PRS-(505|500)/[^:]+:MS Media')
- OSX_CARD_B_MEM = re.compile(r'Sony PRS-(505|500)/[^:]+:SD Media')
MAIN_MEMORY_VOLUME_LABEL = 'Sony Reader Main Memory'
STORAGE_CARD_VOLUME_LABEL = 'Sony Reader Storage Card'
@@ -109,20 +112,4 @@ class PRS505(USBMS):
USBMS.sync_booklists(self, booklists, end_session=end_session)
-class PRS700(PRS505):
- name = 'PRS-600/700/900 Device Interface'
- description = _('Communicate with the Sony PRS-600/700/900 eBook reader.')
- author = 'Kovid Goyal and John Schember'
- gui_name = 'SONY Reader'
- supported_platforms = ['windows', 'osx', 'linux']
-
- BCD = [0x31a]
-
- WINDOWS_MAIN_MEM = re.compile('PRS-((700[#/])|((6|9)00&))')
- WINDOWS_CARD_A_MEM = re.compile(r'PRS-((700[/#]\S+:)|((6|9)00[#_]))MS')
- WINDOWS_CARD_B_MEM = re.compile(r'PRS-((700[/#]\S+:)|((6|9)00[#_]))SD')
-
- OSX_MAIN_MEM = re.compile(r'Sony PRS-((700/[^:]+)|((6|9)00)) Media')
- OSX_CARD_A_MEM = re.compile(r'Sony PRS-((700/[^:]+:)|((6|9)00 ))MS Media')
- OSX_CARD_B_MEM = re.compile(r'Sony PRS-((700/[^:]+:)|((6|9)00 ))SD Media')
From 702a2030131f6d2c0e73a49d1963e5c3088044f0 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Thu, 20 May 2010 00:14:14 -0600
Subject: [PATCH 163/324] Remove tag_order kludge
---
src/calibre/devices/interface.py | 3 +--
src/calibre/ebooks/metadata/__init__.py | 4 ++--
src/calibre/gui2/library/models.py | 3 ---
3 files changed, 3 insertions(+), 7 deletions(-)
diff --git a/src/calibre/devices/interface.py b/src/calibre/devices/interface.py
index df2d5500e4..f71585fad0 100644
--- a/src/calibre/devices/interface.py
+++ b/src/calibre/devices/interface.py
@@ -293,8 +293,7 @@ class DevicePlugin(Plugin):
put the book. len(metadata) == len(files). Apart from the regular
cover (path to cover), there may also be a thumbnail attribute, which should
be used in preference. The thumbnail attribute is of the form
- (width, height, cover_data as jpeg). In addition the MetaInformation
- objects can have a tag_order attribute.
+ (width, height, cover_data as jpeg).
'''
raise NotImplementedError()
diff --git a/src/calibre/ebooks/metadata/__init__.py b/src/calibre/ebooks/metadata/__init__.py
index a1c29be337..6b573a0420 100644
--- a/src/calibre/ebooks/metadata/__init__.py
+++ b/src/calibre/ebooks/metadata/__init__.py
@@ -258,7 +258,7 @@ class MetaInformation(object):
'series', 'series_index', 'tags', 'rating', 'isbn', 'language',
'application_id', 'manifest', 'toc', 'spine', 'guide', 'cover',
'book_producer', 'timestamp', 'lccn', 'lcc', 'ddc', 'pubdate',
- 'rights', 'publication_type', 'uuid', 'tag_order',
+ 'rights', 'publication_type', 'uuid'
):
prints(x, getattr(self, x, 'None'))
@@ -278,7 +278,7 @@ class MetaInformation(object):
'isbn', 'application_id', 'manifest', 'spine', 'toc',
'cover', 'language', 'guide', 'book_producer',
'timestamp', 'lccn', 'lcc', 'ddc', 'pubdate', 'rights',
- 'publication_type', 'uuid', 'tag_order'):
+ 'publication_type', 'uuid'):
if hasattr(mi, attr):
val = getattr(mi, attr)
if val is not None:
diff --git a/src/calibre/gui2/library/models.py b/src/calibre/gui2/library/models.py
index 66ebc4dc53..cb911d4106 100644
--- a/src/calibre/gui2/library/models.py
+++ b/src/calibre/gui2/library/models.py
@@ -343,9 +343,6 @@ class BooksModel(QAbstractTableModel): # {{{
ans = []
for id in ids:
mi = self.db.get_metadata(id, index_is_id=True, get_cover=True)
- if mi.series is not None:
- mi.tag_order = { mi.series: self.db.books_in_series_of(id,
- index_is_id=True)}
ans.append(mi)
return ans
From 02c3d52c3ea94969f2ec036d668a849f67abaa58 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Thu, 20 May 2010 00:24:41 -0600
Subject: [PATCH 164/324] Fix device collections column not being updated when
sending book to SONY
---
src/calibre/devices/prs505/sony_cache.py | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
diff --git a/src/calibre/devices/prs505/sony_cache.py b/src/calibre/devices/prs505/sony_cache.py
index ec4c263cf9..262d1a3f64 100644
--- a/src/calibre/devices/prs505/sony_cache.py
+++ b/src/calibre/devices/prs505/sony_cache.py
@@ -266,6 +266,8 @@ class XMLCache(object):
def update_booklist(self, bl, bl_index):
if bl_index not in self.record_roots:
return
+ if DEBUG:
+ prints('Updating JSON cache:', bl_index)
root = self.record_roots[bl_index]
pmap = self.get_playlist_map()[bl_index]
playlist_map = {}
@@ -315,7 +317,7 @@ class XMLCache(object):
for i, booklist in booklists.items():
if DEBUG:
- prints('Updating booklist:', i)
+ prints('Updating XML Cache:', i)
root = self.record_roots[i]
for book in booklist:
path = os.path.join(self.prefixes[i], *(book.lpath.split('/')))
@@ -329,6 +331,10 @@ class XMLCache(object):
self.fix_ids()
+ # This is needed to update device_collections
+ for i, booklist in booklists.items():
+ self.update_booklist(booklist, i)
+
def update_playlists(self, bl_index, root, booklist, playlist_map,
collections_attributes):
collections = booklist.get_collections(collections_attributes)
From 23b67eb3b10c13e44ce771ed9c89a25024de29b0 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Thu, 20 May 2010 01:49:15 -0600
Subject: [PATCH 165/324] SONY driver: Set the titleSorter attribute in the XML
cache
---
src/calibre/devices/prs505/sony_cache.py | 9 +++++++--
1 file changed, 7 insertions(+), 2 deletions(-)
diff --git a/src/calibre/devices/prs505/sony_cache.py b/src/calibre/devices/prs505/sony_cache.py
index 262d1a3f64..5d4cf1d10a 100644
--- a/src/calibre/devices/prs505/sony_cache.py
+++ b/src/calibre/devices/prs505/sony_cache.py
@@ -16,7 +16,8 @@ from calibre import prints, guess_type
from calibre.devices.errors import DeviceError
from calibre.constants import DEBUG
from calibre.ebooks.chardet import xml_to_unicode
-from calibre.ebooks.metadata import string_to_authors, authors_to_string
+from calibre.ebooks.metadata import string_to_authors, authors_to_string, \
+ title_sort
# Utility functions {{{
EMPTY_CARD_CACHE = '''\
@@ -344,7 +345,7 @@ class XMLCache(object):
# *should* never happen
if DEBUG and None in records:
prints('WARNING: Some elements in the JSON cache were not'
- 'found in the XML cache')
+ ' found in the XML cache')
records = [x for x in records if x is not None]
for rec in records:
if rec.get('id', None) is None:
@@ -413,6 +414,10 @@ class XMLCache(object):
record.set('date', date)
record.set('size', str(os.stat(path).st_size))
record.set('title', book.title)
+ ts = book.title_sort
+ if not ts:
+ ts = title_sort(book.title)
+ record.set('titleSorter', ts)
record.set('author', authors_to_string(book.authors))
ext = os.path.splitext(path)[1]
if ext:
From e7c666d60fe4b1c9907f9f142c72f0e1042acf57 Mon Sep 17 00:00:00 2001
From: Charles Haley <>
Date: Thu, 20 May 2010 10:57:31 +0100
Subject: [PATCH 166/324] Suggested changes after working with new sony driver
---
src/calibre/devices/prs505/sony_cache.py | 9 ++++++---
src/calibre/gui2/ui.py | 13 +++++++------
2 files changed, 13 insertions(+), 9 deletions(-)
diff --git a/src/calibre/devices/prs505/sony_cache.py b/src/calibre/devices/prs505/sony_cache.py
index 262d1a3f64..f4d1889d64 100644
--- a/src/calibre/devices/prs505/sony_cache.py
+++ b/src/calibre/devices/prs505/sony_cache.py
@@ -325,9 +325,9 @@ class XMLCache(object):
if record is None:
record = self.create_text_record(root, i, book.lpath)
self.update_text_record(record, book, path, i)
- bl_pmap = playlist_map[i]
- self.update_playlists(i, root, booklist, bl_pmap,
- collections_attributes)
+ bl_pmap = playlist_map[i]
+ self.update_playlists(i, root, booklist, bl_pmap,
+ collections_attributes)
self.fix_ids()
@@ -339,6 +339,9 @@ class XMLCache(object):
collections_attributes):
collections = booklist.get_collections(collections_attributes)
for category, books in collections.items():
+ for b in books:
+ if self.book_by_lpath(b.lpath, root) is None:
+ print b.lpath
records = [self.book_by_lpath(b.lpath, root) for b in books]
# Remove any books that were not found, although this
# *should* never happen
diff --git a/src/calibre/gui2/ui.py b/src/calibre/gui2/ui.py
index 3f67e4184c..8f30e5a9c4 100644
--- a/src/calibre/gui2/ui.py
+++ b/src/calibre/gui2/ui.py
@@ -636,19 +636,20 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
self.download_scheduled_recipe, Qt.QueuedConnection)
self.library_view.verticalHeader().sectionClicked.connect(self.view_specific_book)
+ if self.library_view.model().rowCount(None) > 1:
+ self.library_view.resizeRowToContents(0)
+ height = self.library_view.rowHeight(0)
+ else:
+ height = None
for view in ('library', 'memory', 'card_a', 'card_b'):
view = getattr(self, view+'_view')
view.verticalHeader().sectionDoubleClicked.connect(self.view_specific_book)
+ if height is not None:
+ view.verticalHeader().setDefaultSectionSize(height)
self.location_view.setCurrentIndex(self.location_view.model().index(0))
-
self._add_filesystem_book = Dispatcher(self.__add_filesystem_book)
- v = self.library_view
- if v.model().rowCount(None) > 1:
- v.resizeRowToContents(0)
- height = v.rowHeight(0)
- self.library_view.verticalHeader().setDefaultSectionSize(height)
self.keyboard_interrupt.connect(self.quit, type=Qt.QueuedConnection)
def do_edit_categories(self):
From 210d81c626e13370f3a474a3a9851625703a08a2 Mon Sep 17 00:00:00 2001
From: Charles Haley <>
Date: Thu, 20 May 2010 12:54:06 +0100
Subject: [PATCH 167/324] Add internal date formatter
---
src/calibre/gui2/library/delegates.py | 8 ++++----
src/calibre/utils/date.py | 25 +++++++++++++++++++++++++
2 files changed, 29 insertions(+), 4 deletions(-)
diff --git a/src/calibre/gui2/library/delegates.py b/src/calibre/gui2/library/delegates.py
index c1e4915db1..d908ed01b4 100644
--- a/src/calibre/gui2/library/delegates.py
+++ b/src/calibre/gui2/library/delegates.py
@@ -17,7 +17,7 @@ from PyQt4.Qt import QColor, Qt, QModelIndex, QSize, \
from calibre.gui2 import UNDEFINED_QDATE
from calibre.gui2.widgets import EnLineEdit, TagsLineEdit
-from calibre.utils.date import now
+from calibre.utils.date import now, format_date
from calibre.utils.config import tweaks
from calibre.gui2.dialogs.comments_dialog import CommentsDialog
@@ -98,7 +98,7 @@ class DateDelegate(QStyledItemDelegate): # {{{
d = val.toDate()
if d == UNDEFINED_QDATE:
return ''
- return d.toString('dd MMM yyyy')
+ return format_date(d.toPyDate(), 'dd MMM yyyy')
def createEditor(self, parent, option, index):
qde = QStyledItemDelegate.createEditor(self, parent, option, index)
@@ -121,7 +121,7 @@ class PubDateDelegate(QStyledItemDelegate): # {{{
format = tweaks['gui_pubdate_display_format']
if format is None:
format = 'MMM yyyy'
- return d.toString(format)
+ return format_date(d.toPyDate(), format)
def createEditor(self, parent, option, index):
qde = QStyledItemDelegate.createEditor(self, parent, option, index)
@@ -195,7 +195,7 @@ class CcDateDelegate(QStyledItemDelegate): # {{{
d = val.toDate()
if d == UNDEFINED_QDATE:
return ''
- return d.toString(self.format)
+ return format_date(d.toPyDate(), self.format)
def createEditor(self, parent, option, index):
qde = QStyledItemDelegate.createEditor(self, parent, option, index)
diff --git a/src/calibre/utils/date.py b/src/calibre/utils/date.py
index a43927c9c5..dc84e6acf4 100644
--- a/src/calibre/utils/date.py
+++ b/src/calibre/utils/date.py
@@ -6,6 +6,7 @@ __license__ = 'GPL v3'
__copyright__ = '2010, Kovid Goyal '
__docformat__ = 'restructuredtext en'
+import re
from datetime import datetime
from dateutil.parser import parse
@@ -113,3 +114,27 @@ def utcnow():
def utcfromtimestamp(stamp):
return datetime.utcfromtimestamp(stamp).replace(tzinfo=_utc_tz)
+
+def format_date(dt, format):
+ ''' Return a date formatted as a string using a subset of Qt's formatting codes '''
+ def format_day(mo):
+ l = len(mo.group(0))
+ if l == 1: return '%d'%dt.day
+ if l == 2: return '%02d'%dt.day
+ if l == 3: return dt.strftime('%a')
+ return dt.strftime('%A')
+
+ def format_month(mo):
+ l = len(mo.group(0))
+ if l == 1: return '%d'%dt.month
+ if l == 2: return '%02d'%dt.month
+ if l == 3: return dt.strftime('%b')
+ return dt.strftime('%B')
+
+ def format_year(mo):
+ if len(mo.group(0)) == 2: return '%02d'%(dt.year % 100)
+ return '%04d'%dt.year
+
+ format = re.sub('d{1,4}', format_day, format)
+ format = re.sub('M{1,4}', format_month, format)
+ return re.sub('yyyy|yy', format_year, format)
From d3ef29463fcea27377675c3c32e56421050ac0a0 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Thu, 20 May 2010 08:59:57 -0600
Subject: [PATCH 168/324] Make prints more robust
---
src/calibre/__init__.py | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/src/calibre/__init__.py b/src/calibre/__init__.py
index 737fa0b383..e44f8d8ec6 100644
--- a/src/calibre/__init__.py
+++ b/src/calibre/__init__.py
@@ -141,7 +141,10 @@ def prints(*args, **kwargs):
raise
arg = repr(arg)
- file.write(arg)
+ try:
+ file.write(arg)
+ except:
+ file.write(repr(arg))
if i != len(args)-1:
file.write(sep)
file.write(end)
From a28c63dc1ff86348d216364c50639446534124e1 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Thu, 20 May 2010 09:22:05 -0600
Subject: [PATCH 169/324] Remove text alignment from rating/bool type columns
---
src/calibre/gui2/library/views.py | 26 +++++++++++++++-----------
1 file changed, 15 insertions(+), 11 deletions(-)
diff --git a/src/calibre/gui2/library/views.py b/src/calibre/gui2/library/views.py
index bae4950de0..d2c3839466 100644
--- a/src/calibre/gui2/library/views.py
+++ b/src/calibre/gui2/library/views.py
@@ -111,17 +111,21 @@ class BooksView(QTableView): # {{{
ac = a if self._model.sorted_on[1] == Qt.AscendingOrder else d
ac.setCheckable(True)
ac.setChecked(True)
- m = self.column_header_context_menu.addMenu(
- _('Change text alignment for %s') % name)
- al = self._model.alignment_map.get(col, 'left')
- for x, t in (('left', _('Left')), ('right', _('Right')), ('center',
- _('Center'))):
- a = m.addAction(t,
- partial(self.column_header_context_handler,
- action='align_'+x, column=col))
- if al == x:
- a.setCheckable(True)
- a.setChecked(True)
+ if col not in ('ondevice', 'rating', 'inlibrary') and \
+ (not self.model().is_custom_column(col) or \
+ self.model().custom_columns[col]['datatype'] not in ('bool',
+ 'rating')):
+ m = self.column_header_context_menu.addMenu(
+ _('Change text alignment for %s') % name)
+ al = self._model.alignment_map.get(col, 'left')
+ for x, t in (('left', _('Left')), ('right', _('Right')), ('center',
+ _('Center'))):
+ a = m.addAction(t,
+ partial(self.column_header_context_handler,
+ action='align_'+x, column=col))
+ if al == x:
+ a.setCheckable(True)
+ a.setChecked(True)
From 036c2fe68ce37c2a69a05bd51fea92046843a0f5 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Thu, 20 May 2010 10:28:41 -0600
Subject: [PATCH 170/324] Rationalize collections column in device view
---
src/calibre/devices/interface.py | 4 +--
src/calibre/devices/prs505/driver.py | 3 ++
src/calibre/devices/usbms/books.py | 13 ++++++--
src/calibre/gui2/library/models.py | 46 +++++++++++++++++++---------
src/calibre/gui2/library/views.py | 1 -
5 files changed, 47 insertions(+), 20 deletions(-)
diff --git a/src/calibre/devices/interface.py b/src/calibre/devices/interface.py
index f71585fad0..80c0b3d339 100644
--- a/src/calibre/devices/interface.py
+++ b/src/calibre/devices/interface.py
@@ -391,8 +391,8 @@ class BookList(list):
def __init__(self, oncard, prefix, settings):
pass
- def supports_tags(self):
- ''' Return True if the the device supports tags (collections) for this book list. '''
+ def supports_collections(self):
+ ''' Return True if the the device supports collections for this book list. '''
raise NotImplementedError()
def add_book(self, book, replace_metadata):
diff --git a/src/calibre/devices/prs505/driver.py b/src/calibre/devices/prs505/driver.py
index 74e1bf0a7e..734c49edbb 100644
--- a/src/calibre/devices/prs505/driver.py
+++ b/src/calibre/devices/prs505/driver.py
@@ -14,6 +14,7 @@ from calibre.devices.prs505 import MEDIA_XML
from calibre.devices.prs505 import CACHE_XML
from calibre.devices.prs505.sony_cache import XMLCache
from calibre import __appname__
+from calibre.devices.usbms.books import CollectionsBookList
class PRS505(USBMS):
@@ -23,6 +24,8 @@ class PRS505(USBMS):
author = 'Kovid Goyal'
supported_platforms = ['windows', 'osx', 'linux']
path_sep = '/'
+ booklist_class = CollectionsBookList
+
FORMATS = ['epub', 'lrf', 'lrx', 'rtf', 'pdf', 'txt']
diff --git a/src/calibre/devices/usbms/books.py b/src/calibre/devices/usbms/books.py
index 4de1341c41..6e8811432a 100644
--- a/src/calibre/devices/usbms/books.py
+++ b/src/calibre/devices/usbms/books.py
@@ -97,8 +97,8 @@ class Book(MetaInformation):
class BookList(_BookList):
- def supports_tags(self):
- return True
+ def supports_collections(self):
+ return False
def add_book(self, book, replace_metadata):
if book not in self:
@@ -109,6 +109,15 @@ class BookList(_BookList):
def remove_book(self, book):
self.remove(book)
+ def get_collections(self):
+ return {}
+
+
+class CollectionsBookList(BookList):
+
+ def supports_collections(self):
+ return True
+
def get_collections(self, collection_attributes):
collections = {}
series_categories = set([])
diff --git a/src/calibre/gui2/library/models.py b/src/calibre/gui2/library/models.py
index cb911d4106..2a5e009675 100644
--- a/src/calibre/gui2/library/models.py
+++ b/src/calibre/gui2/library/models.py
@@ -729,6 +729,17 @@ class BooksModel(QAbstractTableModel): # {{{
class OnDeviceSearch(SearchQueryParser): # {{{
+ DEFAULT_LOCATIONS = [
+ 'collections',
+ 'title',
+ 'author',
+ 'format',
+ 'search',
+ 'date',
+ 'all',
+ ]
+
+
def __init__(self, model):
SearchQueryParser.__init__(self)
self.model = model
@@ -738,6 +749,8 @@ class OnDeviceSearch(SearchQueryParser): # {{{
def get_matches(self, location, query):
location = location.lower().strip()
+ if location == 'authors':
+ location = 'author'
matchkind = CONTAINS_MATCH
if len(query) > 1:
@@ -752,14 +765,15 @@ class OnDeviceSearch(SearchQueryParser): # {{{
if matchkind != REGEXP_MATCH: ### leave case in regexps because it can be significant e.g. \S \W \D
query = query.lower()
- if location not in ('title', 'author', 'tag', 'all', 'format'):
+ if location not in self.DEFAULT_LOCATIONS:
return set([])
matches = set([])
- locations = ['title', 'author', 'tag', 'format'] if location == 'all' else [location]
+ all_locs = set(self.DEFAULT_LOCATIONS) - set(['all'])
+ locations = all_locs if location == 'all' else [location]
q = {
'title' : lambda x : getattr(x, 'title').lower(),
'author': lambda x: ' & '.join(getattr(x, 'authors')).lower(),
- 'tag':lambda x: ','.join(getattr(x, 'tags')).lower(),
+ 'collections':lambda x: ','.join(getattr(x, 'device_collections')).lower(),
'format':lambda x: os.path.splitext(x.path)[1].lower()
}
for index, row in enumerate(self.model.db):
@@ -774,7 +788,7 @@ class OnDeviceSearch(SearchQueryParser): # {{{
else:
m = matchkind
- if locvalue == 'tag':
+ if locvalue == 'collections':
vals = accessor(row).split(',')
else:
vals = [accessor(row)]
@@ -800,14 +814,14 @@ class DeviceBooksModel(BooksModel): # {{{
self.sort_history = [self.sorted_on]
self.unknown = _('Unknown')
self.column_map = ['inlibrary', 'title', 'authors', 'timestamp', 'size',
- 'tags']
+ 'collections']
self.headers = {
- 'inlibrary' : _('In Library'),
- 'title' : _('Title'),
- 'authors' : _('Author(s)'),
- 'timestamp' : _('Date'),
- 'size' : _('Size'),
- 'tags' : _('Collections')
+ 'inlibrary' : _('In Library'),
+ 'title' : _('Title'),
+ 'authors' : _('Author(s)'),
+ 'timestamp' : _('Date'),
+ 'size' : _('Size'),
+ 'collections' : _('Collections')
}
self.marked_for_deletion = {}
self.search_engine = OnDeviceSearch(self)
@@ -846,7 +860,8 @@ class DeviceBooksModel(BooksModel): # {{{
flags = QAbstractTableModel.flags(self, index)
if index.isValid() and self.editable:
cname = self.column_map[index.column()]
- if cname in ('title', 'authors') or (cname == 'tags' and self.db.supports_tags()):
+ if cname in ('title', 'authors') or (cname == 'collection' and \
+ self.db.supports_collections()):
flags |= Qt.ItemIsEditable
return flags
@@ -918,7 +933,7 @@ class DeviceBooksModel(BooksModel): # {{{
'authors' : authorcmp,
'size' : sizecmp,
'timestamp': datecmp,
- 'tags': tagscmp,
+ 'collections': tagscmp,
'inlibrary': libcmp,
}[cname]
self.map.sort(cmp=fcmp, reverse=descending)
@@ -1000,14 +1015,15 @@ class DeviceBooksModel(BooksModel): # {{{
dt = self.db[self.map[row]].datetime
dt = dt_factory(dt, assume_utc=True, as_utc=False)
return QVariant(strftime(TIME_FMT, dt.timetuple()))
- elif cname == 'tags':
+ elif cname == 'collections':
tags = self.db[self.map[row]].device_collections
if tags:
return QVariant(', '.join(tags))
elif role == Qt.ToolTipRole and index.isValid():
if self.map[row] in self.indices_to_be_deleted():
return QVariant(_('Marked for deletion'))
- if cname in ['title', 'authors'] or (cname == 'tags' and self.db.supports_tags()):
+ if cname in ['title', 'authors'] or (cname == 'collections' and \
+ self.db.supports_collections()):
return QVariant(_("Double click to edit me
"))
elif role == Qt.DecorationRole and cname == 'inlibrary':
if self.db[self.map[row]].in_library:
diff --git a/src/calibre/gui2/library/views.py b/src/calibre/gui2/library/views.py
index d2c3839466..7f6edd1b3d 100644
--- a/src/calibre/gui2/library/views.py
+++ b/src/calibre/gui2/library/views.py
@@ -222,7 +222,6 @@ class BooksView(QTableView): # {{{
return
for col, order in reversed(self.cleanup_sort_history(saved_history)[:3]):
self.sortByColumn(self.column_map.index(col), order)
- #self.model().sort_history = saved_history
def apply_state(self, state):
h = self.column_header
From 00f4a0f4ebd25ba1f9f281f3d92091446ac7dbba Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Thu, 20 May 2010 10:33:10 -0600
Subject: [PATCH 171/324] Change USBMS default CAN_SET_METADATA to False
---
src/calibre/devices/prs505/driver.py | 1 +
src/calibre/devices/usbms/driver.py | 2 +-
2 files changed, 2 insertions(+), 1 deletion(-)
diff --git a/src/calibre/devices/prs505/driver.py b/src/calibre/devices/prs505/driver.py
index 734c49edbb..bd06d2d7e1 100644
--- a/src/calibre/devices/prs505/driver.py
+++ b/src/calibre/devices/prs505/driver.py
@@ -28,6 +28,7 @@ class PRS505(USBMS):
FORMATS = ['epub', 'lrf', 'lrx', 'rtf', 'pdf', 'txt']
+ CAN_SET_METADATA = True
VENDOR_ID = [0x054c] #: SONY Vendor Id
PRODUCT_ID = [0x031e]
diff --git a/src/calibre/devices/usbms/driver.py b/src/calibre/devices/usbms/driver.py
index 76996481a5..97c212775a 100644
--- a/src/calibre/devices/usbms/driver.py
+++ b/src/calibre/devices/usbms/driver.py
@@ -37,7 +37,7 @@ class USBMS(CLI, Device):
book_class = Book
FORMATS = []
- CAN_SET_METADATA = True
+ CAN_SET_METADATA = False
METADATA_CACHE = 'metadata.calibre'
def get_device_information(self, end_session=True):
From 5ae9181f2bbb643e476e79bc977c855a5c1a99e3 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Thu, 20 May 2010 10:47:18 -0600
Subject: [PATCH 172/324] Do not allow the user to eject the device from
calibre if there are device jobs running
---
src/calibre/gui2/device.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/calibre/gui2/device.py b/src/calibre/gui2/device.py
index 37f1d9e513..19d0c5f068 100644
--- a/src/calibre/gui2/device.py
+++ b/src/calibre/gui2/device.py
@@ -161,7 +161,7 @@ class DeviceManager(Thread):
print 'Device connect failed again, giving up'
def umount_device(self, *args):
- if self.is_device_connected:
+ if self.is_device_connected and not self.job_manager.has_device_jobs():
self.connected_device.eject()
self.ejected_devices.add(self.connected_device)
self.connected_slot(False, self.connected_device_is_folder)
From cbd83766c3750ea1117629f5f7a107517529cb5b Mon Sep 17 00:00:00 2001
From: Charles Haley <>
Date: Thu, 20 May 2010 18:04:24 +0100
Subject: [PATCH 173/324] Remove changing author in sony_cache.py Add more
metadata in device view
---
src/calibre/devices/prs505/sony_cache.py | 18 +++++++++--------
src/calibre/gui2/library/models.py | 25 ++++++++++++++++++++----
src/calibre/gui2/status.py | 7 ++++---
3 files changed, 35 insertions(+), 15 deletions(-)
diff --git a/src/calibre/devices/prs505/sony_cache.py b/src/calibre/devices/prs505/sony_cache.py
index a0057d8ec9..64f82e2b76 100644
--- a/src/calibre/devices/prs505/sony_cache.py
+++ b/src/calibre/devices/prs505/sony_cache.py
@@ -288,14 +288,16 @@ class XMLCache(object):
if DEBUG:
prints('Renaming title', book.title, 'to', title)
book.title = title
- authors = record.get('author', None)
- if authors is not None:
- authors = string_to_authors(authors)
- if authors != book.authors:
- if DEBUG:
- prints('Renaming authors', book.authors, 'to',
- authors)
- book.authors = authors
+# We shouldn't do this for Sonys, because the reader strips
+# all but the first author.
+# authors = record.get('author', None)
+# if authors is not None:
+# authors = string_to_authors(authors)
+# if authors != book.authors:
+# if DEBUG:
+# prints('Renaming authors', book.authors, 'to',
+# authors)
+# book.authors = authors
for thumbnail in record.xpath(
'descendant::*[local-name()="thumbnail"]'):
for img in thumbnail.xpath(
diff --git a/src/calibre/gui2/library/models.py b/src/calibre/gui2/library/models.py
index cb911d4106..b8fcf235e6 100644
--- a/src/calibre/gui2/library/models.py
+++ b/src/calibre/gui2/library/models.py
@@ -800,14 +800,14 @@ class DeviceBooksModel(BooksModel): # {{{
self.sort_history = [self.sorted_on]
self.unknown = _('Unknown')
self.column_map = ['inlibrary', 'title', 'authors', 'timestamp', 'size',
- 'tags']
+ 'collections']
self.headers = {
'inlibrary' : _('In Library'),
'title' : _('Title'),
'authors' : _('Author(s)'),
'timestamp' : _('Date'),
'size' : _('Size'),
- 'tags' : _('Collections')
+ 'collections': _('Collections')
}
self.marked_for_deletion = {}
self.search_engine = OnDeviceSearch(self)
@@ -968,6 +968,23 @@ class DeviceBooksModel(BooksModel): # {{{
dt = dt_factory(item.datetime, assume_utc=True)
data[_('Timestamp')] = isoformat(dt, sep=' ', as_utc=False)
data[_('Collections')] = ', '.join(item.device_collections)
+
+ tags = getattr(item, 'tags', None)
+ if tags:
+ tags = ', '.join(tags)
+ else:
+ tags = _('None')
+ data[_('Tags')] = tags
+ comments = getattr(item, 'comments', None)
+ if not comments:
+ comments = _('None')
+ data[_('Comments')] = comments
+ series = getattr(item, 'series', None)
+ if series:
+ sidx = getattr(item, 'series_index', 0)
+ sidx = fmt_sidx(sidx, use_roman = self.use_roman_numbers)
+ data[_('Series')] = _('Book %s of %s.')%(sidx, series)
+
self.new_bookdisplay_data.emit(data)
def paths(self, rows):
@@ -1000,7 +1017,7 @@ class DeviceBooksModel(BooksModel): # {{{
dt = self.db[self.map[row]].datetime
dt = dt_factory(dt, assume_utc=True, as_utc=False)
return QVariant(strftime(TIME_FMT, dt.timetuple()))
- elif cname == 'tags':
+ elif cname == 'collections':
tags = self.db[self.map[row]].device_collections
if tags:
return QVariant(', '.join(tags))
@@ -1045,7 +1062,7 @@ class DeviceBooksModel(BooksModel): # {{{
self.db[idx].title_sorter = val
elif cname == 'authors':
self.db[idx].authors = string_to_authors(val)
- elif cname == 'tags':
+ elif cname == 'collections':
tags = [i.strip() for i in val.split(',')]
tags = [t for t in tags if t]
self.db[idx].device_collections = tags
diff --git a/src/calibre/gui2/status.py b/src/calibre/gui2/status.py
index f7bafacf8b..2759c4074b 100644
--- a/src/calibre/gui2/status.py
+++ b/src/calibre/gui2/status.py
@@ -101,9 +101,10 @@ class BookInfoDisplay(QWidget):
WEIGHTS = collections.defaultdict(lambda : 100)
WEIGHTS[_('Path')] = 0
WEIGHTS[_('Formats')] = 1
- WEIGHTS[_('Comments')] = 4
- WEIGHTS[_('Series')] = 2
- WEIGHTS[_('Tags')] = 3
+ WEIGHTS[_('Collections')] = 2
+ WEIGHTS[_('Series')] = 3
+ WEIGHTS[_('Tags')] = 4
+ WEIGHTS[_('Comments')] = 5
show_book_info = pyqtSignal()
From 10b7714cc4aae8145e150bafaf70bdc3e03457f5 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Thu, 20 May 2010 13:49:07 -0600
Subject: [PATCH 174/324] When adding books to the GUI, do not randomly
generate a UUID as application_id as this breaks direct adding to device
---
src/calibre/ebooks/metadata/worker.py | 2 ++
src/calibre/gui2/add.py | 2 ++
2 files changed, 4 insertions(+)
diff --git a/src/calibre/ebooks/metadata/worker.py b/src/calibre/ebooks/metadata/worker.py
index 178174a0d7..909eef05c2 100644
--- a/src/calibre/ebooks/metadata/worker.py
+++ b/src/calibre/ebooks/metadata/worker.py
@@ -35,6 +35,8 @@ def read_metadata_(task, tdir, notification=lambda x,y:x):
if mi.cover_data:
cdata = mi.cover_data[-1]
mi.cover_data = None
+ if not mi.application_id:
+ mi.application_id = '__calibre_dummy__'
with open(os.path.join(tdir, '%s.opf'%id), 'wb') as f:
f.write(metadata_to_opf(mi))
if cdata:
diff --git a/src/calibre/gui2/add.py b/src/calibre/gui2/add.py
index becf78e85f..131692a2c2 100644
--- a/src/calibre/gui2/add.py
+++ b/src/calibre/gui2/add.py
@@ -181,6 +181,8 @@ class DBAdder(Thread):
mi.title = os.path.splitext(name)[0]
mi.title = mi.title if isinstance(mi.title, unicode) else \
mi.title.decode(preferred_encoding, 'replace')
+ if mi.application_id == '__calibre_dummy__':
+ mi.application_id = None
if self.db is not None:
if cover:
cover = open(cover, 'rb').read()
From 497a02119adfa8d558e8ef8b1e4cc98b30d83443 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Thu, 20 May 2010 14:07:32 -0600
Subject: [PATCH 175/324] version 0.6.93
---
src/calibre/constants.py | 2 +-
src/calibre/devices/prs505/sony_cache.py | 3 +--
2 files changed, 2 insertions(+), 3 deletions(-)
diff --git a/src/calibre/constants.py b/src/calibre/constants.py
index 2617603e25..9c7291a273 100644
--- a/src/calibre/constants.py
+++ b/src/calibre/constants.py
@@ -2,7 +2,7 @@ __license__ = 'GPL v3'
__copyright__ = '2008, Kovid Goyal kovid@kovidgoyal.net'
__docformat__ = 'restructuredtext en'
__appname__ = 'calibre'
-__version__ = '0.6.92'
+__version__ = '0.6.93'
__author__ = "Kovid Goyal "
import re
diff --git a/src/calibre/devices/prs505/sony_cache.py b/src/calibre/devices/prs505/sony_cache.py
index 64f82e2b76..ecd0df2b37 100644
--- a/src/calibre/devices/prs505/sony_cache.py
+++ b/src/calibre/devices/prs505/sony_cache.py
@@ -16,8 +16,7 @@ from calibre import prints, guess_type
from calibre.devices.errors import DeviceError
from calibre.constants import DEBUG
from calibre.ebooks.chardet import xml_to_unicode
-from calibre.ebooks.metadata import string_to_authors, authors_to_string, \
- title_sort
+from calibre.ebooks.metadata import authors_to_string, title_sort
# Utility functions {{{
EMPTY_CARD_CACHE = '''\
From 57ab9f1ba027eceec12a60bfe7632eb0cd710fe9 Mon Sep 17 00:00:00 2001
From: Charles Haley <>
Date: Fri, 21 May 2010 11:30:05 +0100
Subject: [PATCH 176/324] sony driver: fix missing mime attribute when adding a
new book
---
src/calibre/devices/prs505/sony_cache.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/calibre/devices/prs505/sony_cache.py b/src/calibre/devices/prs505/sony_cache.py
index 64f82e2b76..f87bcf89d3 100644
--- a/src/calibre/devices/prs505/sony_cache.py
+++ b/src/calibre/devices/prs505/sony_cache.py
@@ -428,8 +428,8 @@ class XMLCache(object):
mime = MIME_MAP.get(ext, None)
if mime is None:
mime = guess_type('a.'+ext)[0]
- if mime is not None:
- record.set('mime', mime)
+ if mime is not None:
+ record.set('mime', mime)
if 'sourceid' not in record.attrib:
record.set('sourceid', '1')
if 'id' not in record.attrib:
From 330de1cc7be73f0ba69eba3362a36395b80249d0 Mon Sep 17 00:00:00 2001
From: Charles Haley <>
Date: Fri, 21 May 2010 12:02:18 +0100
Subject: [PATCH 177/324] Fix searching on devices to not fail when using
non-located searches (bare words).
---
src/calibre/gui2/library/models.py | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/src/calibre/gui2/library/models.py b/src/calibre/gui2/library/models.py
index e3dc5eed48..e234002a9c 100644
--- a/src/calibre/gui2/library/models.py
+++ b/src/calibre/gui2/library/models.py
@@ -734,8 +734,6 @@ class OnDeviceSearch(SearchQueryParser): # {{{
'title',
'author',
'format',
- 'search',
- 'date',
'all',
]
@@ -867,6 +865,7 @@ class DeviceBooksModel(BooksModel): # {{{
def search(self, text, refinement, reset=True):
+ traceback.print_stack()
if not text or not text.strip():
self.map = list(range(len(self.db)))
else:
From 76ceb08b58d8603707e2ee0aefea827b60497ef0 Mon Sep 17 00:00:00 2001
From: Charles Haley <>
Date: Fri, 21 May 2010 13:17:58 +0100
Subject: [PATCH 178/324] Get rid of traceback accidentally left in.
---
src/calibre/gui2/library/models.py | 1 -
1 file changed, 1 deletion(-)
diff --git a/src/calibre/gui2/library/models.py b/src/calibre/gui2/library/models.py
index e234002a9c..7bf679fa0c 100644
--- a/src/calibre/gui2/library/models.py
+++ b/src/calibre/gui2/library/models.py
@@ -865,7 +865,6 @@ class DeviceBooksModel(BooksModel): # {{{
def search(self, text, refinement, reset=True):
- traceback.print_stack()
if not text or not text.strip():
self.map = list(range(len(self.db)))
else:
From 0b3bc6d5d5b4d119b228522704af116bdd3f905b Mon Sep 17 00:00:00 2001
From: Charles Haley <>
Date: Fri, 21 May 2010 13:32:25 +0100
Subject: [PATCH 179/324] OndeviceSearch must permit all the locations that the
library permits or exceptions get raised. Changed to do that, but also limit
the locations that the search will actually use.
---
src/calibre/gui2/library/models.py | 9 +++++----
1 file changed, 5 insertions(+), 4 deletions(-)
diff --git a/src/calibre/gui2/library/models.py b/src/calibre/gui2/library/models.py
index 7bf679fa0c..6fbc0660f7 100644
--- a/src/calibre/gui2/library/models.py
+++ b/src/calibre/gui2/library/models.py
@@ -729,18 +729,19 @@ class BooksModel(QAbstractTableModel): # {{{
class OnDeviceSearch(SearchQueryParser): # {{{
- DEFAULT_LOCATIONS = [
+ USABLE_LOCATIONS = set([
'collections',
'title',
'author',
'format',
'all',
- ]
+ ])
def __init__(self, model):
SearchQueryParser.__init__(self)
self.model = model
+ self.DEFAULT_LOCATIONS = set(self.DEFAULT_LOCATIONS) | self.USABLE_LOCATIONS
def universal_set(self):
return set(range(0, len(self.model.db)))
@@ -763,10 +764,10 @@ class OnDeviceSearch(SearchQueryParser): # {{{
if matchkind != REGEXP_MATCH: ### leave case in regexps because it can be significant e.g. \S \W \D
query = query.lower()
- if location not in self.DEFAULT_LOCATIONS:
+ if location not in self.USABLE_LOCATIONS:
return set([])
matches = set([])
- all_locs = set(self.DEFAULT_LOCATIONS) - set(['all'])
+ all_locs = set(self.USABLE_LOCATIONS) - set(['all'])
locations = all_locs if location == 'all' else [location]
q = {
'title' : lambda x : getattr(x, 'title').lower(),
From db9d14ef774fdc2cfd2ca52a47275059be80901c Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Fri, 21 May 2010 09:07:14 -0600
Subject: [PATCH 180/324] version 0.6.94
---
src/calibre/constants.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/calibre/constants.py b/src/calibre/constants.py
index 9c7291a273..e61ea6bda3 100644
--- a/src/calibre/constants.py
+++ b/src/calibre/constants.py
@@ -2,7 +2,7 @@ __license__ = 'GPL v3'
__copyright__ = '2008, Kovid Goyal kovid@kovidgoyal.net'
__docformat__ = 'restructuredtext en'
__appname__ = 'calibre'
-__version__ = '0.6.93'
+__version__ = '0.6.94'
__author__ = "Kovid Goyal "
import re
From beb817b5cbd10ce4ed6b97d8c4af32b0b3aeea23 Mon Sep 17 00:00:00 2001
From: Charles Haley <>
Date: Fri, 21 May 2010 19:19:51 +0100
Subject: [PATCH 181/324] Build enough of custom_column metadata support to
implement reserved column names. Fix search to not go pink when the timer
expires, and to not be always pink for devices.
---
src/calibre/devices/metadata_serializer.py | 90 +++++++++++++++++++
src/calibre/devices/usbms/books.py | 25 +-----
src/calibre/devices/usbms/driver.py | 7 +-
.../dialogs/config/create_custom_column.py | 3 +
src/calibre/gui2/library/models.py | 2 +-
src/calibre/gui2/search_box.py | 5 +-
6 files changed, 104 insertions(+), 28 deletions(-)
create mode 100644 src/calibre/devices/metadata_serializer.py
diff --git a/src/calibre/devices/metadata_serializer.py b/src/calibre/devices/metadata_serializer.py
new file mode 100644
index 0000000000..651ba1d678
--- /dev/null
+++ b/src/calibre/devices/metadata_serializer.py
@@ -0,0 +1,90 @@
+'''
+Created on 21 May 2010
+
+@author: charles
+'''
+
+from calibre.constants import filesystem_encoding, preferred_encoding
+from calibre import isbytestring
+import json
+
+class MetadataSerializer(object):
+
+ SERIALIZED_ATTRS = [
+ 'lpath', 'title', 'authors', 'mime', 'size', 'tags', 'author_sort',
+ 'title_sort', 'comments', 'category', 'publisher', 'series',
+ 'series_index', 'rating', 'isbn', 'language', 'application_id',
+ 'book_producer', 'lccn', 'lcc', 'ddc', 'rights', 'publication_type',
+ 'uuid',
+ ]
+
+ def to_json(self):
+ json = {}
+ for attr in self.SERIALIZED_ATTRS:
+ val = getattr(self, attr)
+ if isbytestring(val):
+ enc = filesystem_encoding if attr == 'lpath' else preferred_encoding
+ val = val.decode(enc, 'replace')
+ elif isinstance(val, (list, tuple)):
+ val = [x.decode(preferred_encoding, 'replace') if
+ isbytestring(x) else x for x in val]
+ json[attr] = val
+ return json
+
+ def read_json(self, cache_file):
+ with open(cache_file, 'rb') as f:
+ js = json.load(f, encoding='utf-8')
+ return js
+
+ def write_json(self, js, cache_file):
+ with open(cache_file, 'wb') as f:
+ json.dump(js, f, indent=2, encoding='utf-8')
+
+ def string_to_value(self, string, col_metadata, column_label=None):
+ '''
+ if column_label is none, col_metadata must be a dict containing custom
+ column metadata for one column. If column_label is not none, then
+ col_metadata must be a dict of custom column metadata, with column
+ labels as keys. Metadata for standard columns is always assumed to be in
+ the col_metadata dict. If column_label is not standard and is not in
+ col_metadata, check if it matches a custom column. If so, use that
+ column metadata. See get_column_metadata below.
+ '''
+ pass
+
+ def value_to_display(self, value, col_metadata, column_label=None):
+ pass
+
+ def value_to_string (self, value, col_metadata, column_label=None):
+ pass
+
+ def get_column_metadata(self, column_label = None, from_book=None):
+ '''
+ if column_label is None, then from_book must not be None. Returns the
+ complete set of custom column metadata for that book.
+
+ If column_label is not None, return the column metadata for the given
+ column. This works even if the label is for a built-in column. If
+ from_book is None, then column_label must be a current custom column
+ label or a standard label. If from_book is not None, then the column
+ metadata from that metadata set is returned if it exists, otherwise the
+ standard metadata for that column is returned. If neither is found,
+ return {}
+ '''
+ pass
+
+ def get_custom_column_labels(self, book):
+ '''
+ returns a list of custom column attributes in the book metadata.
+ '''
+ pass
+
+ def get_standard_column_labels(self):
+ '''
+ returns a list of standard attributes that should be in any book's
+ metadata
+ '''
+ pass
+
+metadata_serializer = MetadataSerializer()
+
diff --git a/src/calibre/devices/usbms/books.py b/src/calibre/devices/usbms/books.py
index 6e8811432a..8d79981ad7 100644
--- a/src/calibre/devices/usbms/books.py
+++ b/src/calibre/devices/usbms/books.py
@@ -9,20 +9,14 @@ import os, re, time, sys
from calibre.ebooks.metadata import MetaInformation
from calibre.devices.mime import mime_type_ext
from calibre.devices.interface import BookList as _BookList
-from calibre.constants import filesystem_encoding, preferred_encoding
+from calibre.devices.metadata_serializer import MetadataSerializer
+from calibre.constants import preferred_encoding
from calibre import isbytestring
-class Book(MetaInformation):
+class Book(MetaInformation, MetadataSerializer):
BOOK_ATTRS = ['lpath', 'size', 'mime', 'device_collections']
- JSON_ATTRS = [
- 'lpath', 'title', 'authors', 'mime', 'size', 'tags', 'author_sort',
- 'title_sort', 'comments', 'category', 'publisher', 'series',
- 'series_index', 'rating', 'isbn', 'language', 'application_id',
- 'book_producer', 'lccn', 'lcc', 'ddc', 'rights', 'publication_type',
- 'uuid',
- ]
def __init__(self, prefix, lpath, size=None, other=None):
from calibre.ebooks.metadata.meta import path_to_ext
@@ -82,19 +76,6 @@ class Book(MetaInformation):
val = getattr(other, attr, None)
setattr(self, attr, val)
- def to_json(self):
- json = {}
- for attr in self.JSON_ATTRS:
- val = getattr(self, attr)
- if isbytestring(val):
- enc = filesystem_encoding if attr == 'lpath' else preferred_encoding
- val = val.decode(enc, 'replace')
- elif isinstance(val, (list, tuple)):
- val = [x.decode(preferred_encoding, 'replace') if
- isbytestring(x) else x for x in val]
- json[attr] = val
- return json
-
class BookList(_BookList):
def supports_collections(self):
diff --git a/src/calibre/devices/usbms/driver.py b/src/calibre/devices/usbms/driver.py
index 97c212775a..3c30827dbc 100644
--- a/src/calibre/devices/usbms/driver.py
+++ b/src/calibre/devices/usbms/driver.py
@@ -17,6 +17,7 @@ from itertools import cycle
from calibre import prints, isbytestring
from calibre.constants import filesystem_encoding
+from calibre.devices.metadata_serializer import metadata_serializer as ms
from calibre.devices.usbms.cli import CLI
from calibre.devices.usbms.device import Device
from calibre.devices.usbms.books import BookList, Book
@@ -260,8 +261,7 @@ class USBMS(CLI, Device):
os.makedirs(self.normalize_path(prefix))
js = [item.to_json() for item in booklists[listid] if
hasattr(item, 'to_json')]
- with open(self.normalize_path(os.path.join(prefix, self.METADATA_CACHE)), 'wb') as f:
- json.dump(js, f, indent=2, encoding='utf-8')
+ ms.write_json(js, self.normalize_path(os.path.join(prefix, self.METADATA_CACHE)))
write_prefix(self._main_prefix, 0)
write_prefix(self._card_a_prefix, 1)
write_prefix(self._card_b_prefix, 2)
@@ -293,8 +293,7 @@ class USBMS(CLI, Device):
cache_file = cls.normalize_path(os.path.join(prefix, name))
if os.access(cache_file, os.R_OK):
try:
- with open(cache_file, 'rb') as f:
- js = json.load(f, encoding='utf-8')
+ js = ms.read_json(cache_file)
for item in js:
book = cls.book_class(prefix, item.get('lpath', None))
for key in item.keys():
diff --git a/src/calibre/gui2/dialogs/config/create_custom_column.py b/src/calibre/gui2/dialogs/config/create_custom_column.py
index 5b470123a4..296a868fbf 100644
--- a/src/calibre/gui2/dialogs/config/create_custom_column.py
+++ b/src/calibre/gui2/dialogs/config/create_custom_column.py
@@ -8,6 +8,7 @@ from functools import partial
from PyQt4.QtCore import SIGNAL
from PyQt4.Qt import QDialog, Qt, QListWidgetItem, QVariant
+from calibre.devices.metadata_serializer import metadata_serializer
from calibre.gui2.dialogs.config.create_custom_column_ui import Ui_QCreateCustomColumn
from calibre.gui2 import error_dialog
@@ -102,6 +103,8 @@ class CreateCustomColumn(QDialog, Ui_QCreateCustomColumn):
return self.simple_error('', _('No lookup name was provided'))
if not col_heading:
return self.simple_error('', _('No column heading was provided'))
+ if col in metadata_serializer.SERIALIZED_ATTRS:
+ return self.simple_error('', _('The lookup name %s is reserved and cannot be used')%col)
bad_col = False
if col in self.parent.custcols:
if not self.editing_col or self.parent.custcols[col]['num'] != self.orig_column_number:
diff --git a/src/calibre/gui2/library/models.py b/src/calibre/gui2/library/models.py
index 0fc2c7f7ed..bc0367b766 100644
--- a/src/calibre/gui2/library/models.py
+++ b/src/calibre/gui2/library/models.py
@@ -883,7 +883,7 @@ class DeviceBooksModel(BooksModel): # {{{
self.reset()
self.last_search = text
if self.last_search:
- self.searched.emit(False)
+ self.searched.emit(True)
def sort(self, col, order, reset=True):
diff --git a/src/calibre/gui2/search_box.py b/src/calibre/gui2/search_box.py
index 230debd598..575f5563d6 100644
--- a/src/calibre/gui2/search_box.py
+++ b/src/calibre/gui2/search_box.py
@@ -136,12 +136,12 @@ class SearchBox2(QComboBox):
def text_edited_slot(self, text):
if self.as_you_type:
text = unicode(text)
- self.prev_text = text
self.timer = self.startTimer(self.__class__.INTERVAL)
def timerEvent(self, event):
self.killTimer(event.timerId())
if event.timerId() == self.timer:
+ self.timer = None
self.do_search()
@property
@@ -190,6 +190,9 @@ class SearchBox2(QComboBox):
def set_search_string(self, txt):
self.normalize_state()
self.setEditText(txt)
+ if self.timer is not None: # Turn off any timers that got started in setEditText
+ self.killTimer(self.timer)
+ self.timer = None
self.search.emit(txt, False)
self.line_edit.end(False)
self.initial_state = False
From d341d81cf8488ffe5f9bf3b6e786285fdeaa91aa Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Fri, 21 May 2010 20:23:48 -0600
Subject: [PATCH 182/324] Disallow custom column names
---
src/calibre/ebooks/metadata/book/__init__.py | 110 ++++++++++++++++++
src/calibre/ebooks/metadata/fetch.py | 3 +-
src/calibre/ebooks/metadata/odt.py | 0
.../dialogs/config/create_custom_column.py | 3 +
4 files changed, 114 insertions(+), 2 deletions(-)
create mode 100644 src/calibre/ebooks/metadata/book/__init__.py
mode change 100755 => 100644 src/calibre/ebooks/metadata/odt.py
diff --git a/src/calibre/ebooks/metadata/book/__init__.py b/src/calibre/ebooks/metadata/book/__init__.py
new file mode 100644
index 0000000000..76fe736f9c
--- /dev/null
+++ b/src/calibre/ebooks/metadata/book/__init__.py
@@ -0,0 +1,110 @@
+#!/usr/bin/env python
+# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai
+
+__license__ = 'GPL v3'
+__copyright__ = '2010, Kovid Goyal '
+__docformat__ = 'restructuredtext en'
+
+'''
+All fields must have a NULL value represented as None
+'''
+
+SOCIAL_METADATA_FIELDS = frozenset([
+ 'tags', # Ordered list
+ # A floating point number between 0 and 10
+ 'rating',
+ # A simple HTML enabled string
+ 'comments',
+ # A simple string
+ 'series',
+ # A floating point number
+ 'series_index',
+ # Of the form { scheme1:value1, scheme2:value2}
+ # For example: {'isbn':'123456789', 'doi':'xxxx', ... }
+ 'classifiers',
+ 'isbn', # Pseudo field for convenience, should get/set isbn classifier
+
+])
+
+PUBLICATION_METADATA_FIELDS = frozenset([
+ # title must never be None. Should be _('Unknown')
+ 'title',
+ # Pseudo field that can be set, but if not set is auto generated
+ # from title and languages
+ 'title_sort',
+ # Ordered list of authors. Must never be None, can be [_('Unknown')]
+ 'authors',
+ # Pseudo field that can be set, but if not set is auto generated
+ # from authors and languages
+ 'author_sort',
+ 'book_producer',
+ # Dates and times must be timezone aware
+ 'timestamp',
+ 'pubdate',
+ 'rights',
+ # So far only known publication type is periodical:calibre
+ # If None, means book
+ 'publication_type',
+ # A UUID usually of type 4
+ 'uuid',
+ 'languages', # ordered list
+ # Simple string, no special semantics
+ 'publisher',
+ # Absolute path to image file encoded in filesystem_encoding
+ 'cover',
+ # Of the form (format, data) where format is, for e.g. 'jpeg', 'png', 'gif'...
+ 'cover_data',
+ # Either thumbnail data, or an object with the attribute
+ # image_path which is the path to an image file, encoded
+ # in filesystem_encoding
+ 'thumbnail',
+ ])
+
+BOOK_STRUCTURE_FIELDS = frozenset([
+ # These are used by code
+ 'toc', 'spine', 'guide', 'manifest',
+ ])
+
+USER_METADATA_FIELDS = frozenset([
+ # A dict of a form to be specified
+ 'user_metadata',
+])
+
+DEVICE_METADATA_FIELDS = frozenset([
+ # Ordered list of strings
+ 'device_collections',
+ 'lpath', # Unicode, / separated
+ # In bytes
+ 'size',
+ # Mimetype of the book file being represented
+ 'mime',
+])
+
+CALIBRE_METADATA_FIELDS = frozenset([
+ # An application id
+ # Semantics to be defined. Is it a db key? a db name + key? A uuid?
+ 'application_id',
+ ]
+)
+
+RESERVED_METADATA_FIELDS = SOCIAL_METADATA_FIELDS.union(
+ PUBLICATION_METADATA_FIELDS).union(
+ BOOK_STRUCTURE_FIELDS).union(
+ USER_METADATA_FIELDS).union(
+ DEVICE_METADATA_FIELDS).union(
+ CALIBRE_METADATA_FIELDS)
+
+assert len(RESERVED_METADATA_FIELDS) == sum(map(len, (
+ SOCIAL_METADATA_FIELDS, PUBLICATION_METADATA_FIELDS,
+ BOOK_STRUCTURE_FIELDS, USER_METADATA_FIELDS,
+ DEVICE_METADATA_FIELDS, CALIBRE_METADATA_FIELDS,
+ )))
+
+SERIALIZABLE_FIELDS = SOCIAL_METADATA_FIELDS.union(
+ USER_METADATA_FIELDS).union(
+ PUBLICATION_METADATA_FIELDS).union(
+ CALIBRE_METADATA_FIELDS).union(
+ frozenset(['lpath'])) # I don't think we need device_collections
+
+# Serialization of covers/thumbnails will have to be handled carefully, maybe
+# as an option to the serializer class
diff --git a/src/calibre/ebooks/metadata/fetch.py b/src/calibre/ebooks/metadata/fetch.py
index 8907a0e34b..a7fd76c661 100644
--- a/src/calibre/ebooks/metadata/fetch.py
+++ b/src/calibre/ebooks/metadata/fetch.py
@@ -9,7 +9,6 @@ from threading import Thread
from calibre import prints
from calibre.utils.config import OptionParser
from calibre.utils.logging import default_log
-from calibre.ebooks.metadata import MetaInformation
from calibre.customize import Plugin
metadata_config = None
@@ -53,7 +52,7 @@ class MetadataSource(Plugin):
if self.results:
c = self.config_store().get(self.name, {})
res = self.results
- if isinstance(res, MetaInformation):
+ if hasattr(res, 'authors'):
res = [res]
for mi in res:
if not c.get('rating', True):
diff --git a/src/calibre/ebooks/metadata/odt.py b/src/calibre/ebooks/metadata/odt.py
old mode 100755
new mode 100644
diff --git a/src/calibre/gui2/dialogs/config/create_custom_column.py b/src/calibre/gui2/dialogs/config/create_custom_column.py
index 5b470123a4..ce06e33603 100644
--- a/src/calibre/gui2/dialogs/config/create_custom_column.py
+++ b/src/calibre/gui2/dialogs/config/create_custom_column.py
@@ -10,6 +10,7 @@ from PyQt4.Qt import QDialog, Qt, QListWidgetItem, QVariant
from calibre.gui2.dialogs.config.create_custom_column_ui import Ui_QCreateCustomColumn
from calibre.gui2 import error_dialog
+from calibre.metadata.book import RESERVED_METADATA_FIELDS
class CreateCustomColumn(QDialog, Ui_QCreateCustomColumn):
@@ -102,6 +103,8 @@ class CreateCustomColumn(QDialog, Ui_QCreateCustomColumn):
return self.simple_error('', _('No lookup name was provided'))
if not col_heading:
return self.simple_error('', _('No column heading was provided'))
+ if col in RESERVED_METADATA_FIELDS:
+ return self.simple_error('', _('The lookup name %s is reserved and cannot be used')%col)
bad_col = False
if col in self.parent.custcols:
if not self.editing_col or self.parent.custcols[col]['num'] != self.orig_column_number:
From 5b820fe42c4281cad928c4168e5eed3c46e55031 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Fri, 21 May 2010 20:25:50 -0600
Subject: [PATCH 183/324] ...
---
src/calibre/gui2/dialogs/config/create_custom_column.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/calibre/gui2/dialogs/config/create_custom_column.py b/src/calibre/gui2/dialogs/config/create_custom_column.py
index ce06e33603..b25968c8e5 100644
--- a/src/calibre/gui2/dialogs/config/create_custom_column.py
+++ b/src/calibre/gui2/dialogs/config/create_custom_column.py
@@ -10,7 +10,7 @@ from PyQt4.Qt import QDialog, Qt, QListWidgetItem, QVariant
from calibre.gui2.dialogs.config.create_custom_column_ui import Ui_QCreateCustomColumn
from calibre.gui2 import error_dialog
-from calibre.metadata.book import RESERVED_METADATA_FIELDS
+from calibre.ebooks.metadata.book import RESERVED_METADATA_FIELDS
class CreateCustomColumn(QDialog, Ui_QCreateCustomColumn):
From 9e110eeec8e1d0507dc5bd4f36c0105af9ebbb5b Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Fri, 21 May 2010 20:36:57 -0600
Subject: [PATCH 184/324] Fix bugs in searchbox as you type implementation
---
src/calibre/gui2/library/models.py | 2 +-
src/calibre/gui2/search_box.py | 6 ++++--
2 files changed, 5 insertions(+), 3 deletions(-)
diff --git a/src/calibre/gui2/library/models.py b/src/calibre/gui2/library/models.py
index 0fc2c7f7ed..bc0367b766 100644
--- a/src/calibre/gui2/library/models.py
+++ b/src/calibre/gui2/library/models.py
@@ -883,7 +883,7 @@ class DeviceBooksModel(BooksModel): # {{{
self.reset()
self.last_search = text
if self.last_search:
- self.searched.emit(False)
+ self.searched.emit(True)
def sort(self, col, order, reset=True):
diff --git a/src/calibre/gui2/search_box.py b/src/calibre/gui2/search_box.py
index 230debd598..8627802ef4 100644
--- a/src/calibre/gui2/search_box.py
+++ b/src/calibre/gui2/search_box.py
@@ -135,13 +135,12 @@ class SearchBox2(QComboBox):
def text_edited_slot(self, text):
if self.as_you_type:
- text = unicode(text)
- self.prev_text = text
self.timer = self.startTimer(self.__class__.INTERVAL)
def timerEvent(self, event):
self.killTimer(event.timerId())
if event.timerId() == self.timer:
+ self.timer = None
self.do_search()
@property
@@ -190,6 +189,9 @@ class SearchBox2(QComboBox):
def set_search_string(self, txt):
self.normalize_state()
self.setEditText(txt)
+ if self.timer is not None: # Turn off any timers that got started in setEditText
+ self.killTimer(self.timer)
+ self.timer = None
self.search.emit(txt, False)
self.line_edit.end(False)
self.initial_state = False
From 87fd8889a5fe698fabbb2194fb0a7d640902e978 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Fri, 21 May 2010 22:36:27 -0600
Subject: [PATCH 185/324] Framework for replacement of MetaInformation
---
src/calibre/ebooks/metadata/book/__init__.py | 5 +-
src/calibre/ebooks/metadata/book/base.py | 129 ++++++++++++++++++
.../dialogs/config/create_custom_column.py | 4 +-
3 files changed, 134 insertions(+), 4 deletions(-)
create mode 100644 src/calibre/ebooks/metadata/book/base.py
diff --git a/src/calibre/ebooks/metadata/book/__init__.py b/src/calibre/ebooks/metadata/book/__init__.py
index 76fe736f9c..9a44a36489 100644
--- a/src/calibre/ebooks/metadata/book/__init__.py
+++ b/src/calibre/ebooks/metadata/book/__init__.py
@@ -6,7 +6,8 @@ __copyright__ = '2010, Kovid Goyal '
__docformat__ = 'restructuredtext en'
'''
-All fields must have a NULL value represented as None
+All fields must have a NULL value represented as None for simple types,
+an empty list/dictionary for complex types and (None, None) for cover_data
'''
SOCIAL_METADATA_FIELDS = frozenset([
@@ -61,7 +62,7 @@ PUBLICATION_METADATA_FIELDS = frozenset([
])
BOOK_STRUCTURE_FIELDS = frozenset([
- # These are used by code
+ # These are used by code, Null values are None.
'toc', 'spine', 'guide', 'manifest',
])
diff --git a/src/calibre/ebooks/metadata/book/base.py b/src/calibre/ebooks/metadata/book/base.py
new file mode 100644
index 0000000000..bf653b38bb
--- /dev/null
+++ b/src/calibre/ebooks/metadata/book/base.py
@@ -0,0 +1,129 @@
+#!/usr/bin/env python
+# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai
+
+__license__ = 'GPL v3'
+__copyright__ = '2010, Kovid Goyal '
+__docformat__ = 'restructuredtext en'
+
+import copy
+
+from calibre.ebooks.metadata.book import RESERVED_METADATA_FIELDS
+
+NULL_VALUES = {
+ 'user_metadata': {},
+ 'cover_data' : (None, None),
+ 'tags' : [],
+ 'classifiers' : {},
+ 'languages' : [],
+ 'device_collections': [],
+ 'authors' : [_('Unknown')],
+ 'title' : _('Unknown'),
+}
+
+class Metadata(object):
+
+ '''
+ This class must expose a superset of the API of MetaInformation in terms
+ of attribute access and methods. Only the __init__ method is different.
+ MetaInformation will simply become a function that creates and fills in
+ the attributes of this class.
+
+ Please keep the method based API of this class to a minimum. Every method
+ becomes a reserved field name.
+ '''
+
+ def __init__(self):
+ object.__setattr__(self, '_data', copy.deepcopy(NULL_VALUES))
+
+ def __getattribute__(self, field):
+ _data = object.__getattribute__(self, '_data')
+ if field in RESERVED_METADATA_FIELDS:
+ return _data.get(field, None)
+ try:
+ return object.__getattribute__(self, field)
+ except AttributeError:
+ pass
+ if field in _data['user_metadata'].iterkeys():
+ # TODO: getting user metadata values
+ pass
+ raise AttributeError(
+ 'Metadata object has no attribute named: '+ repr(field))
+
+
+ def __setattr__(self, field, val):
+ _data = object.__getattribute__(self, '_data')
+ if field in RESERVED_METADATA_FIELDS:
+ if field != 'user_metadata':
+ if not val:
+ val = NULL_VALUES[field]
+ _data[field] = val
+ else:
+ raise AttributeError('You cannot set user_metadata directly.')
+ elif field in _data['user_metadata'].iterkeys():
+ # TODO: Setting custom column values
+ pass
+ else:
+ # You are allowed to stick arbitrary attributes onto this object as
+ # long as they dont conflict with global or user metadata names
+ # Don't abuse this privilege
+ self.__dict__[field] = val
+
+ @property
+ def reserved_names(self):
+ 'The set of names you cannot use for your own purposes on this object'
+ _data = object.__getattribute__(self, '_data')
+ return frozenset(RESERVED_FIELD_NAMES).union(frozenset(
+ _data['user_metadata'].iterkeys()))
+
+ @property
+ def user_metadata_names(self):
+ 'The set of user metadata names this object knows about'
+ _data = object.__getattribute__(self, '_data')
+ return frozenset(_data['user_metadata'].iterkeys())
+
+ # Old MetaInformation API {{{
+ def copy(self):
+ pass
+
+ def print_all_attributes(self):
+ pass
+
+ def smart_update(self, other):
+ pass
+
+ def format_series_index(self):
+ pass
+
+ def authors_from_string(self, raw):
+ pass
+
+ def format_authors(self):
+ pass
+
+ def format_tags(self):
+ pass
+
+ def format_rating(self):
+ return unicode(self.rating)
+
+ def __unicode__(self):
+ pass
+
+ def to_html(self):
+ pass
+
+ def __str__(self):
+ return self.__unicode__().encode('utf-8')
+
+ def __nonzero__(self):
+ return True
+
+ # }}}
+
+_m = Metadata()
+RESERVED_FIELD_NAMES = \
+ frozenset(_m.__dict__.iterkeys()).union( # _data
+ RESERVED_METADATA_FIELDS).union(
+ frozenset(Metadata.__dict__.iterkeys())) # methods defined in Metadata
+del _m
+
diff --git a/src/calibre/gui2/dialogs/config/create_custom_column.py b/src/calibre/gui2/dialogs/config/create_custom_column.py
index b25968c8e5..357e1e2ad8 100644
--- a/src/calibre/gui2/dialogs/config/create_custom_column.py
+++ b/src/calibre/gui2/dialogs/config/create_custom_column.py
@@ -10,7 +10,7 @@ from PyQt4.Qt import QDialog, Qt, QListWidgetItem, QVariant
from calibre.gui2.dialogs.config.create_custom_column_ui import Ui_QCreateCustomColumn
from calibre.gui2 import error_dialog
-from calibre.ebooks.metadata.book import RESERVED_METADATA_FIELDS
+from calibre.ebooks.metadata.book.base import RESERVED_FIELD_NAMES
class CreateCustomColumn(QDialog, Ui_QCreateCustomColumn):
@@ -103,7 +103,7 @@ class CreateCustomColumn(QDialog, Ui_QCreateCustomColumn):
return self.simple_error('', _('No lookup name was provided'))
if not col_heading:
return self.simple_error('', _('No column heading was provided'))
- if col in RESERVED_METADATA_FIELDS:
+ if col in RESERVED_FIELD_NAMES:
return self.simple_error('', _('The lookup name %s is reserved and cannot be used')%col)
bad_col = False
if col in self.parent.custcols:
From 7969c9ead533418dfced6e439dfd4eed53fc6868 Mon Sep 17 00:00:00 2001
From: Charles Haley <>
Date: Sat, 22 May 2010 08:50:22 +0100
Subject: [PATCH 186/324] Back out metadata/json changes I made
---
src/calibre/devices/metadata_serializer.py | 90 ----------------------
src/calibre/devices/usbms/books.py | 25 +++++-
src/calibre/devices/usbms/driver.py | 7 +-
3 files changed, 26 insertions(+), 96 deletions(-)
delete mode 100644 src/calibre/devices/metadata_serializer.py
diff --git a/src/calibre/devices/metadata_serializer.py b/src/calibre/devices/metadata_serializer.py
deleted file mode 100644
index 651ba1d678..0000000000
--- a/src/calibre/devices/metadata_serializer.py
+++ /dev/null
@@ -1,90 +0,0 @@
-'''
-Created on 21 May 2010
-
-@author: charles
-'''
-
-from calibre.constants import filesystem_encoding, preferred_encoding
-from calibre import isbytestring
-import json
-
-class MetadataSerializer(object):
-
- SERIALIZED_ATTRS = [
- 'lpath', 'title', 'authors', 'mime', 'size', 'tags', 'author_sort',
- 'title_sort', 'comments', 'category', 'publisher', 'series',
- 'series_index', 'rating', 'isbn', 'language', 'application_id',
- 'book_producer', 'lccn', 'lcc', 'ddc', 'rights', 'publication_type',
- 'uuid',
- ]
-
- def to_json(self):
- json = {}
- for attr in self.SERIALIZED_ATTRS:
- val = getattr(self, attr)
- if isbytestring(val):
- enc = filesystem_encoding if attr == 'lpath' else preferred_encoding
- val = val.decode(enc, 'replace')
- elif isinstance(val, (list, tuple)):
- val = [x.decode(preferred_encoding, 'replace') if
- isbytestring(x) else x for x in val]
- json[attr] = val
- return json
-
- def read_json(self, cache_file):
- with open(cache_file, 'rb') as f:
- js = json.load(f, encoding='utf-8')
- return js
-
- def write_json(self, js, cache_file):
- with open(cache_file, 'wb') as f:
- json.dump(js, f, indent=2, encoding='utf-8')
-
- def string_to_value(self, string, col_metadata, column_label=None):
- '''
- if column_label is none, col_metadata must be a dict containing custom
- column metadata for one column. If column_label is not none, then
- col_metadata must be a dict of custom column metadata, with column
- labels as keys. Metadata for standard columns is always assumed to be in
- the col_metadata dict. If column_label is not standard and is not in
- col_metadata, check if it matches a custom column. If so, use that
- column metadata. See get_column_metadata below.
- '''
- pass
-
- def value_to_display(self, value, col_metadata, column_label=None):
- pass
-
- def value_to_string (self, value, col_metadata, column_label=None):
- pass
-
- def get_column_metadata(self, column_label = None, from_book=None):
- '''
- if column_label is None, then from_book must not be None. Returns the
- complete set of custom column metadata for that book.
-
- If column_label is not None, return the column metadata for the given
- column. This works even if the label is for a built-in column. If
- from_book is None, then column_label must be a current custom column
- label or a standard label. If from_book is not None, then the column
- metadata from that metadata set is returned if it exists, otherwise the
- standard metadata for that column is returned. If neither is found,
- return {}
- '''
- pass
-
- def get_custom_column_labels(self, book):
- '''
- returns a list of custom column attributes in the book metadata.
- '''
- pass
-
- def get_standard_column_labels(self):
- '''
- returns a list of standard attributes that should be in any book's
- metadata
- '''
- pass
-
-metadata_serializer = MetadataSerializer()
-
diff --git a/src/calibre/devices/usbms/books.py b/src/calibre/devices/usbms/books.py
index 8d79981ad7..6e8811432a 100644
--- a/src/calibre/devices/usbms/books.py
+++ b/src/calibre/devices/usbms/books.py
@@ -9,14 +9,20 @@ import os, re, time, sys
from calibre.ebooks.metadata import MetaInformation
from calibre.devices.mime import mime_type_ext
from calibre.devices.interface import BookList as _BookList
-from calibre.devices.metadata_serializer import MetadataSerializer
-from calibre.constants import preferred_encoding
+from calibre.constants import filesystem_encoding, preferred_encoding
from calibre import isbytestring
-class Book(MetaInformation, MetadataSerializer):
+class Book(MetaInformation):
BOOK_ATTRS = ['lpath', 'size', 'mime', 'device_collections']
+ JSON_ATTRS = [
+ 'lpath', 'title', 'authors', 'mime', 'size', 'tags', 'author_sort',
+ 'title_sort', 'comments', 'category', 'publisher', 'series',
+ 'series_index', 'rating', 'isbn', 'language', 'application_id',
+ 'book_producer', 'lccn', 'lcc', 'ddc', 'rights', 'publication_type',
+ 'uuid',
+ ]
def __init__(self, prefix, lpath, size=None, other=None):
from calibre.ebooks.metadata.meta import path_to_ext
@@ -76,6 +82,19 @@ class Book(MetaInformation, MetadataSerializer):
val = getattr(other, attr, None)
setattr(self, attr, val)
+ def to_json(self):
+ json = {}
+ for attr in self.JSON_ATTRS:
+ val = getattr(self, attr)
+ if isbytestring(val):
+ enc = filesystem_encoding if attr == 'lpath' else preferred_encoding
+ val = val.decode(enc, 'replace')
+ elif isinstance(val, (list, tuple)):
+ val = [x.decode(preferred_encoding, 'replace') if
+ isbytestring(x) else x for x in val]
+ json[attr] = val
+ return json
+
class BookList(_BookList):
def supports_collections(self):
diff --git a/src/calibre/devices/usbms/driver.py b/src/calibre/devices/usbms/driver.py
index 3c30827dbc..97c212775a 100644
--- a/src/calibre/devices/usbms/driver.py
+++ b/src/calibre/devices/usbms/driver.py
@@ -17,7 +17,6 @@ from itertools import cycle
from calibre import prints, isbytestring
from calibre.constants import filesystem_encoding
-from calibre.devices.metadata_serializer import metadata_serializer as ms
from calibre.devices.usbms.cli import CLI
from calibre.devices.usbms.device import Device
from calibre.devices.usbms.books import BookList, Book
@@ -261,7 +260,8 @@ class USBMS(CLI, Device):
os.makedirs(self.normalize_path(prefix))
js = [item.to_json() for item in booklists[listid] if
hasattr(item, 'to_json')]
- ms.write_json(js, self.normalize_path(os.path.join(prefix, self.METADATA_CACHE)))
+ with open(self.normalize_path(os.path.join(prefix, self.METADATA_CACHE)), 'wb') as f:
+ json.dump(js, f, indent=2, encoding='utf-8')
write_prefix(self._main_prefix, 0)
write_prefix(self._card_a_prefix, 1)
write_prefix(self._card_b_prefix, 2)
@@ -293,7 +293,8 @@ class USBMS(CLI, Device):
cache_file = cls.normalize_path(os.path.join(prefix, name))
if os.access(cache_file, os.R_OK):
try:
- js = ms.read_json(cache_file)
+ with open(cache_file, 'rb') as f:
+ js = json.load(f, encoding='utf-8')
for item in js:
book = cls.book_class(prefix, item.get('lpath', None))
for key in item.keys():
From 8daa9ab683f5cb341994240c61fb56135d3ec878 Mon Sep 17 00:00:00 2001
From: GRiker
Date: Sat, 22 May 2010 06:22:01 -0600
Subject: [PATCH 187/324] GwR initial iTunes driver
---
src/calibre/devices/apple/__init__.py | 2 +
src/calibre/devices/apple/driver.py | 287 ++++++++++++++++++++++++++
2 files changed, 289 insertions(+)
create mode 100644 src/calibre/devices/apple/__init__.py
create mode 100644 src/calibre/devices/apple/driver.py
diff --git a/src/calibre/devices/apple/__init__.py b/src/calibre/devices/apple/__init__.py
new file mode 100644
index 0000000000..c705e32a66
--- /dev/null
+++ b/src/calibre/devices/apple/__init__.py
@@ -0,0 +1,2 @@
+__license__ = 'GPL v3'
+__copyright__ = '2008, Kovid Goyal '
\ No newline at end of file
diff --git a/src/calibre/devices/apple/driver.py b/src/calibre/devices/apple/driver.py
new file mode 100644
index 0000000000..ef5cf344a1
--- /dev/null
+++ b/src/calibre/devices/apple/driver.py
@@ -0,0 +1,287 @@
+'''
+ Device driver for iTunes
+
+ GRiker
+
+ 22 May 2010
+'''
+
+from calibre.devices.interface import DevicePlugin
+
+class iDevice(DevicePlugin):
+ name = 'Apple device interface'
+ gui_name = 'Apple device'
+ supported_platforms = ['windows','osx']
+ author = 'GRiker'
+
+ FORMATS = ['epub']
+
+ VENDOR_ID = [0x0830]
+ PRODUCT_ID = [0x8004, 0x8002, 0x0101]
+ BCD = [0x0316]
+
+ def is_usb_connected(self, device_on_system):
+ return True
+
+ def can_handle(self, device_info):
+ # Return True if iTunes installed
+
+ def can_handle_windows(self, device_id, debug=False):
+ '''
+ Optional method to perform further checks on a device to see if this driver
+ is capable of handling it. If it is not it should return False. This method
+ is only called after the vendor, product ids and the bcd have matched, so
+ it can do some relatively time intensive checks. The default implementation
+ returns True. This method is called only on windows. See also
+ :method:`can_handle`.
+
+ :param device_info: On windows a device ID string. On Unix a tuple of
+ ``(vendor_id, product_id, bcd)``.
+ '''
+ return True
+
+ def can_handle(self, device_info, debug=False):
+ '''
+ Unix version of :method:`can_handle_windows`
+
+ :param device_info: Is a tupe of (vid, pid, bcd, manufacturer, product,
+ serial number)
+ '''
+
+ return True
+
+ def open(self):
+ '''
+ Perform any device specific initialization. Called after the device is
+ detected but before any other functions that communicate with the device.
+ For example: For devices that present themselves as USB Mass storage
+ devices, this method would be responsible for mounting the device or
+ if the device has been automounted, for finding out where it has been
+ mounted. The base class within USBMS device.py has a implementation of
+ this function that should serve as a good example for USB Mass storage
+ devices.
+ '''
+ print "iDevice(): I am here!"
+
+ def eject(self):
+ '''
+ Un-mount / eject the device from the OS. This does not check if there
+ are pending GUI jobs that need to communicate with the device.
+ '''
+ raise NotImplementedError()
+
+ def post_yank_cleanup(self):
+ '''
+ Called if the user yanks the device without ejecting it first.
+ '''
+ raise NotImplementedError()
+
+ def set_progress_reporter(self, report_progress):
+ '''
+ @param report_progress: Function that is called with a % progress
+ (number between 0 and 100) for various tasks
+ If it is called with -1 that means that the
+ task does not have any progress information
+ '''
+ raise NotImplementedError()
+
+ def get_device_information(self, end_session=True):
+ """
+ Ask device for device information. See L{DeviceInfoQuery}.
+ @return: (device name, device version, software version on device, mime type)
+ """
+ raise NotImplementedError()
+
+ def card_prefix(self, end_session=True):
+ '''
+ Return a 2 element list of the prefix to paths on the cards.
+ If no card is present None is set for the card's prefix.
+ E.G.
+ ('/place', '/place2')
+ (None, 'place2')
+ ('place', None)
+ (None, None)
+ '''
+ raise NotImplementedError()
+
+ def total_space(self, end_session=True):
+ """
+ Get total space available on the mountpoints:
+ 1. Main memory
+ 2. Memory Card A
+ 3. Memory Card B
+
+ @return: A 3 element list with total space in bytes of (1, 2, 3). If a
+ particular device doesn't have any of these locations it should return 0.
+ """
+ raise NotImplementedError()
+
+ def free_space(self, end_session=True):
+ """
+ Get free space available on the mountpoints:
+ 1. Main memory
+ 2. Card A
+ 3. Card B
+
+ @return: A 3 element list with free space in bytes of (1, 2, 3). If a
+ particular device doesn't have any of these locations it should return -1.
+ """
+ raise NotImplementedError()
+
+ def books(self, oncard=None, end_session=True):
+ """
+ Return a list of ebooks on the device.
+ @param oncard: If 'carda' or 'cardb' return a list of ebooks on the
+ specific storage card, otherwise return list of ebooks
+ in main memory of device. If a card is specified and no
+ books are on the card return empty list.
+ @return: A BookList.
+ """
+ raise NotImplementedError()
+
+ def upload_books(self, files, names, on_card=None, end_session=True,
+ metadata=None):
+ '''
+ Upload a list of books to the device. If a file already
+ exists on the device, it should be replaced.
+ This method should raise a L{FreeSpaceError} if there is not enough
+ free space on the device. The text of the FreeSpaceError must contain the
+ word "card" if C{on_card} is not None otherwise it must contain the word "memory".
+ :files: A list of paths and/or file-like objects.
+ :names: A list of file names that the books should have
+ once uploaded to the device. len(names) == len(files)
+ :return: A list of 3-element tuples. The list is meant to be passed
+ to L{add_books_to_metadata}.
+ :metadata: If not None, it is a list of :class:`MetaInformation` objects.
+ The idea is to use the metadata to determine where on the device to
+ put the book. len(metadata) == len(files). Apart from the regular
+ cover (path to cover), there may also be a thumbnail attribute, which should
+ be used in preference. The thumbnail attribute is of the form
+ (width, height, cover_data as jpeg).
+ '''
+ raise NotImplementedError()
+
+ @classmethod
+ def add_books_to_metadata(cls, locations, metadata, booklists):
+ '''
+ Add locations to the booklists. This function must not communicate with
+ the device.
+ @param locations: Result of a call to L{upload_books}
+ @param metadata: List of MetaInformation objects, same as for
+ :method:`upload_books`.
+ @param booklists: A tuple containing the result of calls to
+ (L{books}(oncard=None), L{books}(oncard='carda'),
+ L{books}(oncard='cardb')).
+ '''
+ raise NotImplementedError
+
+ def delete_books(self, paths, end_session=True):
+ '''
+ Delete books at paths on device.
+ '''
+ raise NotImplementedError()
+
+ @classmethod
+ def remove_books_from_metadata(cls, paths, booklists):
+ '''
+ Remove books from the metadata list. This function must not communicate
+ with the device.
+ @param paths: paths to books on the device.
+ @param booklists: A tuple containing the result of calls to
+ (L{books}(oncard=None), L{books}(oncard='carda'),
+ L{books}(oncard='cardb')).
+ '''
+ raise NotImplementedError()
+
+ def sync_booklists(self, booklists, end_session=True):
+ '''
+ Update metadata on device.
+ @param booklists: A tuple containing the result of calls to
+ (L{books}(oncard=None), L{books}(oncard='carda'),
+ L{books}(oncard='cardb')).
+ '''
+ raise NotImplementedError()
+
+ def get_file(self, path, outfile, end_session=True):
+ '''
+ Read the file at C{path} on the device and write it to outfile.
+ @param outfile: file object like C{sys.stdout} or the result of an C{open} call
+ '''
+ raise NotImplementedError()
+
+ @classmethod
+ def config_widget(cls):
+ '''
+ Should return a QWidget. The QWidget contains the settings for the device interface
+ '''
+ raise NotImplementedError()
+
+ @classmethod
+ def save_settings(cls, settings_widget):
+ '''
+ Should save settings to disk. Takes the widget created in config_widget
+ and saves all settings to disk.
+ '''
+ raise NotImplementedError()
+
+ @classmethod
+ def settings(cls):
+ '''
+ Should return an opts object. The opts object should have one attribute
+ `format_map` which is an ordered list of formats for the device.
+ '''
+ raise NotImplementedError()
+
+
+
+
+class BookList(list):
+ '''
+ A list of books. Each Book object must have the fields:
+ 1. title
+ 2. authors
+ 3. size (file size of the book)
+ 4. datetime (a UTC time tuple)
+ 5. path (path on the device to the book)
+ 6. thumbnail (can be None) thumbnail is either a str/bytes object with the
+ image data or it should have an attribute image_path that stores an
+ absolute (platform native) path to the image
+ 7. tags (a list of strings, can be empty).
+ '''
+
+ __getslice__ = None
+ __setslice__ = None
+
+ def __init__(self, oncard, prefix, settings):
+ pass
+
+ def supports_collections(self):
+ ''' Return True if the the device supports collections for this book list. '''
+ raise NotImplementedError()
+
+ def add_book(self, book, replace_metadata):
+ '''
+ Add the book to the booklist. Intent is to maintain any device-internal
+ metadata. Return True if booklists must be sync'ed
+ '''
+ raise NotImplementedError()
+
+ def remove_book(self, book):
+ '''
+ Remove a book from the booklist. Correct any device metadata at the
+ same time
+ '''
+ raise NotImplementedError()
+
+ def get_collections(self, collection_attributes):
+ '''
+ Return a dictionary of collections created from collection_attributes.
+ Each entry in the dictionary is of the form collection name:[list of
+ books]
+
+ The list of books is sorted by book title, except for collections
+ created from series, in which case series_index is used.
+
+ :param collection_attributes: A list of attributes of the Book object
+ '''
+ raise NotImplementedError()
\ No newline at end of file
From ddda93ea7a297a7711c176d4e842460d2a7f360a Mon Sep 17 00:00:00 2001
From: GRiker
Date: Sat, 22 May 2010 06:31:22 -0600
Subject: [PATCH 188/324] GwR initial iTunes driver
---
src/calibre/devices/apple/driver.py | 3 ---
1 file changed, 3 deletions(-)
diff --git a/src/calibre/devices/apple/driver.py b/src/calibre/devices/apple/driver.py
index ef5cf344a1..cebc20f732 100644
--- a/src/calibre/devices/apple/driver.py
+++ b/src/calibre/devices/apple/driver.py
@@ -23,9 +23,6 @@ class iDevice(DevicePlugin):
def is_usb_connected(self, device_on_system):
return True
- def can_handle(self, device_info):
- # Return True if iTunes installed
-
def can_handle_windows(self, device_id, debug=False):
'''
Optional method to perform further checks on a device to see if this driver
From fefe3ca0159b55446e87f9e668e0d6520499e422 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Sat, 22 May 2010 09:39:10 -0600
Subject: [PATCH 189/324] Fix #5589
---
src/calibre/utils/date.py | 19 ++++++++++++++-----
1 file changed, 14 insertions(+), 5 deletions(-)
diff --git a/src/calibre/utils/date.py b/src/calibre/utils/date.py
index dc84e6acf4..d81791a927 100644
--- a/src/calibre/utils/date.py
+++ b/src/calibre/utils/date.py
@@ -8,10 +8,13 @@ __docformat__ = 'restructuredtext en'
import re
from datetime import datetime
+from functools import partial
from dateutil.parser import parse
from dateutil.tz import tzlocal, tzutc
+from calibre import strftime
+
class SafeLocalTimeZone(tzlocal):
'''
Assume DST was not in effect for historical dates, if DST
@@ -115,21 +118,27 @@ def utcnow():
def utcfromtimestamp(stamp):
return datetime.utcfromtimestamp(stamp).replace(tzinfo=_utc_tz)
-def format_date(dt, format):
+def format_date(dt, format, assume_utc=False, as_utc=False):
''' Return a date formatted as a string using a subset of Qt's formatting codes '''
+ if dt.tzinfo is None:
+ dt = dt.replace(tzinfo=_utc_tz if assume_utc else
+ _local_tz)
+ dt = dt.astimezone(_utc_tz if as_utc else _local_tz)
+ strf = partial(strftime, t=dt.timetuple())
+
def format_day(mo):
l = len(mo.group(0))
if l == 1: return '%d'%dt.day
if l == 2: return '%02d'%dt.day
- if l == 3: return dt.strftime('%a')
- return dt.strftime('%A')
+ if l == 3: return strf('%a')
+ return strf('%A')
def format_month(mo):
l = len(mo.group(0))
if l == 1: return '%d'%dt.month
if l == 2: return '%02d'%dt.month
- if l == 3: return dt.strftime('%b')
- return dt.strftime('%B')
+ if l == 3: return strf('%b')
+ return strf('%B')
def format_year(mo):
if len(mo.group(0)) == 2: return '%02d'%(dt.year % 100)
From 82b3f3c7f1fff4969e7711502fceef96c0c65f82 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Sat, 22 May 2010 09:58:30 -0600
Subject: [PATCH 190/324] version 0.6.95
---
src/calibre/constants.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/calibre/constants.py b/src/calibre/constants.py
index e61ea6bda3..e90db34bc4 100644
--- a/src/calibre/constants.py
+++ b/src/calibre/constants.py
@@ -2,7 +2,7 @@ __license__ = 'GPL v3'
__copyright__ = '2008, Kovid Goyal kovid@kovidgoyal.net'
__docformat__ = 'restructuredtext en'
__appname__ = 'calibre'
-__version__ = '0.6.94'
+__version__ = '0.6.95'
__author__ = "Kovid Goyal "
import re
From 0c254d8d64f9de6e9de28279e0f33f69323a7141 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Sat, 22 May 2010 10:44:20 -0600
Subject: [PATCH 191/324] ...
---
src/calibre/utils/date.py | 6 +-----
1 file changed, 1 insertion(+), 5 deletions(-)
diff --git a/src/calibre/utils/date.py b/src/calibre/utils/date.py
index d81791a927..aa7714df2d 100644
--- a/src/calibre/utils/date.py
+++ b/src/calibre/utils/date.py
@@ -118,12 +118,8 @@ def utcnow():
def utcfromtimestamp(stamp):
return datetime.utcfromtimestamp(stamp).replace(tzinfo=_utc_tz)
-def format_date(dt, format, assume_utc=False, as_utc=False):
+def format_date(dt, format):
''' Return a date formatted as a string using a subset of Qt's formatting codes '''
- if dt.tzinfo is None:
- dt = dt.replace(tzinfo=_utc_tz if assume_utc else
- _local_tz)
- dt = dt.astimezone(_utc_tz if as_utc else _local_tz)
strf = partial(strftime, t=dt.timetuple())
def format_day(mo):
From 9426f999a803fd536920afbef958b717f6045db6 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Sat, 22 May 2010 10:46:05 -0600
Subject: [PATCH 192/324] ...
---
src/calibre/utils/date.py | 7 ++++++-
1 file changed, 6 insertions(+), 1 deletion(-)
diff --git a/src/calibre/utils/date.py b/src/calibre/utils/date.py
index aa7714df2d..50b98b39b8 100644
--- a/src/calibre/utils/date.py
+++ b/src/calibre/utils/date.py
@@ -118,8 +118,13 @@ def utcnow():
def utcfromtimestamp(stamp):
return datetime.utcfromtimestamp(stamp).replace(tzinfo=_utc_tz)
-def format_date(dt, format):
+def format_date(dt, format, assume_utc=False, as_utc=False):
''' Return a date formatted as a string using a subset of Qt's formatting codes '''
+ if hasattr(dt, 'tzinfo'):
+ if dt.tzinfo is None:
+ dt = dt.replace(tzinfo=_utc_tz if assume_utc else
+ _local_tz)
+ dt = dt.astimezone(_utc_tz if as_utc else _local_tz)
strf = partial(strftime, t=dt.timetuple())
def format_day(mo):
From a80094415aeb92f646cfaddd33a413de09a14348 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Sat, 22 May 2010 20:17:16 -0600
Subject: [PATCH 193/324] Breakup library.server into a package
---
src/calibre/gui2/dialogs/config/__init__.py | 6 +-
src/calibre/gui2/ui.py | 4 +-
src/calibre/gui2/wizard/__init__.py | 2 +-
src/calibre/library/__init__.py | 25 -
src/calibre/library/server.py | 955 --------------------
src/calibre/library/server/__init__.py | 42 +
src/calibre/library/server/base.py | 130 +++
src/calibre/library/server/content.py | 199 ++++
src/calibre/library/server/main.py | 91 ++
src/calibre/library/server/mobile.py | 228 +++++
src/calibre/library/server/opds.py | 300 ++++++
src/calibre/library/server/utils.py | 32 +
src/calibre/library/server/xml.py | 98 ++
src/calibre/linux.py | 2 +-
14 files changed, 1127 insertions(+), 987 deletions(-)
delete mode 100644 src/calibre/library/server.py
create mode 100644 src/calibre/library/server/__init__.py
create mode 100644 src/calibre/library/server/base.py
create mode 100644 src/calibre/library/server/content.py
create mode 100644 src/calibre/library/server/main.py
create mode 100644 src/calibre/library/server/mobile.py
create mode 100644 src/calibre/library/server/opds.py
create mode 100644 src/calibre/library/server/utils.py
create mode 100644 src/calibre/library/server/xml.py
diff --git a/src/calibre/gui2/dialogs/config/__init__.py b/src/calibre/gui2/dialogs/config/__init__.py
index f92c52e204..9d108d3807 100644
--- a/src/calibre/gui2/dialogs/config/__init__.py
+++ b/src/calibre/gui2/dialogs/config/__init__.py
@@ -20,7 +20,7 @@ from calibre.gui2 import choose_dir, error_dialog, config, \
from calibre.utils.config import prefs
from calibre.ebooks import BOOK_EXTENSIONS
from calibre.ebooks.oeb.iterator import is_supported
-from calibre.library import server_config
+from calibre.library.server import server_config
from calibre.customize.ui import initialized_plugins, is_disabled, enable_plugin, \
disable_plugin, customize_plugin, \
plugin_customization, add_plugin, \
@@ -770,7 +770,7 @@ class ConfigDialog(ResizableDialog, Ui_Dialog):
def start_server(self):
self.set_server_options()
- from calibre.library.server import start_threaded_server
+ from calibre.library.server.main import start_threaded_server
self.server = start_threaded_server(self.db, server_config().parse())
while not self.server.is_running and self.server.exception is None:
time.sleep(1)
@@ -783,7 +783,7 @@ class ConfigDialog(ResizableDialog, Ui_Dialog):
self.stop.setEnabled(True)
def stop_server(self):
- from calibre.library.server import stop_threaded_server
+ from calibre.library.server.main import stop_threaded_server
stop_threaded_server(self.server)
self.server = None
self.start.setEnabled(True)
diff --git a/src/calibre/gui2/ui.py b/src/calibre/gui2/ui.py
index 6ed51d3eff..2b647fe5c8 100644
--- a/src/calibre/gui2/ui.py
+++ b/src/calibre/gui2/ui.py
@@ -617,8 +617,8 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
if config['autolaunch_server']:
- from calibre.library.server import start_threaded_server
- from calibre.library import server_config
+ from calibre.library.server.main import start_threaded_server
+ from calibre.library.server import server_config
self.content_server = start_threaded_server(
db, server_config().parse())
self.test_server_timer = QTimer.singleShot(10000, self.test_server)
diff --git a/src/calibre/gui2/wizard/__init__.py b/src/calibre/gui2/wizard/__init__.py
index 0ac6c0a00b..d7bcc268f5 100644
--- a/src/calibre/gui2/wizard/__init__.py
+++ b/src/calibre/gui2/wizard/__init__.py
@@ -331,7 +331,7 @@ class StanzaPage(QWizardPage, StanzaUI):
p = self.set_port()
if p is not None:
- from calibre.library import server_config
+ from calibre.library.server import server_config
c = server_config()
c.set('port', p)
diff --git a/src/calibre/library/__init__.py b/src/calibre/library/__init__.py
index 3c98db5e8a..18aec71fc8 100644
--- a/src/calibre/library/__init__.py
+++ b/src/calibre/library/__init__.py
@@ -1,31 +1,6 @@
__license__ = 'GPL v3'
__copyright__ = '2008, Kovid Goyal '
''' Code to manage ebook library'''
-from calibre.utils.config import Config, StringConfig
-
-
-def server_config(defaults=None):
- desc=_('Settings to control the calibre content server')
- c = Config('server', desc) if defaults is None else StringConfig(defaults, desc)
-
- c.add_opt('port', ['-p', '--port'], default=8080,
- help=_('The port on which to listen. Default is %default'))
- c.add_opt('timeout', ['-t', '--timeout'], default=120,
- help=_('The server timeout in seconds. Default is %default'))
- c.add_opt('thread_pool', ['--thread-pool'], default=30,
- help=_('The max number of worker threads to use. Default is %default'))
- c.add_opt('password', ['--password'], default=None,
- help=_('Set a password to restrict access. By default access is unrestricted.'))
- c.add_opt('username', ['--username'], default='calibre',
- help=_('Username for access. By default, it is: %default'))
- c.add_opt('develop', ['--develop'], default=False,
- help='Development mode. Server automatically restarts on file changes and serves code files (html, css, js) from the file system instead of calibre\'s resource system.')
- c.add_opt('max_cover', ['--max-cover'], default='600x800',
- help=_('The maximum size for displayed covers. Default is %default.'))
- c.add_opt('max_opds_items', ['--max-opds-items'], default=30,
- help=_('The maximum number of matches to return per OPDS query. '
- 'This affects Stanza, WordPlayer, etc. integration.'))
- return c
def db():
from calibre.library.database2 import LibraryDatabase2
diff --git a/src/calibre/library/server.py b/src/calibre/library/server.py
deleted file mode 100644
index 1a15492da3..0000000000
--- a/src/calibre/library/server.py
+++ /dev/null
@@ -1,955 +0,0 @@
-#!/usr/bin/env python
-__license__ = 'GPL v3'
-__copyright__ = '2008, Kovid Goyal kovid@kovidgoyal.net'
-__docformat__ = 'restructuredtext en'
-
-'''
-HTTP server for remote access to the calibre database.
-'''
-
-import sys, textwrap, operator, os, re, logging, cStringIO, copy
-import __builtin__
-from itertools import repeat
-from logging.handlers import RotatingFileHandler
-from threading import Thread
-
-import cherrypy
-try:
- from PIL import Image as PILImage
- PILImage
-except ImportError:
- import Image as PILImage
-
-from calibre.constants import __version__, __appname__, iswindows
-from calibre.utils.genshi.template import MarkupTemplate
-from calibre import fit_image, guess_type, prepare_string_for_xml, \
- strftime as _strftime
-from calibre.library import server_config as config
-from calibre.library.database2 import LibraryDatabase2
-from calibre.utils.config import config_dir
-from calibre.utils.mdns import publish as publish_zeroconf, \
- stop_server as stop_zeroconf, get_external_ip
-from calibre.ebooks.metadata import fmt_sidx, title_sort
-from calibre.utils.date import now as nowf, fromtimestamp
-
-listen_on = '0.0.0.0'
-
-def strftime(fmt='%Y/%m/%d %H:%M:%S', dt=None):
- if not hasattr(dt, 'timetuple'):
- dt = nowf()
- dt = dt.timetuple()
- try:
- return _strftime(fmt, dt)
- except:
- return _strftime(fmt, nowf().timetuple())
-
-def expose(func):
-
- def do(self, *args, **kwargs):
- dict.update(cherrypy.response.headers, {'Server':self.server_name})
- if not self.embedded:
- self.db.check_if_modified()
- return func(self, *args, **kwargs)
-
- return cherrypy.expose(do)
-
-log_access_file = os.path.join(config_dir, 'server_access_log.txt')
-log_error_file = os.path.join(config_dir, 'server_error_log.txt')
-
-
-class LibraryServer(object):
-
- server_name = __appname__ + '/' + __version__
-
- BOOK = textwrap.dedent('''\
- ${r[FM['comments']] if r[FM['comments']] else ''}
-
- ''')
-
- MOBILE_UA = re.compile('(?i)(?:iPhone|Opera Mini|NetFront|webOS|Mobile|Android|imode|DoCoMo|Minimo|Blackberry|MIDP|Symbian|HD2)')
-
- MOBILE_BOOK = textwrap.dedent('''\
-
-
-
-
-
-
- ${format.lower()}
-
- ${r[FM['title']]}${(' ['+r[FM['series']]+'-'+r[FM['series_index']]+']') if r[FM['series']] else ''} by ${authors} - ${r[FM['size']]/1024}k - ${r[FM['publisher']] if r[FM['publisher']] else ''} ${pubdate} ${'['+r[FM['tags']]+']' if r[FM['tags']] else ''}
-
+
+
+''')
+
+# }}}
+
+class MobileServer(object):
+ 'A view optimized for browsers in mobile devices'
+
+ MOBILE_UA = re.compile('(?i)(?:iPhone|Opera Mini|NetFront|webOS|Mobile|Android|imode|DoCoMo|Minimo|Blackberry|MIDP|Symbian|HD2)')
+
+ @expose
+ def mobile(self, start='1', num='25', sort='date', search='',
+ _=None, order='descending'):
+ '''
+ Serves metadata from the calibre database as XML.
+
+ :param sort: Sort results by ``sort``. Can be one of `title,author,rating`.
+ :param search: Filter results by ``search`` query. See :class:`SearchQueryParser` for query syntax
+ :param start,num: Return the slice `[start:start+num]` of the sorted and filtered results
+ :param _: Firefox seems to sometimes send this when using XMLHttpRequest with no caching
+ '''
+ try:
+ start = int(start)
+ except ValueError:
+ raise cherrypy.HTTPError(400, 'start: %s is not an integer'%start)
+ try:
+ num = int(num)
+ except ValueError:
+ raise cherrypy.HTTPError(400, 'num: %s is not an integer'%num)
+ ids = self.db.data.parse(search) if search and search.strip() else self.db.data.universal_set()
+ ids = sorted(ids)
+ FM = self.db.FIELD_MAP
+ items = copy.deepcopy([r for r in iter(self.db) if r[FM['id']] in ids])
+ if sort is not None:
+ self.sort(items, sort, (order.lower().strip() == 'ascending'))
+
+ book, books = MarkupTemplate(MOBILE_BOOK), []
+ for record in items[(start-1):(start-1)+num]:
+ if record[FM['formats']] is None:
+ record[FM['formats']] = ''
+ if record[FM['size']] is None:
+ record[FM['size']] = 0
+ aus = record[FM['authors']] if record[FM['authors']] else __builtin__._('Unknown')
+ authors = '|'.join([i.replace('|', ',') for i in aus.split(',')])
+ record[FM['series_index']] = \
+ fmt_sidx(float(record[FM['series_index']]))
+ ts, pd = strftime('%Y/%m/%d %H:%M:%S', record[FM['timestamp']]), \
+ strftime('%Y/%m/%d %H:%M:%S', record[FM['pubdate']])
+ books.append(book.generate(r=record, authors=authors, timestamp=ts,
+ pubdate=pd, FM=FM).render('xml').decode('utf-8'))
+ updated = self.db.last_modified()
+
+ cherrypy.response.headers['Content-Type'] = 'text/html; charset=utf-8'
+ cherrypy.response.headers['Last-Modified'] = self.last_modified(updated)
+
+
+ url_base = "/mobile?search=" + search+";order="+order+";sort="+sort+";num="+str(num)
+
+ return MOBILE.generate(books=books, start=start, updated=updated,
+ search=search, sort=sort, order=order, num=num, FM=FM,
+ total=len(ids), url_base=url_base).render('html')
+
+
diff --git a/src/calibre/library/server/opds.py b/src/calibre/library/server/opds.py
new file mode 100644
index 0000000000..f7a7679813
--- /dev/null
+++ b/src/calibre/library/server/opds.py
@@ -0,0 +1,300 @@
+#!/usr/bin/env python
+# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai
+
+__license__ = 'GPL v3'
+__copyright__ = '2010, Kovid Goyal '
+__docformat__ = 'restructuredtext en'
+
+import re
+from itertools import repeat
+
+import cherrypy
+
+from calibre.utils.genshi.template import MarkupTemplate
+from calibre.library.server.utils import strftime, expose
+from calibre.ebooks.metadata import fmt_sidx, title_sort
+from calibre import guess_type, prepare_string_for_xml
+
+# Templates {{{
+
+STANZA_ENTRY=MarkupTemplate('''\
+
+ ${record[FM['title']]}
+ urn:calibre:${urn}
+ ${authors}
+ ${timestamp}
+
+
+
+
+
${Markup(extra)}${record[FM['comments']]}
+
+
+''')
+
+STANZA_SUBCATALOG_ENTRY=MarkupTemplate('''\
+
+ ${title}
+ urn:calibre:${id}
+ ${updated.strftime('%Y-%m-%dT%H:%M:%S+00:00')}
+
+ ${count} books
+
+''')
+
+STANZA = MarkupTemplate('''\
+
+
+ calibre Library
+ $id
+ ${updated.strftime('%Y-%m-%dT%H:%M:%S+00:00')}
+
+ ${Markup(next_link)}
+
+ calibre
+ http://calibre-ebook.com
+
+
+ ${subtitle}
+
+
+ ${Markup(entry)}
+
+
+''')
+
+STANZA_MAIN = MarkupTemplate('''\
+
+
+ calibre Library
+ $id
+ ${updated.strftime('%Y-%m-%dT%H:%M:%S+00:00')}
+
+
+ calibre
+ http://calibre-ebook.com
+
+
+ ${subtitle}
+
+
+ By Author
+ urn:uuid:fc000fa0-8c23-11de-a31d-0002a5d5c51b
+ ${updated.strftime('%Y-%m-%dT%H:%M:%S+00:00')}
+
+ Books sorted by Author
+
+
+ By Title
+ urn:uuid:1df4fe40-8c24-11de-b4c6-0002a5d5c51b
+ ${updated.strftime('%Y-%m-%dT%H:%M:%S+00:00')}
+
+ Books sorted by Title
+
+
+ By Newest
+ urn:uuid:3c6d4940-8c24-11de-a4d7-0002a5d5c51b
+ ${updated.strftime('%Y-%m-%dT%H:%M:%S+00:00')}
+
+ Books sorted by Date
+
+
+ By Tag
+ urn:uuid:824921e8-db8a-4e61-7d38-f1ce41502853
+ ${updated.strftime('%Y-%m-%dT%H:%M:%S+00:00')}
+
+ Books sorted by Tags
+
+
+ By Series
+ urn:uuid:512a5e50-a88f-f6b8-82aa-8f129c719f61
+ ${updated.strftime('%Y-%m-%dT%H:%M:%S+00:00')}
+
+ Books sorted by Series
+
+
+''')
+
+# }}}
+
+class OPDSServer(object):
+
+ def get_matches(self, location, query):
+ base = self.db.data.get_matches(location, query)
+ epub = self.db.data.get_matches('format', '=epub')
+ pdb = self.db.data.get_matches('format', '=pdb')
+ return base.intersection(epub.union(pdb))
+
+ def stanza_sortby_subcategory(self, updated, sortby, offset):
+ pat = re.compile(r'\(.*\)')
+
+ def clean_author(x):
+ return pat.sub('', x).strip()
+
+ def author_cmp(x, y):
+ x = x if ',' in x else clean_author(x).rpartition(' ')[-1]
+ y = y if ',' in y else clean_author(y).rpartition(' ')[-1]
+ return cmp(x.lower(), y.lower())
+
+ def get_author(x):
+ pref, ___, suff = clean_author(x).rpartition(' ')
+ return suff + (', '+pref) if pref else suff
+
+
+ what, subtitle = sortby[2:], ''
+ if sortby == 'byseries':
+ data = self.db.all_series()
+ data = [(x[0], x[1], len(self.get_matches('series', '='+x[1]))) for x in data]
+ subtitle = 'Books by series'
+ elif sortby == 'byauthor':
+ data = self.db.all_authors()
+ data = [(x[0], x[1], len(self.get_matches('authors', '='+x[1]))) for x in data]
+ subtitle = 'Books by author'
+ elif sortby == 'bytag':
+ data = self.db.all_tags2()
+ data = [(x[0], x[1], len(self.get_matches('tags', '='+x[1]))) for x in data]
+ subtitle = 'Books by tag'
+ fcmp = author_cmp if sortby == 'byauthor' else cmp
+ data = [x for x in data if x[2] > 0]
+ data.sort(cmp=lambda x, y: fcmp(x[1], y[1]))
+ next_offset = offset + self.max_stanza_items
+ rdata = data[offset:next_offset]
+ if next_offset >= len(data):
+ next_offset = -1
+ gt = get_author if sortby == 'byauthor' else lambda x: x
+ entries = [STANZA_SUBCATALOG_ENTRY.generate(title=gt(title), id=id,
+ what=what, updated=updated, count=c).render('xml').decode('utf-8') for id,
+ title, c in rdata]
+ next_link = ''
+ if next_offset > -1:
+ next_link = ('\n'
+ ) % (sortby, next_offset)
+ return STANZA.generate(subtitle=subtitle, data=entries, FM=self.db.FIELD_MAP,
+ updated=updated, id='urn:calibre:main', next_link=next_link).render('xml')
+
+ def stanza_main(self, updated):
+ return STANZA_MAIN.generate(subtitle='', data=[], FM=self.db.FIELD_MAP,
+ updated=updated, id='urn:calibre:main').render('xml')
+
+ @expose
+ def stanza(self, search=None, sortby=None, authorid=None, tagid=None,
+ seriesid=None, offset=0):
+ 'Feeds to read calibre books on a ipod with stanza.'
+ books = []
+ updated = self.db.last_modified()
+ offset = int(offset)
+ cherrypy.response.headers['Last-Modified'] = self.last_modified(updated)
+ cherrypy.response.headers['Content-Type'] = 'text/xml'
+ # Main feed
+ if not sortby and not search and not authorid and not tagid and not seriesid:
+ return self.stanza_main(updated)
+ if sortby in ('byseries', 'byauthor', 'bytag'):
+ return self.stanza_sortby_subcategory(updated, sortby, offset)
+
+ # Get matching ids
+ if authorid:
+ authorid=int(authorid)
+ au = self.db.author_name(authorid)
+ ids = self.get_matches('authors', au)
+ elif tagid:
+ tagid=int(tagid)
+ ta = self.db.tag_name(tagid)
+ ids = self.get_matches('tags', ta)
+ elif seriesid:
+ seriesid=int(seriesid)
+ se = self.db.series_name(seriesid)
+ ids = self.get_matches('series', se)
+ else:
+ ids = self.db.data.parse(search) if search and search.strip() else self.db.data.universal_set()
+ record_list = list(iter(self.db))
+
+ FM = self.db.FIELD_MAP
+ # Sort the record list
+ if sortby == "bytitle" or authorid or tagid:
+ record_list.sort(lambda x, y:
+ cmp(title_sort(x[FM['title']]),
+ title_sort(y[FM['title']])))
+ elif seriesid:
+ record_list.sort(lambda x, y:
+ cmp(x[FM['series_index']],
+ y[FM['series_index']]))
+ else: # Sort by date
+ record_list = reversed(record_list)
+
+
+ fmts = FM['formats']
+ pat = re.compile(r'EPUB|PDB', re.IGNORECASE)
+ record_list = [x for x in record_list if x[FM['id']] in ids and
+ pat.search(x[fmts] if x[fmts] else '') is not None]
+ next_offset = offset + self.max_stanza_items
+ nrecord_list = record_list[offset:next_offset]
+ if next_offset >= len(record_list):
+ next_offset = -1
+
+ next_link = ''
+ if next_offset > -1:
+ q = ['offset=%d'%next_offset]
+ for x in ('search', 'sortby', 'authorid', 'tagid', 'seriesid'):
+ val = locals()[x]
+ if val is not None:
+ val = prepare_string_for_xml(unicode(val), True)
+ q.append('%s=%s'%(x, val))
+ next_link = ('\n'
+ ) % '&'.join(q)
+
+ for record in nrecord_list:
+ r = record[FM['formats']]
+ r = r.upper() if r else ''
+
+ z = record[FM['authors']]
+ if not z:
+ z = _('Unknown')
+ authors = ' & '.join([i.replace('|', ',') for i in
+ z.split(',')])
+
+ # Setup extra description
+ extra = []
+ rating = record[FM['rating']]
+ if rating > 0:
+ rating = ''.join(repeat('★', rating))
+ extra.append('RATING: %s '%rating)
+ tags = record[FM['tags']]
+ if tags:
+ extra.append('TAGS: %s '%\
+ prepare_string_for_xml(', '.join(tags.split(','))))
+ series = record[FM['series']]
+ if series:
+ extra.append('SERIES: %s [%s] '%\
+ (prepare_string_for_xml(series),
+ fmt_sidx(float(record[FM['series_index']]))))
+
+ fmt = 'epub' if 'EPUB' in r else 'pdb'
+ mimetype = guess_type('dummy.'+fmt)[0]
+
+ # Create the sub-catalog, which is either a list of
+ # authors/tags/series or a list of books
+ data = dict(
+ record=record,
+ updated=updated,
+ authors=authors,
+ tags=tags,
+ series=series,
+ FM=FM,
+ extra='\n'.join(extra),
+ mimetype=mimetype,
+ fmt=fmt,
+ urn=record[FM['uuid']],
+ timestamp=strftime('%Y-%m-%dT%H:%M:%S+00:00',
+ record[FM['timestamp']])
+ )
+ books.append(STANZA_ENTRY.generate(**data)\
+ .render('xml').decode('utf8'))
+
+ return STANZA.generate(subtitle='', data=books, FM=FM,
+ next_link=next_link, updated=updated, id='urn:calibre:main').render('xml')
+
+
+
+
diff --git a/src/calibre/library/server/utils.py b/src/calibre/library/server/utils.py
new file mode 100644
index 0000000000..1732da540c
--- /dev/null
+++ b/src/calibre/library/server/utils.py
@@ -0,0 +1,32 @@
+#!/usr/bin/env python
+# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai
+
+__license__ = 'GPL v3'
+__copyright__ = '2010, Kovid Goyal '
+__docformat__ = 'restructuredtext en'
+
+from calibre import strftime as _strftime
+from calibre.utils.date import now as nowf
+
+
+def expose(func):
+ import cherrypy
+
+ def do(self, *args, **kwargs):
+ dict.update(cherrypy.response.headers, {'Server':self.server_name})
+ if not self.embedded:
+ self.db.check_if_modified()
+ return func(self, *args, **kwargs)
+
+ return cherrypy.expose(do)
+
+def strftime(fmt='%Y/%m/%d %H:%M:%S', dt=None):
+ if not hasattr(dt, 'timetuple'):
+ dt = nowf()
+ dt = dt.timetuple()
+ try:
+ return _strftime(fmt, dt)
+ except:
+ return _strftime(fmt, nowf().timetuple())
+
+
diff --git a/src/calibre/library/server/xml.py b/src/calibre/library/server/xml.py
new file mode 100644
index 0000000000..e9f9a02548
--- /dev/null
+++ b/src/calibre/library/server/xml.py
@@ -0,0 +1,98 @@
+#!/usr/bin/env python
+# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai
+
+__license__ = 'GPL v3'
+__copyright__ = '2010, Kovid Goyal '
+__docformat__ = 'restructuredtext en'
+
+import copy, __builtin__
+
+import cherrypy
+
+from calibre.utils.genshi.template import MarkupTemplate
+from calibre.library.server.utils import strftime, expose
+from calibre.ebooks.metadata import fmt_sidx
+
+# Templates {{{
+BOOK = '''\
+${r[FM['comments']] if r[FM['comments']] else ''}
+
+'''
+
+
+LIBRARY = MarkupTemplate('''\
+
+
+
+ ${Markup(book)}
+
+
+''')
+
+# }}}
+
+class XMLServer(object):
+ 'Serves XML and the Ajax based HTML frontend'
+
+ @expose
+ def library(self, start='0', num='50', sort=None, search=None,
+ _=None, order='ascending'):
+ '''
+ Serves metadata from the calibre database as XML.
+
+ :param sort: Sort results by ``sort``. Can be one of `title,author,rating`.
+ :param search: Filter results by ``search`` query. See :class:`SearchQueryParser` for query syntax
+ :param start,num: Return the slice `[start:start+num]` of the sorted and filtered results
+ :param _: Firefox seems to sometimes send this when using XMLHttpRequest with no caching
+ '''
+ try:
+ start = int(start)
+ except ValueError:
+ raise cherrypy.HTTPError(400, 'start: %s is not an integer'%start)
+ try:
+ num = int(num)
+ except ValueError:
+ raise cherrypy.HTTPError(400, 'num: %s is not an integer'%num)
+ order = order.lower().strip() == 'ascending'
+ ids = self.db.data.parse(search) if search and search.strip() else self.db.data.universal_set()
+ ids = sorted(ids)
+ FM = self.db.FIELD_MAP
+ items = copy.deepcopy([r for r in iter(self.db) if r[FM['id']] in ids])
+ if sort is not None:
+ self.sort(items, sort, order)
+
+ book, books = MarkupTemplate(BOOK), []
+ for record in items[start:start+num]:
+ aus = record[FM['authors']] if record[FM['authors']] else __builtin__._('Unknown')
+ authors = '|'.join([i.replace('|', ',') for i in aus.split(',')])
+ record[FM['series_index']] = \
+ fmt_sidx(float(record[FM['series_index']]))
+ ts, pd = strftime('%Y/%m/%d %H:%M:%S', record[FM['timestamp']]), \
+ strftime('%Y/%m/%d %H:%M:%S', record[FM['pubdate']])
+ books.append(book.generate(r=record, authors=authors, timestamp=ts,
+ pubdate=pd, FM=FM).render('xml').decode('utf-8'))
+ updated = self.db.last_modified()
+
+ cherrypy.response.headers['Content-Type'] = 'text/xml'
+ cherrypy.response.headers['Last-Modified'] = self.last_modified(updated)
+ return LIBRARY.generate(books=books, start=start, updated=updated,
+ total=len(ids), FM=FM).render('xml')
+
+
+
+
diff --git a/src/calibre/linux.py b/src/calibre/linux.py
index 331783c775..ed806d58ac 100644
--- a/src/calibre/linux.py
+++ b/src/calibre/linux.py
@@ -18,7 +18,7 @@ entry_points = {
'ebook-convert = calibre.ebooks.conversion.cli:main',
'markdown-calibre = calibre.ebooks.markdown.markdown:main',
'web2disk = calibre.web.fetch.simple:main',
- 'calibre-server = calibre.library.server:main',
+ 'calibre-server = calibre.library.server.main:main',
'lrf2lrs = calibre.ebooks.lrf.lrfparser:main',
'lrs2lrf = calibre.ebooks.lrf.lrs.convert_from:main',
'librarything = calibre.ebooks.metadata.library_thing:main',
From 007cf9d6c1121a99c02a4880fdc39557425b61a2 Mon Sep 17 00:00:00 2001
From: GRiker
Date: Sun, 23 May 2010 10:15:07 -0600
Subject: [PATCH 194/324] GwR early apple driver
---
src/calibre/customize/builtins.py | 3 +-
src/calibre/devices/apple/driver.py | 346 +++++++++++++++++-----------
2 files changed, 215 insertions(+), 134 deletions(-)
diff --git a/src/calibre/customize/builtins.py b/src/calibre/customize/builtins.py
index d1f5ea050c..e2e4b549c8 100644
--- a/src/calibre/customize/builtins.py
+++ b/src/calibre/customize/builtins.py
@@ -430,7 +430,7 @@ from calibre.ebooks.txt.output import TXTOutput
from calibre.customize.profiles import input_profiles, output_profiles
-
+from calibre.devices.apple.driver import ITUNES
from calibre.devices.hanlin.driver import HANLINV3, HANLINV5, BOOX
from calibre.devices.blackberry.driver import BLACKBERRY
from calibre.devices.cybook.driver import CYBOOK
@@ -495,6 +495,7 @@ plugins += [
]
# Order here matters. The first matched device is the one used.
plugins += [
+ ITUNES,
HANLINV3,
HANLINV5,
BLACKBERRY,
diff --git a/src/calibre/devices/apple/driver.py b/src/calibre/devices/apple/driver.py
index cebc20f732..154412d220 100644
--- a/src/calibre/devices/apple/driver.py
+++ b/src/calibre/devices/apple/driver.py
@@ -5,22 +5,83 @@
22 May 2010
'''
+import datetime
+from calibre.constants import isosx, iswindows
from calibre.devices.interface import DevicePlugin
+#from calibre.ebooks.metadata import MetaInformation
+from calibre.utils.config import Config
-class iDevice(DevicePlugin):
+if isosx:
+ print "running in OSX"
+ import appscript
+
+if iswindows:
+ print "running in Windows"
+ import win32com.client
+
+class ITUNES(DevicePlugin):
name = 'Apple device interface'
gui_name = 'Apple device'
+ icon = I('devices/iPad.png')
+ description = _('Communicate with iBooks through iTunes.')
supported_platforms = ['windows','osx']
author = 'GRiker'
FORMATS = ['epub']
- VENDOR_ID = [0x0830]
- PRODUCT_ID = [0x8004, 0x8002, 0x0101]
- BCD = [0x0316]
+ VENDOR_ID = [0x05ac]
+ # 0x129a:iPad 0x1292:iPhone 3G
+ PRODUCT_ID = [0x129a,0x1292]
+ BCD = [0x01]
- def is_usb_connected(self, device_on_system):
+ app = None
+ is_connected = False
+
+
+ # Public methods
+
+ def add_books_to_metadata(cls, locations, metadata, booklists):
+ '''
+ Add locations to the booklists. This function must not communicate with
+ the device.
+ @param locations: Result of a call to L{upload_books}
+ @param metadata: List of MetaInformation objects, same as for
+ :method:`upload_books`.
+ @param booklists: A tuple containing the result of calls to
+ (L{books}(oncard=None), L{books}(oncard='carda'),
+ L{books}(oncard='cardb')).
+ '''
+ raise NotImplementedError
+
+ def books(self, oncard=None, end_session=True):
+ """
+ Return a list of ebooks on the device.
+ @param oncard: If 'carda' or 'cardb' return a list of ebooks on the
+ specific storage card, otherwise return list of ebooks
+ in main memory of device. If a card is specified and no
+ books are on the card return empty list.
+ @return: A BookList.
+ """
+ print "ITUNES:books(oncard=%s)" % oncard
+ if not oncard:
+ myBooks = BookList()
+ book = Book()
+
+ myBooks.add_book(book, False)
+ print "len(myBooks): %d" % len(myBooks)
+ return myBooks
+ else:
+ return []
+
+ def can_handle(self, device_info, debug=False):
+ '''
+ Unix version of :method:`can_handle_windows`
+
+ :param device_info: Is a tupe of (vid, pid, bcd, manufacturer, product,
+ serial number)
+ '''
+ print "ITUNES:can_handle()"
return True
def can_handle_windows(self, device_id, debug=False):
@@ -35,17 +96,68 @@ class iDevice(DevicePlugin):
:param device_info: On windows a device ID string. On Unix a tuple of
``(vendor_id, product_id, bcd)``.
'''
+ print "ITUNES:can_handle_windows()"
return True
- def can_handle(self, device_info, debug=False):
+ def card_prefix(self, end_session=True):
'''
- Unix version of :method:`can_handle_windows`
-
- :param device_info: Is a tupe of (vid, pid, bcd, manufacturer, product,
- serial number)
+ Return a 2 element list of the prefix to paths on the cards.
+ If no card is present None is set for the card's prefix.
+ E.G.
+ ('/place', '/place2')
+ (None, 'place2')
+ ('place', None)
+ (None, None)
'''
+ print "ITUNES:card_prefix()"
+ return (None,None)
- return True
+ def config_widget(cls):
+ '''
+ Should return a QWidget. The QWidget contains the settings for the device interface
+ '''
+ raise NotImplementedError()
+
+ def delete_books(self, paths, end_session=True):
+ '''
+ Delete books at paths on device.
+ '''
+ raise NotImplementedError()
+
+ def eject(self):
+ '''
+ Un-mount / eject the device from the OS. This does not check if there
+ are pending GUI jobs that need to communicate with the device.
+ '''
+ print "ITUNES:eject()"
+
+ def free_space(self, end_session=True):
+ """
+ Get free space available on the mountpoints:
+ 1. Main memory
+ 2. Card A
+ 3. Card B
+
+ @return: A 3 element list with free space in bytes of (1, 2, 3). If a
+ particular device doesn't have any of these locations it should return -1.
+ """
+ print "ITUNES:free_space()"
+ return (0,-1,-1)
+
+ def get_device_information(self, end_session=True):
+ """
+ Ask device for device information. See L{DeviceInfoQuery}.
+ @return: (device name, device version, software version on device, mime type)
+ """
+ print "ITUNES:get_device_information()"
+ return ('iPad','hw v1.0','sw v1.0', 'mime type')
+
+ def get_file(self, path, outfile, end_session=True):
+ '''
+ Read the file at C{path} on the device and write it to outfile.
+ @param outfile: file object like C{sys.stdout} or the result of an C{open} call
+ '''
+ raise NotImplementedError()
def open(self):
'''
@@ -58,14 +170,16 @@ class iDevice(DevicePlugin):
this function that should serve as a good example for USB Mass storage
devices.
'''
- print "iDevice(): I am here!"
-
- def eject(self):
- '''
- Un-mount / eject the device from the OS. This does not check if there
- are pending GUI jobs that need to communicate with the device.
- '''
- raise NotImplementedError()
+ print "ITUNES.open()"
+ if isosx:
+ # Launch iTunes if not already running
+ running_apps = appscript.app('System Events')
+ if not 'iTunes' in running_apps.processes.name():
+ print " launching iTunes"
+ app = appscript.app('iTunes', hide=True)
+ app.run()
+ self.app = app
+ # May need to set focus back to calibre here?
def post_yank_cleanup(self):
'''
@@ -73,6 +187,37 @@ class iDevice(DevicePlugin):
'''
raise NotImplementedError()
+ def remove_books_from_metadata(cls, paths, booklists):
+ '''
+ Remove books from the metadata list. This function must not communicate
+ with the device.
+ @param paths: paths to books on the device.
+ @param booklists: A tuple containing the result of calls to
+ (L{books}(oncard=None), L{books}(oncard='carda'),
+ L{books}(oncard='cardb')).
+ '''
+ raise NotImplementedError()
+
+ def reset(self, key='-1', log_packets=False, report_progress=None,
+ detected_device=None) :
+ """
+ :key: The key to unlock the device
+ :log_packets: If true the packet stream to/from the device is logged
+ :report_progress: Function that is called with a % progress
+ (number between 0 and 100) for various tasks
+ If it is called with -1 that means that the
+ task does not have any progress information
+ :detected_device: Device information from the device scanner
+ """
+ print "ITUNE.reset()"
+
+ def save_settings(cls, settings_widget):
+ '''
+ Should save settings to disk. Takes the widget created in config_widget
+ and saves all settings to disk.
+ '''
+ raise NotImplementedError()
+
def set_progress_reporter(self, report_progress):
'''
@param report_progress: Function that is called with a % progress
@@ -80,26 +225,28 @@ class iDevice(DevicePlugin):
If it is called with -1 that means that the
task does not have any progress information
'''
- raise NotImplementedError()
+ print "ITUNES:set_progress_reporter()"
- def get_device_information(self, end_session=True):
- """
- Ask device for device information. See L{DeviceInfoQuery}.
- @return: (device name, device version, software version on device, mime type)
- """
- raise NotImplementedError()
+ def settings(cls):
+ '''
+ Should return an opts object. The opts object should have one attribute
+ `format_map` which is an ordered list of formats for the device.
+ '''
+ print "ITUNES.settings()"
+ klass = cls if isinstance(cls, type) else cls.__class__
+ c = Config('device_drivers_%s' % klass.__name__, _('settings for device drivers'))
+ c.add_opt('format_map', default=cls.FORMATS,
+ help=_('Ordered list of formats the device will accept'))
+ return c.parse()
- def card_prefix(self, end_session=True):
+ def sync_booklists(self, booklists, end_session=True):
'''
- Return a 2 element list of the prefix to paths on the cards.
- If no card is present None is set for the card's prefix.
- E.G.
- ('/place', '/place2')
- (None, 'place2')
- ('place', None)
- (None, None)
+ Update metadata on device.
+ @param booklists: A tuple containing the result of calls to
+ (L{books}(oncard=None), L{books}(oncard='carda'),
+ L{books}(oncard='cardb')).
'''
- raise NotImplementedError()
+ print "ITUNES:sync_booklists():"
def total_space(self, end_session=True):
"""
@@ -111,30 +258,7 @@ class iDevice(DevicePlugin):
@return: A 3 element list with total space in bytes of (1, 2, 3). If a
particular device doesn't have any of these locations it should return 0.
"""
- raise NotImplementedError()
-
- def free_space(self, end_session=True):
- """
- Get free space available on the mountpoints:
- 1. Main memory
- 2. Card A
- 3. Card B
-
- @return: A 3 element list with free space in bytes of (1, 2, 3). If a
- particular device doesn't have any of these locations it should return -1.
- """
- raise NotImplementedError()
-
- def books(self, oncard=None, end_session=True):
- """
- Return a list of ebooks on the device.
- @param oncard: If 'carda' or 'cardb' return a list of ebooks on the
- specific storage card, otherwise return list of ebooks
- in main memory of device. If a card is specified and no
- books are on the card return empty list.
- @return: A BookList.
- """
- raise NotImplementedError()
+ print "ITUNES:total_space()"
def upload_books(self, files, names, on_card=None, end_session=True,
metadata=None):
@@ -158,79 +282,16 @@ class iDevice(DevicePlugin):
'''
raise NotImplementedError()
- @classmethod
- def add_books_to_metadata(cls, locations, metadata, booklists):
- '''
- Add locations to the booklists. This function must not communicate with
- the device.
- @param locations: Result of a call to L{upload_books}
- @param metadata: List of MetaInformation objects, same as for
- :method:`upload_books`.
- @param booklists: A tuple containing the result of calls to
- (L{books}(oncard=None), L{books}(oncard='carda'),
- L{books}(oncard='cardb')).
- '''
- raise NotImplementedError
+ # Private methods
- def delete_books(self, paths, end_session=True):
+ def _get_source(self):
'''
- Delete books at paths on device.
+ Get iTunes sources (Library, iPod, Radio ...)
'''
- raise NotImplementedError()
-
- @classmethod
- def remove_books_from_metadata(cls, paths, booklists):
- '''
- Remove books from the metadata list. This function must not communicate
- with the device.
- @param paths: paths to books on the device.
- @param booklists: A tuple containing the result of calls to
- (L{books}(oncard=None), L{books}(oncard='carda'),
- L{books}(oncard='cardb')).
- '''
- raise NotImplementedError()
-
- def sync_booklists(self, booklists, end_session=True):
- '''
- Update metadata on device.
- @param booklists: A tuple containing the result of calls to
- (L{books}(oncard=None), L{books}(oncard='carda'),
- L{books}(oncard='cardb')).
- '''
- raise NotImplementedError()
-
- def get_file(self, path, outfile, end_session=True):
- '''
- Read the file at C{path} on the device and write it to outfile.
- @param outfile: file object like C{sys.stdout} or the result of an C{open} call
- '''
- raise NotImplementedError()
-
- @classmethod
- def config_widget(cls):
- '''
- Should return a QWidget. The QWidget contains the settings for the device interface
- '''
- raise NotImplementedError()
-
- @classmethod
- def save_settings(cls, settings_widget):
- '''
- Should save settings to disk. Takes the widget created in config_widget
- and saves all settings to disk.
- '''
- raise NotImplementedError()
-
- @classmethod
- def settings(cls):
- '''
- Should return an opts object. The opts object should have one attribute
- `format_map` which is an ordered list of formats for the device.
- '''
- raise NotImplementedError()
-
-
-
+ sources = self._app.sources()
+ names = [s.name() for s in sources]
+ kinds = [s.kind() for s in sources]
+ return dict(zip(kinds,names))
class BookList(list):
'''
@@ -249,19 +310,20 @@ class BookList(list):
__getslice__ = None
__setslice__ = None
- def __init__(self, oncard, prefix, settings):
+ def __init__(self):
pass
def supports_collections(self):
''' Return True if the the device supports collections for this book list. '''
- raise NotImplementedError()
+ return False
def add_book(self, book, replace_metadata):
'''
Add the book to the booklist. Intent is to maintain any device-internal
metadata. Return True if booklists must be sync'ed
'''
- raise NotImplementedError()
+ print "adding %s" % book
+ self.append(book)
def remove_book(self, book):
'''
@@ -281,4 +343,22 @@ class BookList(list):
:param collection_attributes: A list of attributes of the Book object
'''
- raise NotImplementedError()
\ No newline at end of file
+ return {}
+
+class Book(object):
+ '''
+ A simple class describing a book in the iTunes Books Library.
+ These seem to be the minimum Book attributes needed.
+ '''
+ def __init__(self):
+ setattr(self,'title','A Book Title')
+ setattr(self,'authors',['John Doe'])
+ setattr(self,'path','some/path.epub')
+ setattr(self,'size',1234567)
+ setattr(self,'datetime',datetime.datetime.now().timetuple())
+ setattr(self,'thumbnail',None)
+ setattr(self,'db_id',0)
+ setattr(self,'device_collections',[])
+ setattr(self,'tags',['Genre'])
+
+
From 2988e48cf23a8e77f1c678ba25224eaeb3abac30 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Sun, 23 May 2010 11:21:00 -0600
Subject: [PATCH 195/324] Cleanup signal connection in tag_view.py
---
src/calibre/gui2/tag_view.py | 37 +++++++++++++++++++-----------------
src/calibre/gui2/ui.py | 8 ++------
2 files changed, 22 insertions(+), 23 deletions(-)
diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py
index 22658291f5..8a01b6ad27 100644
--- a/src/calibre/gui2/tag_view.py
+++ b/src/calibre/gui2/tag_view.py
@@ -10,17 +10,18 @@ Browsing book collection by tags.
from itertools import izip
from PyQt4.Qt import Qt, QTreeView, QApplication, pyqtSignal, \
- QFont, SIGNAL, QSize, QIcon, QPoint, \
+ QFont, 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
-class TagsView(QTreeView):
+class TagsView(QTreeView): # {{{
- need_refresh = pyqtSignal()
+ need_refresh = pyqtSignal()
restriction_set = pyqtSignal(object)
+ tags_marked = pyqtSignal(object, object)
def __init__(self, *args):
QTreeView.__init__(self, *args)
@@ -36,10 +37,10 @@ class TagsView(QTreeView):
self.tag_match = tag_match
self.db = db
self.setModel(self._model)
- self.connect(self, SIGNAL('clicked(QModelIndex)'), self.toggle)
+ self.clicked.connect(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.popularity.stateChanged.connect(self.sort_changed)
+ self.restriction.activated[str].connect(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)
@@ -69,15 +70,13 @@ class TagsView(QTreeView):
self.model().set_search_restriction(self.search_restriction)
self.restriction_set.emit(self.search_restriction)
self.recount() # Must happen after the emission of the restriction_set signal
- self.emit(SIGNAL('tags_marked(PyQt_PyObject, PyQt_PyObject)'),
- self._model.tokens(), self.match_all)
+ self.tags_marked.emit(self._model.tokens(), self.match_all)
def toggle(self, index):
modifiers = int(QApplication.keyboardModifiers())
exclusive = modifiers not in (Qt.CTRL, Qt.SHIFT)
if self._model.toggle(index, exclusive):
- self.emit(SIGNAL('tags_marked(PyQt_PyObject, PyQt_PyObject)'),
- self._model.tokens(), self.match_all)
+ self.tags_marked.emit(self._model.tokens(), self.match_all)
def clear(self):
self.model().clear_state()
@@ -119,8 +118,9 @@ class TagsView(QTreeView):
def set_new_model(self):
self._model = TagsModel(self.db, parent=self)
self.setModel(self._model)
+ # }}}
-class TagTreeItem(object):
+class TagTreeItem(object): # {{{
CATEGORY = 0
TAG = 1
@@ -193,8 +193,10 @@ class TagTreeItem(object):
if self.type == self.TAG:
self.tag.state = (self.tag.state + 1)%3
+ # }}}
+
+class TagsModel(QAbstractItemModel): # {{{
-class TagsModel(QAbstractItemModel):
categories_orig = [_('Authors'), _('Series'), _('Formats'), _('Publishers'),
_('Ratings'), _('News'), _('Tags')]
row_map_orig = ['author', 'series', 'format', 'publisher', 'rating',
@@ -400,14 +402,12 @@ class TagsModel(QAbstractItemModel):
tag_item = tag_index.internalPointer()
tag = tag_item.tag
if tag is except_:
- self.emit(SIGNAL('dataChanged(QModelIndex,QModelIndex)'),
- tag_index, tag_index)
+ self.dataChanged.emit(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)
+ self.dataChanged.emit(tag_index, tag_index)
def clear_state(self):
self.reset_all_states()
@@ -426,7 +426,7 @@ class TagsModel(QAbstractItemModel):
if exclusive:
self.reset_all_states(except_=item.tag)
self.ignore_next_search = 2
- self.emit(SIGNAL('dataChanged(QModelIndex,QModelIndex)'), index, index)
+ self.dataChanged.emit(index, index)
return True
return False
@@ -451,3 +451,6 @@ class TagsModel(QAbstractItemModel):
tags_seen.append(tag.name)
ans.append('%s%s:"=%s"'%(prefix, category, tag.name))
return ans
+
+ # }}}
+
diff --git a/src/calibre/gui2/ui.py b/src/calibre/gui2/ui.py
index 2b647fe5c8..36848e33cf 100644
--- a/src/calibre/gui2/ui.py
+++ b/src/calibre/gui2/ui.py
@@ -538,14 +538,10 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
self.library_view.model().cover_cache = self.cover_cache
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.tags_view.tags_marked.connect(self.search.search_from_tags)
for x in (self.saved_search.clear_to_help, self.mark_restriction_set):
self.tags_view.restriction_set.connect(x)
- self.connect(self.tags_view,
- SIGNAL('tags_marked(PyQt_PyObject, PyQt_PyObject)'),
- self.saved_search.clear_to_help)
+ self.tags_view.tags_marked.connect(self.saved_search.clear_to_help)
self.search.search.connect(self.tags_view.model().reinit)
for x in (self.location_view.count_changed, self.tags_view.recount,
self.restriction_count_changed):
From d7fa2363a878f6b9e5676d6ea7a83cb241e9bacd Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Sun, 23 May 2010 12:28:24 -0600
Subject: [PATCH 196/324] Timing infrastructure for the content server
---
src/calibre/library/server/utils.py | 17 ++++++++++++++++-
1 file changed, 16 insertions(+), 1 deletion(-)
diff --git a/src/calibre/library/server/utils.py b/src/calibre/library/server/utils.py
index 1732da540c..7dc0884e1a 100644
--- a/src/calibre/library/server/utils.py
+++ b/src/calibre/library/server/utils.py
@@ -5,7 +5,9 @@ __license__ = 'GPL v3'
__copyright__ = '2010, Kovid Goyal '
__docformat__ = 'restructuredtext en'
-from calibre import strftime as _strftime
+import time
+
+from calibre import strftime as _strftime, prints
from calibre.utils.date import now as nowf
@@ -20,6 +22,19 @@ def expose(func):
return cherrypy.expose(do)
+def timeit(func):
+
+ def do(self, *args, **kwargs):
+ if self.opts.develop:
+ start = time.time()
+ ans = func(self, *args, **kwargs)
+ if self.opts.develop:
+ prints('Function', func.__name__, 'called with args:', args, kwargs)
+ prints('\tTime:', func.__name__, time.time()-start)
+ return ans
+
+ return do
+
def strftime(fmt='%Y/%m/%d %H:%M:%S', dt=None):
if not hasattr(dt, 'timetuple'):
dt = nowf()
From 3b557de4c724384587f99ff2ad2f496e3db46011 Mon Sep 17 00:00:00 2001
From: Charles Haley <>
Date: Sun, 23 May 2010 21:34:19 +0100
Subject: [PATCH 197/324] First pass at converting db2.get_categories to return
a complete dict
---
src/calibre/gui2/tag_view.py | 8 +--
src/calibre/library/custom_columns.py | 4 +-
src/calibre/library/database2.py | 61 +++++++++-------
src/calibre/utils/ordered_dict.py | 100 ++++++++++++++++++++++++++
4 files changed, 141 insertions(+), 32 deletions(-)
create mode 100644 src/calibre/utils/ordered_dict.py
diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py
index 8a01b6ad27..0fb72e071b 100644
--- a/src/calibre/gui2/tag_view.py
+++ b/src/calibre/gui2/tag_view.py
@@ -199,8 +199,8 @@ class TagsModel(QAbstractItemModel): # {{{
categories_orig = [_('Authors'), _('Series'), _('Formats'), _('Publishers'),
_('Ratings'), _('News'), _('Tags')]
- row_map_orig = ['author', 'series', 'format', 'publisher', 'rating',
- 'news', 'tag']
+ row_map_orig = ['authors', 'series', 'formats', 'publishers', 'ratings',
+ 'news', 'tags']
tags_categories_start= 7
search_keys=['search', _('Searches')]
@@ -264,8 +264,8 @@ class TagsModel(QAbstractItemModel): # {{{
self.cat_icon_map.append(self.cat_icon_map_orig[i])
# Clean up the author's tags, getting rid of the '|' characters
- if data['author'] is not None:
- for t in data['author']:
+ if data['authors'] is not None:
+ for t in data['authors']:
t.name = t.name.replace('|', ',')
# Now do the user-defined categories. There is a time/space tradeoff here.
diff --git a/src/calibre/library/custom_columns.py b/src/calibre/library/custom_columns.py
index a8375c6b5c..b6ada01b8c 100644
--- a/src/calibre/library/custom_columns.py
+++ b/src/calibre/library/custom_columns.py
@@ -144,8 +144,8 @@ class CustomColumns(object):
for i, v in self.custom_column_num_map.items():
if v['normalized']:
tn = 'custom_column_{0}'.format(i)
- self.tag_browser_categories[tn] = [v['label'], 'value']
- self.tag_browser_datatype[v['label']] = v['datatype']
+ self.tag_browser_categories[v['label']] = {'table':tn, 'column':'value', 'type':v['datatype'], 'name':v['name']}
+ #self.tag_browser_datatype[v['label']] = v['datatype']
def get_custom(self, idx, label=None, num=None, index_is_id=False):
if label is not None:
diff --git a/src/calibre/library/database2.py b/src/calibre/library/database2.py
index ed56d35bdc..12398de918 100644
--- a/src/calibre/library/database2.py
+++ b/src/calibre/library/database2.py
@@ -33,6 +33,7 @@ from calibre.customize.ui import run_plugins_on_import
from calibre.utils.filenames import ascii_filename
from calibre.utils.date import utcnow, now as nowf, utcfromtimestamp
+from calibre.utils.ordered_dict import OrderedDict
from calibre.ebooks import BOOK_EXTENSIONS, check_ebook_format
if iswindows:
@@ -123,22 +124,25 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
if isinstance(self.dbpath, unicode):
self.dbpath = self.dbpath.encode(filesystem_encoding)
- self.tag_browser_categories = {
- 'tags' : ['tag', 'name'],
- 'series' : ['series', 'name'],
- 'publishers': ['publisher', 'name'],
- 'authors' : ['author', 'name'],
- 'news' : ['news', 'name'],
- 'ratings' : ['rating', 'rating']
- }
- self.tag_browser_datatype = {
- 'tag' : 'textmult',
- 'series' : None,
- 'publisher' : 'text',
- 'author' : 'text',
- 'news' : None,
- 'rating' : 'rating',
- }
+ # Order as has been customary in the tags pane.
+ self.tag_browser_categories = OrderedDict([
+ ('authors', {'table':'authors', 'column':'name', 'type':'text', 'name':_('Authors')}),
+ ('series', {'table':'series', 'column':'name', 'type':None, 'name':_('Series')}),
+ ('formats', {'table':None, 'column':None, 'type':None, 'name':_('Formats')}),
+ ('publishers',{'table':'publishers', 'column':'name', 'type':'text', 'name':_('Publishers')}),
+ ('ratings', {'table':'ratings', 'column':'rating', 'type':'rating', 'name':_('Ratings')}),
+ ('news', {'table':'news', 'column':'name', 'type':None, 'name':_('News')}),
+ ('tags', {'table':'tags', 'column':'name', 'type':'textmult', 'name':_('Tags')}),
+ ])
+
+# self.tag_browser_datatype = {
+# 'tag' : 'textmult',
+# 'series' : None,
+# 'publisher' : 'text',
+# 'author' : 'text',
+# 'news' : None,
+# 'rating' : 'rating',
+# }
self.tag_browser_formatters = {'rating': lambda x:u'\u2605'*int(round(x/2.))}
@@ -653,17 +657,22 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
self.books_list_filter.change([] if not ids else ids)
categories = {}
- for tn, cn in self.tag_browser_categories.items():
+ for category in self.tag_browser_categories.keys():
+ tn = self.tag_browser_categories[category]['table']
+ categories[category] = [] #reserve the position in the ordered list
+ if tn is None:
+ continue
+ cn = self.tag_browser_categories[category]['column']
if ids is None:
- query = 'SELECT id, {0}, count FROM tag_browser_{1}'.format(cn[1], tn)
+ query = 'SELECT id, {0}, count FROM tag_browser_{1}'.format(cn, tn)
else:
- query = 'SELECT id, {0}, count FROM tag_browser_filtered_{1}'.format(cn[1], tn)
+ query = 'SELECT id, {0}, count FROM tag_browser_filtered_{1}'.format(cn, tn)
if sort_on_count:
query += ' ORDER BY count DESC'
else:
- query += ' ORDER BY {0} ASC'.format(cn[1])
+ query += ' ORDER BY {0} ASC'.format(cn)
data = self.conn.get(query)
- category = cn[0]
+ # category = cn[0]
icon, tooltip = None, ''
if icon_map:
if category in icon_map:
@@ -671,14 +680,14 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
else:
icon = icon_map['*custom']
tooltip = self.custom_column_label_map[category]['name']
- datatype = self.tag_browser_datatype[category]
+ datatype = self.tag_browser_categories[category]['type']
formatter = self.tag_browser_formatters.get(datatype, lambda x: x)
categories[category] = [Tag(formatter(r[1]), count=r[2], id=r[0],
icon=icon, tooltip = tooltip)
for r in data
if r[2] > 0 and
(datatype != 'rating' or len(formatter(r[1])) > 0)]
- categories['format'] = []
+ categories['formats'] = []
for fmt in self.conn.get('SELECT DISTINCT format FROM data'):
fmt = fmt[0]
if ids is not None:
@@ -693,13 +702,13 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
WHERE format="%s"'''%fmt,
all=False)
if count > 0:
- categories['format'].append(Tag(fmt, count=count))
+ categories['formats'].append(Tag(fmt, count=count))
if sort_on_count:
- categories['format'].sort(cmp=lambda x,y:cmp(x.count, y.count),
+ categories['formats'].sort(cmp=lambda x,y:cmp(x.count, y.count),
reverse=True)
else:
- categories['format'].sort(cmp=lambda x,y:cmp(x.name, y.name))
+ categories['formats'].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/ordered_dict.py b/src/calibre/utils/ordered_dict.py
new file mode 100644
index 0000000000..95a0af9e76
--- /dev/null
+++ b/src/calibre/utils/ordered_dict.py
@@ -0,0 +1,100 @@
+from UserDict import DictMixin
+
+class OrderedDict(dict, DictMixin):
+
+ def __init__(self, *args, **kwds):
+ if len(args) > 1:
+ raise TypeError('expected at most 1 arguments, got %d' % len(args))
+ try:
+ self.__end
+ except AttributeError:
+ self.clear()
+ self.update(*args, **kwds)
+
+ def clear(self):
+ self.__end = end = []
+ end += [None, end, end] # sentinel node for doubly linked list
+ self.__map = {} # key --> [key, prev, next]
+ dict.clear(self)
+
+ def __setitem__(self, key, value):
+ if key not in self:
+ end = self.__end
+ curr = end[1]
+ curr[2] = end[1] = self.__map[key] = [key, curr, end]
+ dict.__setitem__(self, key, value)
+
+ def __delitem__(self, key):
+ dict.__delitem__(self, key)
+ key, prev, next = self.__map.pop(key)
+ prev[2] = next
+ next[1] = prev
+
+ def __iter__(self):
+ end = self.__end
+ curr = end[2]
+ while curr is not end:
+ yield curr[0]
+ curr = curr[2]
+
+ def __reversed__(self):
+ end = self.__end
+ curr = end[1]
+ while curr is not end:
+ yield curr[0]
+ curr = curr[1]
+
+ def popitem(self, last=True):
+ if not self:
+ raise KeyError('dictionary is empty')
+ if last:
+ key = reversed(self).next()
+ else:
+ key = iter(self).next()
+ value = self.pop(key)
+ return key, value
+
+ def __reduce__(self):
+ items = [[k, self[k]] for k in self]
+ tmp = self.__map, self.__end
+ del self.__map, self.__end
+ inst_dict = vars(self).copy()
+ self.__map, self.__end = tmp
+ if inst_dict:
+ return (self.__class__, (items,), inst_dict)
+ return self.__class__, (items,)
+
+ def keys(self):
+ return list(self)
+
+ setdefault = DictMixin.setdefault
+ update = DictMixin.update
+ pop = DictMixin.pop
+ values = DictMixin.values
+ items = DictMixin.items
+ iterkeys = DictMixin.iterkeys
+ itervalues = DictMixin.itervalues
+ iteritems = DictMixin.iteritems
+
+ def __repr__(self):
+ if not self:
+ return '%s()' % (self.__class__.__name__,)
+ return '%s(%r)' % (self.__class__.__name__, self.items())
+
+ def copy(self):
+ return self.__class__(self)
+
+ @classmethod
+ def fromkeys(cls, iterable, value=None):
+ d = cls()
+ for key in iterable:
+ d[key] = value
+ return d
+
+ def __eq__(self, other):
+ if isinstance(other, OrderedDict):
+ return len(self)==len(other) and self.items() == other.items()
+ return dict.__eq__(self, other)
+
+ def __ne__(self, other):
+ return not self == other
From e2ef2579536d39c720d083007652fbaea67a7090 Mon Sep 17 00:00:00 2001
From: GRiker
Date: Sun, 23 May 2010 15:52:17 -0600
Subject: [PATCH 198/324] GwR early iPad device driver
---
src/calibre/devices/apple/driver.py | 79 +++++++++++++++++------------
1 file changed, 47 insertions(+), 32 deletions(-)
diff --git a/src/calibre/devices/apple/driver.py b/src/calibre/devices/apple/driver.py
index 154412d220..8ec93b9499 100644
--- a/src/calibre/devices/apple/driver.py
+++ b/src/calibre/devices/apple/driver.py
@@ -5,12 +5,13 @@
22 May 2010
'''
-import datetime
+import datetime, re
from calibre.constants import isosx, iswindows
from calibre.devices.interface import DevicePlugin
-#from calibre.ebooks.metadata import MetaInformation
+from calibre.ebooks.metadata import MetaInformation
from calibre.utils.config import Config
+from calibre.utils.date import parse_date
if isosx:
print "running in OSX"
@@ -35,7 +36,7 @@ class ITUNES(DevicePlugin):
PRODUCT_ID = [0x129a,0x1292]
BCD = [0x01]
- app = None
+ it = None
is_connected = False
@@ -65,12 +66,32 @@ class ITUNES(DevicePlugin):
"""
print "ITUNES:books(oncard=%s)" % oncard
if not oncard:
- myBooks = BookList()
- book = Book()
+ # Fetch a list of books from iTunes
+ if isosx:
+ names = [s.name() for s in self.it.sources()]
+ kinds = [s.kind() for s in self.it.sources()]
+ sources = dict(zip(kinds,names))
+
+ lib = self.it.sources['Library']
+
+ if 'Books' in lib.playlists.name():
+ booklist = BookList()
+ it_books = lib.playlists['Books'].file_tracks()
+ for it_book in it_books:
+ this_book = Book(it_book.name(), it_book.artist())
+ this_book.datetime = parse_date(str(it_book.date_added())).timetuple()
+ this_book.db_id = None
+ this_book.device_collections = []
+ this_book.path = 'iTunes/Books/%s.epub' % it_book.name()
+ this_book.size = it_book.size()
+ this_book.thumbnail = None
+ booklist.add_book(this_book, False)
+ return booklist
+
+ else:
+ return []
+
- myBooks.add_book(book, False)
- print "len(myBooks): %d" % len(myBooks)
- return myBooks
else:
return []
@@ -80,8 +101,9 @@ class ITUNES(DevicePlugin):
:param device_info: Is a tupe of (vid, pid, bcd, manufacturer, product,
serial number)
+ This gets called ~1x/second while device is sensed
'''
- print "ITUNES:can_handle()"
+ # print "ITUNES:can_handle()"
return True
def can_handle_windows(self, device_id, debug=False):
@@ -176,10 +198,11 @@ class ITUNES(DevicePlugin):
running_apps = appscript.app('System Events')
if not 'iTunes' in running_apps.processes.name():
print " launching iTunes"
- app = appscript.app('iTunes', hide=True)
+ it = appscript.app('iTunes', hide=True)
app.run()
- self.app = app
- # May need to set focus back to calibre here?
+ self.it = it
+ else:
+ self.it = appscript.app('iTunes')
def post_yank_cleanup(self):
'''
@@ -284,14 +307,6 @@ class ITUNES(DevicePlugin):
# Private methods
- def _get_source(self):
- '''
- Get iTunes sources (Library, iPod, Radio ...)
- '''
- sources = self._app.sources()
- names = [s.name() for s in sources]
- kinds = [s.kind() for s in sources]
- return dict(zip(kinds,names))
class BookList(list):
'''
@@ -345,20 +360,20 @@ class BookList(list):
'''
return {}
-class Book(object):
+class Book(MetaInformation):
'''
A simple class describing a book in the iTunes Books Library.
- These seem to be the minimum Book attributes needed.
+ Q's:
+ - Should thumbnail come from calibre if available?
+ - See ebooks.metadata.__init__ for all fields
'''
- def __init__(self):
- setattr(self,'title','A Book Title')
- setattr(self,'authors',['John Doe'])
- setattr(self,'path','some/path.epub')
- setattr(self,'size',1234567)
- setattr(self,'datetime',datetime.datetime.now().timetuple())
- setattr(self,'thumbnail',None)
- setattr(self,'db_id',0)
- setattr(self,'device_collections',[])
- setattr(self,'tags',['Genre'])
+ def __init__(self,title,author):
+ MetaInformation.__init__(self, title, authors=[author])
+ @dynamic_property
+ def title_sorter(self):
+ doc = '''String to sort the title. If absent, title is returned'''
+ def fget(self):
+ return re.sub('^\s*A\s+|^\s*The\s+|^\s*An\s+', '', self.title).rstrip()
+ return property(doc=doc, fget=fget)
From 359c0cd40e06a4ce261efcc85bb44fce4bd87eab Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Sun, 23 May 2010 19:13:44 -0600
Subject: [PATCH 199/324] Start refactoring of Content Server to use Routes for
URL dispatching and etree instead of genshi for templating. OPDS feeds are
currently broken.
---
resources/content_server/gui.js | 2 +-
src/calibre/library/server/base.py | 44 +-
src/calibre/library/server/cache.py | 18 +
src/calibre/library/server/content.py | 14 +-
src/calibre/library/server/mobile.py | 6 +-
src/calibre/library/server/opds.py | 165 ++--
src/calibre/library/server/utils.py | 23 +-
src/calibre/library/server/xml.py | 101 ++-
src/routes/__init__.py | 142 +++
src/routes/base.py | 4 +
src/routes/lru.py | 70 ++
src/routes/mapper.py | 1161 +++++++++++++++++++++++++
src/routes/middleware.py | 146 ++++
src/routes/route.py | 742 ++++++++++++++++
src/routes/util.py | 503 +++++++++++
15 files changed, 3013 insertions(+), 128 deletions(-)
create mode 100644 src/calibre/library/server/cache.py
create mode 100644 src/routes/__init__.py
create mode 100644 src/routes/base.py
create mode 100644 src/routes/lru.py
create mode 100644 src/routes/mapper.py
create mode 100644 src/routes/middleware.py
create mode 100644 src/routes/route.py
create mode 100644 src/routes/util.py
diff --git a/resources/content_server/gui.js b/resources/content_server/gui.js
index ba2b0af940..9c20037207 100644
--- a/resources/content_server/gui.js
+++ b/resources/content_server/gui.js
@@ -123,7 +123,7 @@ function fetch_library_books(start, num, timeout, sort, order, search) {
current_library_request = $.ajax({
type: "GET",
- url: "library",
+ url: "xml",
data: data,
cache: false,
timeout: timeout, //milliseconds
diff --git a/src/calibre/library/server/base.py b/src/calibre/library/server/base.py
index 666ce52ffc..a8d4ae899c 100644
--- a/src/calibre/library/server/base.py
+++ b/src/calibre/library/server/base.py
@@ -14,14 +14,46 @@ import cherrypy
from calibre.constants import __appname__, __version__
from calibre.utils.date import fromtimestamp
from calibre.library.server import listen_on, log_access_file, log_error_file
+from calibre.library.server.utils import expose
from calibre.utils.mdns import publish as publish_zeroconf, \
stop_server as stop_zeroconf, get_external_ip
from calibre.library.server.content import ContentServer
from calibre.library.server.mobile import MobileServer
from calibre.library.server.xml import XMLServer
from calibre.library.server.opds import OPDSServer
+from calibre.library.server.cache import Cache
-class LibraryServer(ContentServer, MobileServer, XMLServer, OPDSServer):
+
+class DispatchController(object): # {{{
+
+ def __init__(self):
+ self.dispatcher = cherrypy.dispatch.RoutesDispatcher()
+ self.funcs = []
+ self.seen = set([])
+
+ def __call__(self, name, route, func, **kwargs):
+ if name in self.seen:
+ raise NameError('Route name: '+ repr(name) + ' already used')
+ self.seen.add(name)
+ kwargs['action'] = 'f_%d'%len(self.funcs)
+ self.dispatcher.connect(name, route, self, **kwargs)
+ self.funcs.append(expose(func))
+
+ def __getattr__(self, attr):
+ if not attr.startswith('f_'):
+ raise AttributeError(attr + ' not found')
+ num = attr.rpartition('_')[-1]
+ try:
+ num = int(num)
+ except:
+ raise AttributeError(attr + ' not found')
+ if num < 0 or num >= len(self.funcs):
+ raise AttributeError(attr + ' not found')
+ return self.funcs[num]
+
+# }}}
+
+class LibraryServer(ContentServer, MobileServer, XMLServer, OPDSServer, Cache):
server_name = __appname__ + '/' + __version__
@@ -88,8 +120,16 @@ class LibraryServer(ContentServer, MobileServer, XMLServer, OPDSServer):
def start(self):
self.is_running = False
+ d = DispatchController()
+ for x in self.__class__.__bases__:
+ if hasattr(x, 'add_routes'):
+ x.add_routes(self, d)
+ root_conf = self.config.get('/', {})
+ root_conf['request.dispatch'] = d.dispatcher
+ self.config['/'] = root_conf
+
self.setup_loggers()
- cherrypy.tree.mount(self, '', config=self.config)
+ cherrypy.tree.mount(root=None, config=self.config)
try:
try:
cherrypy.engine.start()
diff --git a/src/calibre/library/server/cache.py b/src/calibre/library/server/cache.py
new file mode 100644
index 0000000000..89dc140434
--- /dev/null
+++ b/src/calibre/library/server/cache.py
@@ -0,0 +1,18 @@
+#!/usr/bin/env python
+# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai
+
+__license__ = 'GPL v3'
+__copyright__ = '2010, Kovid Goyal '
+__docformat__ = 'restructuredtext en'
+
+from calibre.utils.date import utcnow
+
+class Cache(object):
+
+ @property
+ def categories_cache(self):
+ old = getattr(self, '_category_cache', None)
+ if old is None or old[0] <= self.db.last_modified():
+ categories = self.db.get_categories()
+ self._category_cache = (utcnow(), categories)
+ return self._category_cache[1]
diff --git a/src/calibre/library/server/content.py b/src/calibre/library/server/content.py
index d1a695cee1..8638035c88 100644
--- a/src/calibre/library/server/content.py
+++ b/src/calibre/library/server/content.py
@@ -16,7 +16,7 @@ except ImportError:
from calibre import fit_image, guess_type
from calibre.utils.date import fromtimestamp
-from calibre.library.server.utils import expose
+
class ContentServer(object):
@@ -25,6 +25,13 @@ class ContentServer(object):
a few utility methods.
'''
+ def add_routes(self, connect):
+ connect('root', '/', self.index)
+ connect('get', '/get/{what}/{id}', self.get,
+ conditions=dict(method=["GET", "HEAD"]))
+ connect('static', '/static/{name}', self.static,
+ conditions=dict(method=["GET", "HEAD"]))
+
# Utility methods {{{
def last_modified(self, updated):
'''
@@ -68,8 +75,7 @@ class ContentServer(object):
# }}}
- @expose
- def get(self, what, id, *args, **kwargs):
+ def get(self, what, id):
'Serves files, covers, thumbnails from the calibre database'
try:
id = int(id)
@@ -87,7 +93,6 @@ class ContentServer(object):
return self.get_cover(id)
return self.get_format(id, what)
- @expose
def static(self, name):
'Serves static content'
name = name.lower()
@@ -108,7 +113,6 @@ class ContentServer(object):
cherrypy.response.headers['Last-Modified'] = self.last_modified(lm)
return open(path, 'rb').read()
- @expose
def index(self, **kwargs):
'The / URL'
ua = cherrypy.request.headers.get('User-Agent', '').strip()
diff --git a/src/calibre/library/server/mobile.py b/src/calibre/library/server/mobile.py
index 9bec6cce35..afb31815d5 100644
--- a/src/calibre/library/server/mobile.py
+++ b/src/calibre/library/server/mobile.py
@@ -11,7 +11,7 @@ import __builtin__
import cherrypy
from calibre.utils.genshi.template import MarkupTemplate
-from calibre.library.server.utils import strftime, expose
+from calibre.library.server.utils import strftime
from calibre.ebooks.metadata import fmt_sidx
# Templates {{{
@@ -173,7 +173,9 @@ class MobileServer(object):
MOBILE_UA = re.compile('(?i)(?:iPhone|Opera Mini|NetFront|webOS|Mobile|Android|imode|DoCoMo|Minimo|Blackberry|MIDP|Symbian|HD2)')
- @expose
+ def add_routes(self, connect):
+ connect('mobile', '/mobile', self.mobile)
+
def mobile(self, start='1', num='25', sort='date', search='',
_=None, order='descending'):
'''
diff --git a/src/calibre/library/server/opds.py b/src/calibre/library/server/opds.py
index f7a7679813..359449a838 100644
--- a/src/calibre/library/server/opds.py
+++ b/src/calibre/library/server/opds.py
@@ -5,15 +5,102 @@ __license__ = 'GPL v3'
__copyright__ = '2010, Kovid Goyal '
__docformat__ = 'restructuredtext en'
-import re
+import re, hashlib
from itertools import repeat
+from functools import partial
import cherrypy
+from lxml import etree
+from lxml.builder import ElementMaker
from calibre.utils.genshi.template import MarkupTemplate
from calibre.library.server.utils import strftime, expose
from calibre.ebooks.metadata import fmt_sidx, title_sort
from calibre import guess_type, prepare_string_for_xml
+from calibre.constants import __appname__
+
+# Vocabulary for building OPDS feeds {{{
+E = ElementMaker(namespace='http://www.w3.org/2005/Atom',
+ nsmap={
+ None : 'http://www.w3.org/2005/Atom',
+ 'dc' : 'http://purl.org/dc/terms/',
+ 'opds' : 'http://opds-spec.org/2010/catalog',
+ })
+
+
+FEED = E.feed
+TITLE = E.title
+ID = E.id
+
+def UPDATED(dt, *args, **kwargs):
+ return E.updated(dt.strftime('%Y-%m-%dT%H:%M:%S+00:00'), *args, **kwargs)
+
+LINK = partial(E.link, type='application/atom+xml')
+NAVLINK = partial(E.link,
+ type='application/atom+xml;type=feed;profile=opds-catalog')
+
+def SEARCH(base_href, *args, **kwargs):
+ kwargs['rel'] = 'search'
+ kwargs['title'] = 'Search'
+ kwargs['href'] = base_href+'/?search={searchTerms}'
+ return LINK(*args, **kwargs)
+
+def AUTHOR(name, uri=None):
+ args = [E.name(name)]
+ if uri is not None:
+ args.append(E.uri(uri))
+ return E.author(*args)
+
+SUBTITLE = E.subtitle
+
+def NAVCATALOG_ENTRY(base_href, updated, title, description, query_data):
+ data = [u'%s=%s'%(key, val) for key, val in query_data.items()]
+ data = '&'.join(data)
+ href = base_href+'/?'+data
+ id_ = 'calibre-subcatalog:'+str(hashlib.sha1(href).hexdigest())
+ return E.entry(
+ TITLE(title),
+ ID(id_),
+ UPDATED(updated),
+ E.content(description, type='text'),
+ NAVLINK(href=href)
+ )
+
+# }}}
+
+class Feed(object):
+
+ def __str__(self):
+ return etree.tostring(self.root, pretty_print=True, encoding='utf-8',
+ xml_declaration=True)
+
+class TopLevel(Feed):
+
+ def __init__(self,
+ updated, # datetime object in UTC
+ categories,
+ id_ = 'urn:calibre:main',
+ base_href = '/stanza'
+ ):
+ self.base_href = base_href
+ subc = partial(NAVCATALOG_ENTRY, base_href, updated)
+
+ subcatalogs = [subc('By '+title,
+ 'Books sorted by '+desc, {'sortby':q}) for title, desc, q in
+ categories]
+
+ self.root = \
+ FEED(
+ TITLE(__appname__ + ' ' + _('Library')),
+ ID(id_),
+ UPDATED(updated),
+ SEARCH(base_href),
+ AUTHOR(__appname__, uri='http://calibre-ebook.com'),
+ SUBTITLE(_('Books in your library')),
+ *subcatalogs
+ )
+
+
# Templates {{{
@@ -42,6 +129,7 @@ STANZA_SUBCATALOG_ENTRY=MarkupTemplate('''\
''')
+# Feed of books
STANZA = MarkupTemplate('''\
@@ -63,62 +151,20 @@ STANZA = MarkupTemplate('''\
''')
-STANZA_MAIN = MarkupTemplate('''\
-
-
- calibre Library
- $id
- ${updated.strftime('%Y-%m-%dT%H:%M:%S+00:00')}
-
-
- calibre
- http://calibre-ebook.com
-
-
- ${subtitle}
-
-
- By Author
- urn:uuid:fc000fa0-8c23-11de-a31d-0002a5d5c51b
- ${updated.strftime('%Y-%m-%dT%H:%M:%S+00:00')}
-
- Books sorted by Author
-
-
- By Title
- urn:uuid:1df4fe40-8c24-11de-b4c6-0002a5d5c51b
- ${updated.strftime('%Y-%m-%dT%H:%M:%S+00:00')}
-
- Books sorted by Title
-
-
- By Newest
- urn:uuid:3c6d4940-8c24-11de-a4d7-0002a5d5c51b
- ${updated.strftime('%Y-%m-%dT%H:%M:%S+00:00')}
-
- Books sorted by Date
-
-
- By Tag
- urn:uuid:824921e8-db8a-4e61-7d38-f1ce41502853
- ${updated.strftime('%Y-%m-%dT%H:%M:%S+00:00')}
-
- Books sorted by Tags
-
-
- By Series
- urn:uuid:512a5e50-a88f-f6b8-82aa-8f129c719f61
- ${updated.strftime('%Y-%m-%dT%H:%M:%S+00:00')}
-
- Books sorted by Series
-
-
-''')
# }}}
class OPDSServer(object):
+ def build_top_level(self, updated, base_href='/stanza'):
+ categories = self.categories_cache
+ categories = [(x.capitalize(), x.capitalize(), x) for x in
+ categories.keys()]
+ categories.append(('Title', 'Title', '|title|'))
+ categories.append(('Newest', 'Newest', '|newest|'))
+
+ return TopLevel(updated, categories, base_href=base_href)
+
def get_matches(self, location, query):
base = self.db.data.get_matches(location, query)
epub = self.db.data.get_matches('format', '=epub')
@@ -173,10 +219,6 @@ class OPDSServer(object):
return STANZA.generate(subtitle=subtitle, data=entries, FM=self.db.FIELD_MAP,
updated=updated, id='urn:calibre:main', next_link=next_link).render('xml')
- def stanza_main(self, updated):
- return STANZA_MAIN.generate(subtitle='', data=[], FM=self.db.FIELD_MAP,
- updated=updated, id='urn:calibre:main').render('xml')
-
@expose
def stanza(self, search=None, sortby=None, authorid=None, tagid=None,
seriesid=None, offset=0):
@@ -186,9 +228,11 @@ class OPDSServer(object):
offset = int(offset)
cherrypy.response.headers['Last-Modified'] = self.last_modified(updated)
cherrypy.response.headers['Content-Type'] = 'text/xml'
- # Main feed
+
+ # Top Level feed
if not sortby and not search and not authorid and not tagid and not seriesid:
- return self.stanza_main(updated)
+ return str(self.build_top_level(updated))
+
if sortby in ('byseries', 'byauthor', 'bytag'):
return self.stanza_sortby_subcategory(updated, sortby, offset)
@@ -296,5 +340,8 @@ class OPDSServer(object):
next_link=next_link, updated=updated, id='urn:calibre:main').render('xml')
-
+if __name__ == '__main__':
+ from datetime import datetime
+ f = TopLevel(datetime.utcnow())
+ print f
diff --git a/src/calibre/library/server/utils.py b/src/calibre/library/server/utils.py
index 7dc0884e1a..ad5aaac169 100644
--- a/src/calibre/library/server/utils.py
+++ b/src/calibre/library/server/utils.py
@@ -7,34 +7,33 @@ __docformat__ = 'restructuredtext en'
import time
+import cherrypy
+
from calibre import strftime as _strftime, prints
from calibre.utils.date import now as nowf
def expose(func):
- import cherrypy
- def do(self, *args, **kwargs):
+ def do(*args, **kwargs):
+ self = func.im_self
+ if self.opts.develop:
+ start = time.time()
+
dict.update(cherrypy.response.headers, {'Server':self.server_name})
if not self.embedded:
self.db.check_if_modified()
- return func(self, *args, **kwargs)
-
- return cherrypy.expose(do)
-
-def timeit(func):
-
- def do(self, *args, **kwargs):
- if self.opts.develop:
- start = time.time()
- ans = func(self, *args, **kwargs)
+ ans = func(*args, **kwargs)
if self.opts.develop:
prints('Function', func.__name__, 'called with args:', args, kwargs)
prints('\tTime:', func.__name__, time.time()-start)
return ans
+ do.__name__ = func.__name__
+
return do
+
def strftime(fmt='%Y/%m/%d %H:%M:%S', dt=None):
if not hasattr(dt, 'timetuple'):
dt = nowf()
diff --git a/src/calibre/library/server/xml.py b/src/calibre/library/server/xml.py
index e9f9a02548..036a2051bf 100644
--- a/src/calibre/library/server/xml.py
+++ b/src/calibre/library/server/xml.py
@@ -5,52 +5,26 @@ __license__ = 'GPL v3'
__copyright__ = '2010, Kovid Goyal '
__docformat__ = 'restructuredtext en'
-import copy, __builtin__
+import __builtin__
import cherrypy
+from lxml.builder import ElementMaker
+from lxml import etree
-from calibre.utils.genshi.template import MarkupTemplate
-from calibre.library.server.utils import strftime, expose
+from calibre.library.server.utils import strftime
from calibre.ebooks.metadata import fmt_sidx
+from calibre.constants import preferred_encoding
+from calibre import isbytestring
-# Templates {{{
-BOOK = '''\
-${r[FM['comments']] if r[FM['comments']] else ''}
-
-'''
-
-
-LIBRARY = MarkupTemplate('''\
-
-
-
- ${Markup(book)}
-
-
-''')
-
-# }}}
+E = ElementMaker()
class XMLServer(object):
'Serves XML and the Ajax based HTML frontend'
- @expose
- def library(self, start='0', num='50', sort=None, search=None,
+ def add_routes(self, connect):
+ connect('xml', '/xml', self.xml)
+
+ def xml(self, start='0', num='50', sort=None, search=None,
_=None, order='ascending'):
'''
Serves metadata from the calibre database as XML.
@@ -68,30 +42,63 @@ class XMLServer(object):
num = int(num)
except ValueError:
raise cherrypy.HTTPError(400, 'num: %s is not an integer'%num)
+
order = order.lower().strip() == 'ascending'
+
ids = self.db.data.parse(search) if search and search.strip() else self.db.data.universal_set()
- ids = sorted(ids)
+
FM = self.db.FIELD_MAP
- items = copy.deepcopy([r for r in iter(self.db) if r[FM['id']] in ids])
+
+ items = [r for r in iter(self.db) if r[FM['id']] in ids]
if sort is not None:
self.sort(items, sort, order)
- book, books = MarkupTemplate(BOOK), []
+
+ books = []
+
+ def serialize(x):
+ if isinstance(x, unicode):
+ return x
+ if isbytestring(x):
+ return x.decode(preferred_encoding, 'replace')
+ return unicode(x)
+
for record in items[start:start+num]:
+ kwargs = {}
aus = record[FM['authors']] if record[FM['authors']] else __builtin__._('Unknown')
authors = '|'.join([i.replace('|', ',') for i in aus.split(',')])
- record[FM['series_index']] = \
+ kwargs['authors'] = authors
+
+ kwargs['series_index'] = \
fmt_sidx(float(record[FM['series_index']]))
- ts, pd = strftime('%Y/%m/%d %H:%M:%S', record[FM['timestamp']]), \
- strftime('%Y/%m/%d %H:%M:%S', record[FM['pubdate']])
- books.append(book.generate(r=record, authors=authors, timestamp=ts,
- pubdate=pd, FM=FM).render('xml').decode('utf-8'))
+
+ for x in ('timestamp', 'pubdate'):
+ kwargs[x] = strftime('%Y/%m/%d %H:%M:%S', record[FM[x]])
+
+ for x in ('id', 'title', 'sort', 'author_sort', 'rating', 'size'):
+ kwargs[x] = serialize(record[FM[x]])
+
+ for x in ('isbn', 'formats', 'series', 'tags', 'publisher',
+ 'comments'):
+ y = record[FM[x]]
+ kwargs[x] = serialize(y) if y else ''
+
+ c = kwargs.pop('comments')
+ books.append(E.book(c, **kwargs))
+
updated = self.db.last_modified()
+ kwargs = dict(
+ start = str(start),
+ updated=updated.strftime('%Y-%m-%dT%H:%M:%S+00:00'),
+ total=str(len(ids)),
+ num=str(len(books)))
+ ans = E.library(*books, **kwargs)
cherrypy.response.headers['Content-Type'] = 'text/xml'
cherrypy.response.headers['Last-Modified'] = self.last_modified(updated)
- return LIBRARY.generate(books=books, start=start, updated=updated,
- total=len(ids), FM=FM).render('xml')
+
+ return etree.tostring(ans, encoding='utf-8', pretty_print=True,
+ xml_declaration=True)
diff --git a/src/routes/__init__.py b/src/routes/__init__.py
new file mode 100644
index 0000000000..d252c700e4
--- /dev/null
+++ b/src/routes/__init__.py
@@ -0,0 +1,142 @@
+"""Provides common classes and functions most users will want access to."""
+import threading, sys
+
+class _RequestConfig(object):
+ """
+ RequestConfig thread-local singleton
+
+ The Routes RequestConfig object is a thread-local singleton that should
+ be initialized by the web framework that is utilizing Routes.
+ """
+ __shared_state = threading.local()
+
+ def __getattr__(self, name):
+ return getattr(self.__shared_state, name)
+
+ def __setattr__(self, name, value):
+ """
+ If the name is environ, load the wsgi envion with load_wsgi_environ
+ and set the environ
+ """
+ if name == 'environ':
+ self.load_wsgi_environ(value)
+ return self.__shared_state.__setattr__(name, value)
+ return self.__shared_state.__setattr__(name, value)
+
+ def __delattr__(self, name):
+ delattr(self.__shared_state, name)
+
+ def load_wsgi_environ(self, environ):
+ """
+ Load the protocol/server info from the environ and store it.
+ Also, match the incoming URL if there's already a mapper, and
+ store the resulting match dict in mapper_dict.
+ """
+ if 'HTTPS' in environ or environ.get('wsgi.url_scheme') == 'https' \
+ or environ.get('HTTP_X_FORWARDED_PROTO') == 'https':
+ self.__shared_state.protocol = 'https'
+ else:
+ self.__shared_state.protocol = 'http'
+ try:
+ self.mapper.environ = environ
+ except AttributeError:
+ pass
+
+ # Wrap in try/except as common case is that there is a mapper
+ # attached to self
+ try:
+ if 'PATH_INFO' in environ:
+ mapper = self.mapper
+ path = environ['PATH_INFO']
+ result = mapper.routematch(path)
+ if result is not None:
+ self.__shared_state.mapper_dict = result[0]
+ self.__shared_state.route = result[1]
+ else:
+ self.__shared_state.mapper_dict = None
+ self.__shared_state.route = None
+ except AttributeError:
+ pass
+
+ if 'HTTP_X_FORWARDED_HOST' in environ:
+ self.__shared_state.host = environ['HTTP_X_FORWARDED_HOST']
+ elif 'HTTP_HOST' in environ:
+ self.__shared_state.host = environ['HTTP_HOST']
+ else:
+ self.__shared_state.host = environ['SERVER_NAME']
+ if environ['wsgi.url_scheme'] == 'https':
+ if environ['SERVER_PORT'] != '443':
+ self.__shared_state.host += ':' + environ['SERVER_PORT']
+ else:
+ if environ['SERVER_PORT'] != '80':
+ self.__shared_state.host += ':' + environ['SERVER_PORT']
+
+def request_config(original=False):
+ """
+ Returns the Routes RequestConfig object.
+
+ To get the Routes RequestConfig:
+
+ >>> from routes import *
+ >>> config = request_config()
+
+ The following attributes must be set on the config object every request:
+
+ mapper
+ mapper should be a Mapper instance thats ready for use
+ host
+ host is the hostname of the webapp
+ protocol
+ protocol is the protocol of the current request
+ mapper_dict
+ mapper_dict should be the dict returned by mapper.match()
+ redirect
+ redirect should be a function that issues a redirect,
+ and takes a url as the sole argument
+ prefix (optional)
+ Set if the application is moved under a URL prefix. Prefix
+ will be stripped before matching, and prepended on generation
+ environ (optional)
+ Set to the WSGI environ for automatic prefix support if the
+ webapp is underneath a 'SCRIPT_NAME'
+
+ Setting the environ will use information in environ to try and
+ populate the host/protocol/mapper_dict options if you've already
+ set a mapper.
+
+ **Using your own requst local**
+
+ If you have your own request local object that you'd like to use instead
+ of the default thread local provided by Routes, you can configure Routes
+ to use it::
+
+ from routes import request_config()
+ config = request_config()
+ if hasattr(config, 'using_request_local'):
+ config.request_local = YourLocalCallable
+ config = request_config()
+
+ Once you have configured request_config, its advisable you retrieve it
+ again to get the object you wanted. The variable you assign to
+ request_local is assumed to be a callable that will get the local config
+ object you wish.
+
+ This example tests for the presence of the 'using_request_local' attribute
+ which will be present if you haven't assigned it yet. This way you can
+ avoid repeat assignments of the request specific callable.
+
+ Should you want the original object, perhaps to change the callable its
+ using or stop this behavior, call request_config(original=True).
+ """
+ obj = _RequestConfig()
+ try:
+ if obj.request_local and original is False:
+ return getattr(obj, 'request_local')()
+ except AttributeError:
+ obj.request_local = False
+ obj.using_request_local = False
+ return _RequestConfig()
+
+from routes.mapper import Mapper
+from routes.util import redirect_to, url_for, URLGenerator
+__all__=['Mapper', 'url_for', 'URLGenerator', 'redirect_to', 'request_config']
diff --git a/src/routes/base.py b/src/routes/base.py
new file mode 100644
index 0000000000..f9e2f64973
--- /dev/null
+++ b/src/routes/base.py
@@ -0,0 +1,4 @@
+"""Route and Mapper core classes"""
+from routes import request_config
+from routes.mapper import Mapper
+from routes.route import Route
diff --git a/src/routes/lru.py b/src/routes/lru.py
new file mode 100644
index 0000000000..9fb2329e44
--- /dev/null
+++ b/src/routes/lru.py
@@ -0,0 +1,70 @@
+"""LRU caching class and decorator"""
+import threading
+
+_marker = object()
+
+class LRUCache(object):
+ def __init__(self, size):
+ """ Implements a psueudo-LRU algorithm (CLOCK) """
+ if size < 1:
+ raise ValueError('size must be >1')
+ self.clock = []
+ for i in xrange(0, size):
+ self.clock.append({'key':_marker, 'ref':False})
+ self.size = size
+ self.maxpos = size - 1
+ self.hand = 0
+ self.data = {}
+ self.lock = threading.Lock()
+
+ def __contains__(self, key):
+ return key in self.data
+
+ def __getitem__(self, key, default=None):
+ try:
+ datum = self.data[key]
+ except KeyError:
+ return default
+ pos, val = datum
+ self.clock[pos]['ref'] = True
+ hand = pos + 1
+ if hand > self.maxpos:
+ hand = 0
+ self.hand = hand
+ return val
+
+ def __setitem__(self, key, val, _marker=_marker):
+ hand = self.hand
+ maxpos = self.maxpos
+ clock = self.clock
+ data = self.data
+ lock = self.lock
+
+ end = hand - 1
+ if end < 0:
+ end = maxpos
+
+ while 1:
+ current = clock[hand]
+ ref = current['ref']
+ if ref is True:
+ current['ref'] = False
+ hand = hand + 1
+ if hand > maxpos:
+ hand = 0
+ elif ref is False or hand == end:
+ lock.acquire()
+ try:
+ oldkey = current['key']
+ if oldkey in data:
+ del data[oldkey]
+ current['key'] = key
+ current['ref'] = True
+ data[key] = (hand, val)
+ hand += 1
+ if hand > maxpos:
+ hand = 0
+ self.hand = hand
+ finally:
+ lock.release()
+ break
\ No newline at end of file
diff --git a/src/routes/mapper.py b/src/routes/mapper.py
new file mode 100644
index 0000000000..50f7482580
--- /dev/null
+++ b/src/routes/mapper.py
@@ -0,0 +1,1161 @@
+"""Mapper and Sub-Mapper"""
+import re
+import sys
+import threading
+
+from routes import request_config
+from routes.lru import LRUCache
+from routes.util import controller_scan, MatchException, RoutesException
+from routes.route import Route
+
+
+COLLECTION_ACTIONS = ['index', 'create', 'new']
+MEMBER_ACTIONS = ['show', 'update', 'delete', 'edit']
+
+
+def strip_slashes(name):
+ """Remove slashes from the beginning and end of a part/URL."""
+ if name.startswith('/'):
+ name = name[1:]
+ if name.endswith('/'):
+ name = name[:-1]
+ return name
+
+
+class SubMapperParent(object):
+ """Base class for Mapper and SubMapper, both of which may be the parent
+ of SubMapper objects
+ """
+
+ def submapper(self, **kargs):
+ """Create a partial version of the Mapper with the designated
+ options set
+
+ This results in a :class:`routes.mapper.SubMapper` object.
+
+ If keyword arguments provided to this method also exist in the
+ keyword arguments provided to the submapper, their values will
+ be merged with the saved options going first.
+
+ In addition to :class:`routes.route.Route` arguments, submapper
+ can also take a ``path_prefix`` argument which will be
+ prepended to the path of all routes that are connected.
+
+ Example::
+
+ >>> map = Mapper(controller_scan=None)
+ >>> map.connect('home', '/', controller='home', action='splash')
+ >>> map.matchlist[0].name == 'home'
+ True
+ >>> m = map.submapper(controller='home')
+ >>> m.connect('index', '/index', action='index')
+ >>> map.matchlist[1].name == 'index'
+ True
+ >>> map.matchlist[1].defaults['controller'] == 'home'
+ True
+
+ Optional ``collection_name`` and ``resource_name`` arguments are
+ used in the generation of route names by the ``action`` and
+ ``link`` methods. These in turn are used by the ``index``,
+ ``new``, ``create``, ``show``, ``edit``, ``update`` and
+ ``delete`` methods which may be invoked indirectly by listing
+ them in the ``actions`` argument. If the ``formatted`` argument
+ is set to ``True`` (the default), generated paths are given the
+ suffix '{.format}' which matches or generates an optional format
+ extension.
+
+ Example::
+
+ >>> from routes.util import url_for
+ >>> map = Mapper(controller_scan=None)
+ >>> m = map.submapper(path_prefix='/entries', collection_name='entries', resource_name='entry', actions=['index', 'new'])
+ >>> url_for('entries') == '/entries'
+ True
+ >>> url_for('new_entry', format='xml') == '/entries/new.xml'
+ True
+
+ """
+ return SubMapper(self, **kargs)
+
+ def collection(self, collection_name, resource_name, path_prefix=None,
+ member_prefix='/{id}', controller=None,
+ collection_actions=COLLECTION_ACTIONS,
+ member_actions = MEMBER_ACTIONS, member_options=None,
+ **kwargs):
+ """Create a submapper that represents a collection.
+
+ This results in a :class:`routes.mapper.SubMapper` object, with a
+ ``member`` property of the same type that represents the collection's
+ member resources.
+
+ Its interface is the same as the ``submapper`` together with
+ ``member_prefix``, ``member_actions`` and ``member_options``
+ which are passed to the ``member` submatter as ``path_prefix``,
+ ``actions`` and keyword arguments respectively.
+
+ Example::
+
+ >>> from routes.util import url_for
+ >>> map = Mapper(controller_scan=None)
+ >>> c = map.collection('entries', 'entry')
+ >>> c.member.link('ping', method='POST')
+ >>> url_for('entries') == '/entries'
+ True
+ >>> url_for('edit_entry', id=1) == '/entries/1/edit'
+ True
+ >>> url_for('ping_entry', id=1) == '/entries/1/ping'
+ True
+
+ """
+ if controller is None:
+ controller = resource_name or collection_name
+
+ if path_prefix is None:
+ path_prefix = '/' + collection_name
+
+ collection = SubMapper(self, collection_name=collection_name,
+ resource_name=resource_name,
+ path_prefix=path_prefix, controller=controller,
+ actions=collection_actions, **kwargs)
+
+ collection.member = SubMapper(collection, path_prefix=member_prefix,
+ actions=member_actions,
+ **(member_options or {}))
+
+ return collection
+
+
+class SubMapper(SubMapperParent):
+ """Partial mapper for use with_options"""
+ def __init__(self, obj, resource_name=None, collection_name=None,
+ actions=None, formatted=None, **kwargs):
+ self.kwargs = kwargs
+ self.obj = obj
+ self.collection_name = collection_name
+ self.member = None
+ self.resource_name = resource_name \
+ or getattr(obj, 'resource_name', None) \
+ or kwargs.get('controller', None) \
+ or getattr(obj, 'controller', None)
+ if formatted is not None:
+ self.formatted = formatted
+ else:
+ self.formatted = getattr(obj, 'formatted', None)
+ if self.formatted is None:
+ self.formatted = True
+
+ self.add_actions(actions or [])
+
+ def connect(self, *args, **kwargs):
+ newkargs = {}
+ newargs = args
+ for key, value in self.kwargs.items():
+ if key == 'path_prefix':
+ if len(args) > 1:
+ newargs = (args[0], self.kwargs[key] + args[1])
+ else:
+ newargs = (self.kwargs[key] + args[0],)
+ elif key in kwargs:
+ if isinstance(value, dict):
+ newkargs[key] = dict(value, **kwargs[key]) # merge dicts
+ else:
+ newkargs[key] = value + kwargs[key]
+ else:
+ newkargs[key] = self.kwargs[key]
+ for key in kwargs:
+ if key not in self.kwargs:
+ newkargs[key] = kwargs[key]
+ return self.obj.connect(*newargs, **newkargs)
+
+ def link(self, rel=None, name=None, action=None, method='GET',
+ formatted=None, **kwargs):
+ """Generates a named route for a subresource.
+
+ Example::
+
+ >>> from routes.util import url_for
+ >>> map = Mapper(controller_scan=None)
+ >>> c = map.collection('entries', 'entry')
+ >>> c.link('recent', name='recent_entries')
+ >>> c.member.link('ping', method='POST', formatted=True)
+ >>> url_for('entries') == '/entries'
+ True
+ >>> url_for('recent_entries') == '/entries/recent'
+ True
+ >>> url_for('ping_entry', id=1) == '/entries/1/ping'
+ True
+ >>> url_for('ping_entry', id=1, format='xml') == '/entries/1/ping.xml'
+ True
+
+ """
+ if formatted or (formatted is None and self.formatted):
+ suffix = '{.format}'
+ else:
+ suffix = ''
+
+ return self.connect(name or (rel + '_' + self.resource_name),
+ '/' + (rel or name) + suffix,
+ action=action or rel or name,
+ **_kwargs_with_conditions(kwargs, method))
+
+ def new(self, **kwargs):
+ """Generates the "new" link for a collection submapper."""
+ return self.link(rel='new', **kwargs)
+
+ def edit(self, **kwargs):
+ """Generates the "edit" link for a collection member submapper."""
+ return self.link(rel='edit', **kwargs)
+
+ def action(self, name=None, action=None, method='GET', formatted=None,
+ **kwargs):
+ """Generates a named route at the base path of a submapper.
+
+ Example::
+
+ >>> from routes import url_for
+ >>> map = Mapper(controller_scan=None)
+ >>> c = map.submapper(path_prefix='/entries', controller='entry')
+ >>> c.action(action='index', name='entries', formatted=True)
+ >>> c.action(action='create', method='POST')
+ >>> url_for(controller='entry', action='index', method='GET') == '/entries'
+ True
+ >>> url_for(controller='entry', action='index', method='GET', format='xml') == '/entries.xml'
+ True
+ >>> url_for(controller='entry', action='create', method='POST') == '/entries'
+ True
+
+ """
+ if formatted or (formatted is None and self.formatted):
+ suffix = '{.format}'
+ else:
+ suffix = ''
+ return self.connect(name or (action + '_' + self.resource_name),
+ suffix,
+ action=action or name,
+ **_kwargs_with_conditions(kwargs, method))
+
+ def index(self, name=None, **kwargs):
+ """Generates the "index" action for a collection submapper."""
+ return self.action(name=name or self.collection_name,
+ action='index', method='GET', **kwargs)
+
+ def show(self, name = None, **kwargs):
+ """Generates the "show" action for a collection member submapper."""
+ return self.action(name=name or self.resource_name,
+ action='show', method='GET', **kwargs)
+
+ def create(self, **kwargs):
+ """Generates the "create" action for a collection submapper."""
+ return self.action(action='create', method='POST', **kwargs)
+
+ def update(self, **kwargs):
+ """Generates the "update" action for a collection member submapper."""
+ return self.action(action='update', method='PUT', **kwargs)
+
+ def delete(self, **kwargs):
+ """Generates the "delete" action for a collection member submapper."""
+ return self.action(action='delete', method='DELETE', **kwargs)
+
+ def add_actions(self, actions):
+ [getattr(self, action)() for action in actions]
+
+ # Provided for those who prefer using the 'with' syntax in Python 2.5+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, type, value, tb):
+ pass
+
+# Create kwargs with a 'conditions' member generated for the given method
+def _kwargs_with_conditions(kwargs, method):
+ if method and 'conditions' not in kwargs:
+ newkwargs = kwargs.copy()
+ newkwargs['conditions'] = {'method': method}
+ return newkwargs
+ else:
+ return kwargs
+
+
+
+class Mapper(SubMapperParent):
+ """Mapper handles URL generation and URL recognition in a web
+ application.
+
+ Mapper is built handling dictionary's. It is assumed that the web
+ application will handle the dictionary returned by URL recognition
+ to dispatch appropriately.
+
+ URL generation is done by passing keyword parameters into the
+ generate function, a URL is then returned.
+
+ """
+ def __init__(self, controller_scan=controller_scan, directory=None,
+ always_scan=False, register=True, explicit=True):
+ """Create a new Mapper instance
+
+ All keyword arguments are optional.
+
+ ``controller_scan``
+ Function reference that will be used to return a list of
+ valid controllers used during URL matching. If
+ ``directory`` keyword arg is present, it will be passed
+ into the function during its call. This option defaults to
+ a function that will scan a directory for controllers.
+
+ Alternatively, a list of controllers or None can be passed
+ in which are assumed to be the definitive list of
+ controller names valid when matching 'controller'.
+
+ ``directory``
+ Passed into controller_scan for the directory to scan. It
+ should be an absolute path if using the default
+ ``controller_scan`` function.
+
+ ``always_scan``
+ Whether or not the ``controller_scan`` function should be
+ run during every URL match. This is typically a good idea
+ during development so the server won't need to be restarted
+ anytime a controller is added.
+
+ ``register``
+ Boolean used to determine if the Mapper should use
+ ``request_config`` to register itself as the mapper. Since
+ it's done on a thread-local basis, this is typically best
+ used during testing though it won't hurt in other cases.
+
+ ``explicit``
+ Boolean used to determine if routes should be connected
+ with implicit defaults of::
+
+ {'controller':'content','action':'index','id':None}
+
+ When set to True, these defaults will not be added to route
+ connections and ``url_for`` will not use Route memory.
+
+ Additional attributes that may be set after mapper
+ initialization (ie, map.ATTRIBUTE = 'something'):
+
+ ``encoding``
+ Used to indicate alternative encoding/decoding systems to
+ use with both incoming URL's, and during Route generation
+ when passed a Unicode string. Defaults to 'utf-8'.
+
+ ``decode_errors``
+ How to handle errors in the encoding, generally ignoring
+ any chars that don't convert should be sufficient. Defaults
+ to 'ignore'.
+
+ ``minimization``
+ Boolean used to indicate whether or not Routes should
+ minimize URL's and the generated URL's, or require every
+ part where it appears in the path. Defaults to True.
+
+ ``hardcode_names``
+ Whether or not Named Routes result in the default options
+ for the route being used *or* if they actually force url
+ generation to use the route. Defaults to False.
+
+ """
+ self.matchlist = []
+ self.maxkeys = {}
+ self.minkeys = {}
+ self.urlcache = LRUCache(1600)
+ self._created_regs = False
+ self._created_gens = False
+ self._master_regexp = None
+ self.prefix = None
+ self.req_data = threading.local()
+ self.directory = directory
+ self.always_scan = always_scan
+ self.controller_scan = controller_scan
+ self._regprefix = None
+ self._routenames = {}
+ self.debug = False
+ self.append_slash = False
+ self.sub_domains = False
+ self.sub_domains_ignore = []
+ self.domain_match = '[^\.\/]+?\.[^\.\/]+'
+ self.explicit = explicit
+ self.encoding = 'utf-8'
+ self.decode_errors = 'ignore'
+ self.hardcode_names = True
+ self.minimization = False
+ self.create_regs_lock = threading.Lock()
+ if register:
+ config = request_config()
+ config.mapper = self
+
+ def __str__(self):
+ """Generates a tabular string representation."""
+ def format_methods(r):
+ if r.conditions:
+ method = r.conditions.get('method', '')
+ return type(method) is str and method or ', '.join(method)
+ else:
+ return ''
+
+ table = [('Route name', 'Methods', 'Path')] + \
+ [(r.name or '', format_methods(r), r.routepath or '')
+ for r in self.matchlist]
+
+ widths = [max(len(row[col]) for row in table)
+ for col in range(len(table[0]))]
+
+ return '\n'.join(
+ ' '.join(row[col].ljust(widths[col])
+ for col in range(len(widths)))
+ for row in table)
+
+ def _envget(self):
+ try:
+ return self.req_data.environ
+ except AttributeError:
+ return None
+ def _envset(self, env):
+ self.req_data.environ = env
+ def _envdel(self):
+ del self.req_data.environ
+ environ = property(_envget, _envset, _envdel)
+
+ def extend(self, routes, path_prefix=''):
+ """Extends the mapper routes with a list of Route objects
+
+ If a path_prefix is provided, all the routes will have their
+ path prepended with the path_prefix.
+
+ Example::
+
+ >>> map = Mapper(controller_scan=None)
+ >>> map.connect('home', '/', controller='home', action='splash')
+ >>> map.matchlist[0].name == 'home'
+ True
+ >>> routes = [Route('index', '/index.htm', controller='home',
+ ... action='index')]
+ >>> map.extend(routes)
+ >>> len(map.matchlist) == 2
+ True
+ >>> map.extend(routes, path_prefix='/subapp')
+ >>> len(map.matchlist) == 3
+ True
+ >>> map.matchlist[2].routepath == '/subapp/index.htm'
+ True
+
+ .. note::
+
+ This function does not merely extend the mapper with the
+ given list of routes, it actually creates new routes with
+ identical calling arguments.
+
+ """
+ for route in routes:
+ if path_prefix and route.minimization:
+ routepath = '/'.join([path_prefix, route.routepath])
+ elif path_prefix:
+ routepath = path_prefix + route.routepath
+ else:
+ routepath = route.routepath
+ self.connect(route.name, routepath, **route._kargs)
+
+ def connect(self, *args, **kargs):
+ """Create and connect a new Route to the Mapper.
+
+ Usage:
+
+ .. code-block:: python
+
+ m = Mapper()
+ m.connect(':controller/:action/:id')
+ m.connect('date/:year/:month/:day', controller="blog", action="view")
+ m.connect('archives/:page', controller="blog", action="by_page",
+ requirements = { 'page':'\d{1,2}' })
+ m.connect('category_list', 'archives/category/:section', controller='blog', action='category',
+ section='home', type='list')
+ m.connect('home', '', controller='blog', action='view', section='home')
+
+ """
+ routename = None
+ if len(args) > 1:
+ routename = args[0]
+ else:
+ args = (None,) + args
+ if '_explicit' not in kargs:
+ kargs['_explicit'] = self.explicit
+ if '_minimize' not in kargs:
+ kargs['_minimize'] = self.minimization
+ route = Route(*args, **kargs)
+
+ # Apply encoding and errors if its not the defaults and the route
+ # didn't have one passed in.
+ if (self.encoding != 'utf-8' or self.decode_errors != 'ignore') and \
+ '_encoding' not in kargs:
+ route.encoding = self.encoding
+ route.decode_errors = self.decode_errors
+
+ if not route.static:
+ self.matchlist.append(route)
+
+ if routename:
+ self._routenames[routename] = route
+ route.name = routename
+ if route.static:
+ return
+ exists = False
+ for key in self.maxkeys:
+ if key == route.maxkeys:
+ self.maxkeys[key].append(route)
+ exists = True
+ break
+ if not exists:
+ self.maxkeys[route.maxkeys] = [route]
+ self._created_gens = False
+
+ def _create_gens(self):
+ """Create the generation hashes for route lookups"""
+ # Use keys temporailly to assemble the list to avoid excessive
+ # list iteration testing with "in"
+ controllerlist = {}
+ actionlist = {}
+
+ # Assemble all the hardcoded/defaulted actions/controllers used
+ for route in self.matchlist:
+ if route.static:
+ continue
+ if route.defaults.has_key('controller'):
+ controllerlist[route.defaults['controller']] = True
+ if route.defaults.has_key('action'):
+ actionlist[route.defaults['action']] = True
+
+ # Setup the lists of all controllers/actions we'll add each route
+ # to. We include the '*' in the case that a generate contains a
+ # controller/action that has no hardcodes
+ controllerlist = controllerlist.keys() + ['*']
+ actionlist = actionlist.keys() + ['*']
+
+ # Go through our list again, assemble the controllers/actions we'll
+ # add each route to. If its hardcoded, we only add it to that dict key.
+ # Otherwise we add it to every hardcode since it can be changed.
+ gendict = {} # Our generated two-deep hash
+ for route in self.matchlist:
+ if route.static:
+ continue
+ clist = controllerlist
+ alist = actionlist
+ if 'controller' in route.hardcoded:
+ clist = [route.defaults['controller']]
+ if 'action' in route.hardcoded:
+ alist = [unicode(route.defaults['action'])]
+ for controller in clist:
+ for action in alist:
+ actiondict = gendict.setdefault(controller, {})
+ actiondict.setdefault(action, ([], {}))[0].append(route)
+ self._gendict = gendict
+ self._created_gens = True
+
+ def create_regs(self, *args, **kwargs):
+ """Atomically creates regular expressions for all connected
+ routes
+ """
+ self.create_regs_lock.acquire()
+ try:
+ self._create_regs(*args, **kwargs)
+ finally:
+ self.create_regs_lock.release()
+
+ def _create_regs(self, clist=None):
+ """Creates regular expressions for all connected routes"""
+ if clist is None:
+ if self.directory:
+ clist = self.controller_scan(self.directory)
+ elif callable(self.controller_scan):
+ clist = self.controller_scan()
+ elif not self.controller_scan:
+ clist = []
+ else:
+ clist = self.controller_scan
+
+ for key, val in self.maxkeys.iteritems():
+ for route in val:
+ route.makeregexp(clist)
+
+ regexps = []
+ routematches = []
+ for route in self.matchlist:
+ if not route.static:
+ routematches.append(route)
+ regexps.append(route.makeregexp(clist, include_names=False))
+ self._routematches = routematches
+
+ # Create our regexp to strip the prefix
+ if self.prefix:
+ self._regprefix = re.compile(self.prefix + '(.*)')
+
+ # Save the master regexp
+ regexp = '|'.join(['(?:%s)' % x for x in regexps])
+ self._master_reg = regexp
+ self._master_regexp = re.compile(regexp)
+ self._created_regs = True
+
+ def _match(self, url, environ):
+ """Internal Route matcher
+
+ Matches a URL against a route, and returns a tuple of the match
+ dict and the route object if a match is successfull, otherwise
+ it returns empty.
+
+ For internal use only.
+
+ """
+ if not self._created_regs and self.controller_scan:
+ self.create_regs()
+ elif not self._created_regs:
+ raise RoutesException("You must generate the regular expressions"
+ " before matching.")
+
+ if self.always_scan:
+ self.create_regs()
+
+ matchlog = []
+ if self.prefix:
+ if re.match(self._regprefix, url):
+ url = re.sub(self._regprefix, r'\1', url)
+ if not url:
+ url = '/'
+ else:
+ return (None, None, matchlog)
+
+ environ = environ or self.environ
+ sub_domains = self.sub_domains
+ sub_domains_ignore = self.sub_domains_ignore
+ domain_match = self.domain_match
+ debug = self.debug
+
+ # Check to see if its a valid url against the main regexp
+ # Done for faster invalid URL elimination
+ valid_url = re.match(self._master_regexp, url)
+ if not valid_url:
+ return (None, None, matchlog)
+
+ for route in self.matchlist:
+ if route.static:
+ if debug:
+ matchlog.append(dict(route=route, static=True))
+ continue
+ match = route.match(url, environ, sub_domains, sub_domains_ignore,
+ domain_match)
+ if debug:
+ matchlog.append(dict(route=route, regexp=bool(match)))
+ if isinstance(match, dict) or match:
+ return (match, route, matchlog)
+ return (None, None, matchlog)
+
+ def match(self, url=None, environ=None):
+ """Match a URL against against one of the routes contained.
+
+ Will return None if no valid match is found.
+
+ .. code-block:: python
+
+ resultdict = m.match('/joe/sixpack')
+
+ """
+ if not url and not environ:
+ raise RoutesException('URL or environ must be provided')
+
+ if not url:
+ url = environ['PATH_INFO']
+
+ result = self._match(url, environ)
+ if self.debug:
+ return result[0], result[1], result[2]
+ if isinstance(result[0], dict) or result[0]:
+ return result[0]
+ return None
+
+ def routematch(self, url=None, environ=None):
+ """Match a URL against against one of the routes contained.
+
+ Will return None if no valid match is found, otherwise a
+ result dict and a route object is returned.
+
+ .. code-block:: python
+
+ resultdict, route_obj = m.match('/joe/sixpack')
+
+ """
+ if not url and not environ:
+ raise RoutesException('URL or environ must be provided')
+
+ if not url:
+ url = environ['PATH_INFO']
+ result = self._match(url, environ)
+ if self.debug:
+ return result[0], result[1], result[2]
+ if isinstance(result[0], dict) or result[0]:
+ return result[0], result[1]
+ return None
+
+ def generate(self, *args, **kargs):
+ """Generate a route from a set of keywords
+
+ Returns the url text, or None if no URL could be generated.
+
+ .. code-block:: python
+
+ m.generate(controller='content',action='view',id=10)
+
+ """
+ # Generate ourself if we haven't already
+ if not self._created_gens:
+ self._create_gens()
+
+ if self.append_slash:
+ kargs['_append_slash'] = True
+
+ if not self.explicit:
+ if 'controller' not in kargs:
+ kargs['controller'] = 'content'
+ if 'action' not in kargs:
+ kargs['action'] = 'index'
+
+ controller = kargs.get('controller', None)
+ action = kargs.get('action', None)
+
+ # If the URL didn't depend on the SCRIPT_NAME, we'll cache it
+ # keyed by just by kargs; otherwise we need to cache it with
+ # both SCRIPT_NAME and kargs:
+ cache_key = unicode(args).encode('utf8') + \
+ unicode(kargs).encode('utf8')
+
+ if self.urlcache is not None:
+ if self.environ:
+ cache_key_script_name = '%s:%s' % (
+ self.environ.get('SCRIPT_NAME', ''), cache_key)
+ else:
+ cache_key_script_name = cache_key
+
+ # Check the url cache to see if it exists, use it if it does
+ for key in [cache_key, cache_key_script_name]:
+ if key in self.urlcache:
+ return self.urlcache[key]
+
+ actionlist = self._gendict.get(controller) or self._gendict.get('*', {})
+ if not actionlist and not args:
+ return None
+ (keylist, sortcache) = actionlist.get(action) or \
+ actionlist.get('*', (None, {}))
+ if not keylist and not args:
+ return None
+
+ keys = frozenset(kargs.keys())
+ cacheset = False
+ cachekey = unicode(keys)
+ cachelist = sortcache.get(cachekey)
+ if args:
+ keylist = args
+ elif cachelist:
+ keylist = cachelist
+ else:
+ cacheset = True
+ newlist = []
+ for route in keylist:
+ if len(route.minkeys - route.dotkeys - keys) == 0:
+ newlist.append(route)
+ keylist = newlist
+
+ def keysort(a, b):
+ """Sorts two sets of sets, to order them ideally for
+ matching."""
+ am = a.minkeys
+ a = a.maxkeys
+ b = b.maxkeys
+
+ lendiffa = len(keys^a)
+ lendiffb = len(keys^b)
+ # If they both match, don't switch them
+ if lendiffa == 0 and lendiffb == 0:
+ return 0
+
+ # First, if a matches exactly, use it
+ if lendiffa == 0:
+ return -1
+
+ # Or b matches exactly, use it
+ if lendiffb == 0:
+ return 1
+
+ # Neither matches exactly, return the one with the most in
+ # common
+ if cmp(lendiffa, lendiffb) != 0:
+ return cmp(lendiffa, lendiffb)
+
+ # Neither matches exactly, but if they both have just as much
+ # in common
+ if len(keys&b) == len(keys&a):
+ # Then we return the shortest of the two
+ return cmp(len(a), len(b))
+
+ # Otherwise, we return the one that has the most in common
+ else:
+ return cmp(len(keys&b), len(keys&a))
+
+ keylist.sort(keysort)
+ if cacheset:
+ sortcache[cachekey] = keylist
+
+ # Iterate through the keylist of sorted routes (or a single route if
+ # it was passed in explicitly for hardcoded named routes)
+ for route in keylist:
+ fail = False
+ for key in route.hardcoded:
+ kval = kargs.get(key)
+ if not kval:
+ continue
+ if isinstance(kval, str):
+ kval = kval.decode(self.encoding)
+ else:
+ kval = unicode(kval)
+ if kval != route.defaults[key] and not callable(route.defaults[key]):
+ fail = True
+ break
+ if fail:
+ continue
+ path = route.generate(**kargs)
+ if path:
+ if self.prefix:
+ path = self.prefix + path
+ external_static = route.static and route.external
+ if self.environ and self.environ.get('SCRIPT_NAME', '') != ''\
+ and not route.absolute and not external_static:
+ path = self.environ['SCRIPT_NAME'] + path
+ key = cache_key_script_name
+ else:
+ key = cache_key
+ if self.urlcache is not None:
+ self.urlcache[key] = str(path)
+ return str(path)
+ else:
+ continue
+ return None
+
+ def resource(self, member_name, collection_name, **kwargs):
+ """Generate routes for a controller resource
+
+ The member_name name should be the appropriate singular version
+ of the resource given your locale and used with members of the
+ collection. The collection_name name will be used to refer to
+ the resource collection methods and should be a plural version
+ of the member_name argument. By default, the member_name name
+ will also be assumed to map to a controller you create.
+
+ The concept of a web resource maps somewhat directly to 'CRUD'
+ operations. The overlying things to keep in mind is that
+ mapping a resource is about handling creating, viewing, and
+ editing that resource.
+
+ All keyword arguments are optional.
+
+ ``controller``
+ If specified in the keyword args, the controller will be
+ the actual controller used, but the rest of the naming
+ conventions used for the route names and URL paths are
+ unchanged.
+
+ ``collection``
+ Additional action mappings used to manipulate/view the
+ entire set of resources provided by the controller.
+
+ Example::
+
+ map.resource('message', 'messages', collection={'rss':'GET'})
+ # GET /message/rss (maps to the rss action)
+ # also adds named route "rss_message"
+
+ ``member``
+ Additional action mappings used to access an individual
+ 'member' of this controllers resources.
+
+ Example::
+
+ map.resource('message', 'messages', member={'mark':'POST'})
+ # POST /message/1/mark (maps to the mark action)
+ # also adds named route "mark_message"
+
+ ``new``
+ Action mappings that involve dealing with a new member in
+ the controller resources.
+
+ Example::
+
+ map.resource('message', 'messages', new={'preview':'POST'})
+ # POST /message/new/preview (maps to the preview action)
+ # also adds a url named "preview_new_message"
+
+ ``path_prefix``
+ Prepends the URL path for the Route with the path_prefix
+ given. This is most useful for cases where you want to mix
+ resources or relations between resources.
+
+ ``name_prefix``
+ Perpends the route names that are generated with the
+ name_prefix given. Combined with the path_prefix option,
+ it's easy to generate route names and paths that represent
+ resources that are in relations.
+
+ Example::
+
+ map.resource('message', 'messages', controller='categories',
+ path_prefix='/category/:category_id',
+ name_prefix="category_")
+ # GET /category/7/message/1
+ # has named route "category_message"
+
+ ``parent_resource``
+ A ``dict`` containing information about the parent
+ resource, for creating a nested resource. It should contain
+ the ``member_name`` and ``collection_name`` of the parent
+ resource. This ``dict`` will
+ be available via the associated ``Route`` object which can
+ be accessed during a request via
+ ``request.environ['routes.route']``
+
+ If ``parent_resource`` is supplied and ``path_prefix``
+ isn't, ``path_prefix`` will be generated from
+ ``parent_resource`` as
+ "/:_id".
+
+ If ``parent_resource`` is supplied and ``name_prefix``
+ isn't, ``name_prefix`` will be generated from
+ ``parent_resource`` as "_".
+
+ Example::
+
+ >>> from routes.util import url_for
+ >>> m = Mapper()
+ >>> m.resource('location', 'locations',
+ ... parent_resource=dict(member_name='region',
+ ... collection_name='regions'))
+ >>> # path_prefix is "regions/:region_id"
+ >>> # name prefix is "region_"
+ >>> url_for('region_locations', region_id=13)
+ '/regions/13/locations'
+ >>> url_for('region_new_location', region_id=13)
+ '/regions/13/locations/new'
+ >>> url_for('region_location', region_id=13, id=60)
+ '/regions/13/locations/60'
+ >>> url_for('region_edit_location', region_id=13, id=60)
+ '/regions/13/locations/60/edit'
+
+ Overriding generated ``path_prefix``::
+
+ >>> m = Mapper()
+ >>> m.resource('location', 'locations',
+ ... parent_resource=dict(member_name='region',
+ ... collection_name='regions'),
+ ... path_prefix='areas/:area_id')
+ >>> # name prefix is "region_"
+ >>> url_for('region_locations', area_id=51)
+ '/areas/51/locations'
+
+ Overriding generated ``name_prefix``::
+
+ >>> m = Mapper()
+ >>> m.resource('location', 'locations',
+ ... parent_resource=dict(member_name='region',
+ ... collection_name='regions'),
+ ... name_prefix='')
+ >>> # path_prefix is "regions/:region_id"
+ >>> url_for('locations', region_id=51)
+ '/regions/51/locations'
+
+ """
+ collection = kwargs.pop('collection', {})
+ member = kwargs.pop('member', {})
+ new = kwargs.pop('new', {})
+ path_prefix = kwargs.pop('path_prefix', None)
+ name_prefix = kwargs.pop('name_prefix', None)
+ parent_resource = kwargs.pop('parent_resource', None)
+
+ # Generate ``path_prefix`` if ``path_prefix`` wasn't specified and
+ # ``parent_resource`` was. Likewise for ``name_prefix``. Make sure
+ # that ``path_prefix`` and ``name_prefix`` *always* take precedence if
+ # they are specified--in particular, we need to be careful when they
+ # are explicitly set to "".
+ if parent_resource is not None:
+ if path_prefix is None:
+ path_prefix = '%s/:%s_id' % (parent_resource['collection_name'],
+ parent_resource['member_name'])
+ if name_prefix is None:
+ name_prefix = '%s_' % parent_resource['member_name']
+ else:
+ if path_prefix is None: path_prefix = ''
+ if name_prefix is None: name_prefix = ''
+
+ # Ensure the edit and new actions are in and GET
+ member['edit'] = 'GET'
+ new.update({'new': 'GET'})
+
+ # Make new dict's based off the old, except the old values become keys,
+ # and the old keys become items in a list as the value
+ def swap(dct, newdct):
+ """Swap the keys and values in the dict, and uppercase the values
+ from the dict during the swap."""
+ for key, val in dct.iteritems():
+ newdct.setdefault(val.upper(), []).append(key)
+ return newdct
+ collection_methods = swap(collection, {})
+ member_methods = swap(member, {})
+ new_methods = swap(new, {})
+
+ # Insert create, update, and destroy methods
+ collection_methods.setdefault('POST', []).insert(0, 'create')
+ member_methods.setdefault('PUT', []).insert(0, 'update')
+ member_methods.setdefault('DELETE', []).insert(0, 'delete')
+
+ # If there's a path prefix option, use it with the controller
+ controller = strip_slashes(collection_name)
+ path_prefix = strip_slashes(path_prefix)
+ path_prefix = '/' + path_prefix
+ if path_prefix and path_prefix != '/':
+ path = path_prefix + '/' + controller
+ else:
+ path = '/' + controller
+ collection_path = path
+ new_path = path + "/new"
+ member_path = path + "/:(id)"
+
+ options = {
+ 'controller': kwargs.get('controller', controller),
+ '_member_name': member_name,
+ '_collection_name': collection_name,
+ '_parent_resource': parent_resource,
+ '_filter': kwargs.get('_filter')
+ }
+
+ def requirements_for(meth):
+ """Returns a new dict to be used for all route creation as the
+ route options"""
+ opts = options.copy()
+ if method != 'any':
+ opts['conditions'] = {'method':[meth.upper()]}
+ return opts
+
+ # Add the routes for handling collection methods
+ for method, lst in collection_methods.iteritems():
+ primary = (method != 'GET' and lst.pop(0)) or None
+ route_options = requirements_for(method)
+ for action in lst:
+ route_options['action'] = action
+ route_name = "%s%s_%s" % (name_prefix, action, collection_name)
+ self.connect("formatted_" + route_name, "%s/%s.:(format)" % \
+ (collection_path, action), **route_options)
+ self.connect(route_name, "%s/%s" % (collection_path, action),
+ **route_options)
+ if primary:
+ route_options['action'] = primary
+ self.connect("%s.:(format)" % collection_path, **route_options)
+ self.connect(collection_path, **route_options)
+
+ # Specifically add in the built-in 'index' collection method and its
+ # formatted version
+ self.connect("formatted_" + name_prefix + collection_name,
+ collection_path + ".:(format)", action='index',
+ conditions={'method':['GET']}, **options)
+ self.connect(name_prefix + collection_name, collection_path,
+ action='index', conditions={'method':['GET']}, **options)
+
+ # Add the routes that deal with new resource methods
+ for method, lst in new_methods.iteritems():
+ route_options = requirements_for(method)
+ for action in lst:
+ path = (action == 'new' and new_path) or "%s/%s" % (new_path,
+ action)
+ name = "new_" + member_name
+ if action != 'new':
+ name = action + "_" + name
+ route_options['action'] = action
+ formatted_path = (action == 'new' and new_path + '.:(format)') or \
+ "%s/%s.:(format)" % (new_path, action)
+ self.connect("formatted_" + name_prefix + name, formatted_path,
+ **route_options)
+ self.connect(name_prefix + name, path, **route_options)
+
+ requirements_regexp = '[^\/]+'
+
+ # Add the routes that deal with member methods of a resource
+ for method, lst in member_methods.iteritems():
+ route_options = requirements_for(method)
+ route_options['requirements'] = {'id':requirements_regexp}
+ if method not in ['POST', 'GET', 'any']:
+ primary = lst.pop(0)
+ else:
+ primary = None
+ for action in lst:
+ route_options['action'] = action
+ self.connect("formatted_%s%s_%s" % (name_prefix, action,
+ member_name),
+ "%s/%s.:(format)" % (member_path, action), **route_options)
+ self.connect("%s%s_%s" % (name_prefix, action, member_name),
+ "%s/%s" % (member_path, action), **route_options)
+ if primary:
+ route_options['action'] = primary
+ self.connect("%s.:(format)" % member_path, **route_options)
+ self.connect(member_path, **route_options)
+
+ # Specifically add the member 'show' method
+ route_options = requirements_for('GET')
+ route_options['action'] = 'show'
+ route_options['requirements'] = {'id':requirements_regexp}
+ self.connect("formatted_" + name_prefix + member_name,
+ member_path + ".:(format)", **route_options)
+ self.connect(name_prefix + member_name, member_path, **route_options)
+
+ def redirect(self, match_path, destination_path, *args, **kwargs):
+ """Add a redirect route to the mapper
+
+ Redirect routes bypass the wrapped WSGI application and instead
+ result in a redirect being issued by the RoutesMiddleware. As
+ such, this method is only meaningful when using
+ RoutesMiddleware.
+
+ By default, a 302 Found status code is used, this can be
+ changed by providing a ``_redirect_code`` keyword argument
+ which will then be used instead. Note that the entire status
+ code string needs to be present.
+
+ When using keyword arguments, all arguments that apply to
+ matching will be used for the match, while generation specific
+ options will be used during generation. Thus all options
+ normally available to connected Routes may be used with
+ redirect routes as well.
+
+ Example::
+
+ map = Mapper()
+ map.redirect('/legacyapp/archives/{url:.*}, '/archives/{url})
+ map.redirect('/home/index', '/', _redirect_code='301 Moved Permanently')
+
+ """
+ both_args = ['_encoding', '_explicit', '_minimize']
+ gen_args = ['_filter']
+
+ status_code = kwargs.pop('_redirect_code', '302 Found')
+ gen_dict, match_dict = {}, {}
+
+ # Create the dict of args for the generation route
+ for key in both_args + gen_args:
+ if key in kwargs:
+ gen_dict[key] = kwargs[key]
+ gen_dict['_static'] = True
+
+ # Create the dict of args for the matching route
+ for key in kwargs:
+ if key not in gen_args:
+ match_dict[key] = kwargs[key]
+
+ self.connect(match_path, **match_dict)
+ match_route = self.matchlist[-1]
+
+ self.connect('_redirect_%s' % id(match_route), destination_path,
+ **gen_dict)
+ match_route.redirect = True
+ match_route.redirect_status = status_code
diff --git a/src/routes/middleware.py b/src/routes/middleware.py
new file mode 100644
index 0000000000..d4c005ee78
--- /dev/null
+++ b/src/routes/middleware.py
@@ -0,0 +1,146 @@
+"""Routes WSGI Middleware"""
+import re
+import logging
+
+from webob import Request
+
+from routes.base import request_config
+from routes.util import URLGenerator, url_for
+
+log = logging.getLogger('routes.middleware')
+
+class RoutesMiddleware(object):
+ """Routing middleware that handles resolving the PATH_INFO in
+ addition to optionally recognizing method overriding."""
+ def __init__(self, wsgi_app, mapper, use_method_override=True,
+ path_info=True, singleton=True):
+ """Create a Route middleware object
+
+ Using the use_method_override keyword will require Paste to be
+ installed, and your application should use Paste's WSGIRequest
+ object as it will properly handle POST issues with wsgi.input
+ should Routes check it.
+
+ If path_info is True, then should a route var contain
+ path_info, the SCRIPT_NAME and PATH_INFO will be altered
+ accordingly. This should be used with routes like:
+
+ .. code-block:: python
+
+ map.connect('blog/*path_info', controller='blog', path_info='')
+
+ """
+ self.app = wsgi_app
+ self.mapper = mapper
+ self.singleton = singleton
+ self.use_method_override = use_method_override
+ self.path_info = path_info
+ log_debug = self.log_debug = logging.DEBUG >= log.getEffectiveLevel()
+ if self.log_debug:
+ log.debug("Initialized with method overriding = %s, and path "
+ "info altering = %s", use_method_override, path_info)
+
+ def __call__(self, environ, start_response):
+ """Resolves the URL in PATH_INFO, and uses wsgi.routing_args
+ to pass on URL resolver results."""
+ old_method = None
+ if self.use_method_override:
+ req = None
+
+ # In some odd cases, there's no query string
+ try:
+ qs = environ['QUERY_STRING']
+ except KeyError:
+ qs = ''
+ if '_method' in qs:
+ req = Request(environ)
+ req.errors = 'ignore'
+ if '_method' in req.GET:
+ old_method = environ['REQUEST_METHOD']
+ environ['REQUEST_METHOD'] = req.GET['_method'].upper()
+ if self.log_debug:
+ log.debug("_method found in QUERY_STRING, altering request"
+ " method to %s", environ['REQUEST_METHOD'])
+ elif environ['REQUEST_METHOD'] == 'POST' and is_form_post(environ):
+ if req is None:
+ req = Request(environ)
+ req.errors = 'ignore'
+ if '_method' in req.POST:
+ old_method = environ['REQUEST_METHOD']
+ environ['REQUEST_METHOD'] = req.POST['_method'].upper()
+ if self.log_debug:
+ log.debug("_method found in POST data, altering request "
+ "method to %s", environ['REQUEST_METHOD'])
+
+ # Run the actual route matching
+ # -- Assignment of environ to config triggers route matching
+ if self.singleton:
+ config = request_config()
+ config.mapper = self.mapper
+ config.environ = environ
+ match = config.mapper_dict
+ route = config.route
+ else:
+ results = self.mapper.routematch(environ=environ)
+ if results:
+ match, route = results[0], results[1]
+ else:
+ match = route = None
+
+ if old_method:
+ environ['REQUEST_METHOD'] = old_method
+
+ if not match:
+ match = {}
+ if self.log_debug:
+ urlinfo = "%s %s" % (environ['REQUEST_METHOD'], environ['PATH_INFO'])
+ log.debug("No route matched for %s", urlinfo)
+ elif self.log_debug:
+ urlinfo = "%s %s" % (environ['REQUEST_METHOD'], environ['PATH_INFO'])
+ log.debug("Matched %s", urlinfo)
+ log.debug("Route path: '%s', defaults: %s", route.routepath,
+ route.defaults)
+ log.debug("Match dict: %s", match)
+
+ url = URLGenerator(self.mapper, environ)
+ environ['wsgiorg.routing_args'] = ((url), match)
+ environ['routes.route'] = route
+ environ['routes.url'] = url
+
+ if route and route.redirect:
+ route_name = '_redirect_%s' % id(route)
+ location = url(route_name, **match)
+ log.debug("Using redirect route, redirect to '%s' with status"
+ "code: %s", location, route.redirect_status)
+ start_response(route.redirect_status,
+ [('Content-Type', 'text/plain; charset=utf8'),
+ ('Location', location)])
+ return []
+
+ # If the route included a path_info attribute and it should be used to
+ # alter the environ, we'll pull it out
+ if self.path_info and 'path_info' in match:
+ oldpath = environ['PATH_INFO']
+ newpath = match.get('path_info') or ''
+ environ['PATH_INFO'] = newpath
+ if not environ['PATH_INFO'].startswith('/'):
+ environ['PATH_INFO'] = '/' + environ['PATH_INFO']
+ environ['SCRIPT_NAME'] += re.sub(r'^(.*?)/' + re.escape(newpath) + '$',
+ r'\1', oldpath)
+
+ response = self.app(environ, start_response)
+
+ # Wrapped in try as in rare cases the attribute will be gone already
+ try:
+ del self.mapper.environ
+ except AttributeError:
+ pass
+ return response
+
+def is_form_post(environ):
+ """Determine whether the request is a POSTed html form"""
+ content_type = environ.get('CONTENT_TYPE', '').lower()
+ if ';' in content_type:
+ content_type = content_type.split(';', 1)[0]
+ return content_type in ('application/x-www-form-urlencoded',
+ 'multipart/form-data')
diff --git a/src/routes/route.py b/src/routes/route.py
new file mode 100644
index 0000000000..688d6e4cb9
--- /dev/null
+++ b/src/routes/route.py
@@ -0,0 +1,742 @@
+import re
+import sys
+import urllib
+
+if sys.version < '2.4':
+ from sets import ImmutableSet as frozenset
+
+from routes.util import _url_quote as url_quote, _str_encode
+
+
+class Route(object):
+ """The Route object holds a route recognition and generation
+ routine.
+
+ See Route.__init__ docs for usage.
+
+ """
+ # reserved keys that don't count
+ reserved_keys = ['requirements']
+
+ # special chars to indicate a natural split in the URL
+ done_chars = ('/', ',', ';', '.', '#')
+
+ def __init__(self, name, routepath, **kargs):
+ """Initialize a route, with a given routepath for
+ matching/generation
+
+ The set of keyword args will be used as defaults.
+
+ Usage::
+
+ >>> from routes.base import Route
+ >>> newroute = Route(None, ':controller/:action/:id')
+ >>> sorted(newroute.defaults.items())
+ [('action', 'index'), ('id', None)]
+ >>> newroute = Route(None, 'date/:year/:month/:day',
+ ... controller="blog", action="view")
+ >>> newroute = Route(None, 'archives/:page', controller="blog",
+ ... action="by_page", requirements = { 'page':'\d{1,2}' })
+ >>> newroute.reqs
+ {'page': '\\\d{1,2}'}
+
+ .. Note::
+ Route is generally not called directly, a Mapper instance
+ connect method should be used to add routes.
+
+ """
+ self.routepath = routepath
+ self.sub_domains = False
+ self.prior = None
+ self.redirect = False
+ self.name = name
+ self._kargs = kargs
+ self.minimization = kargs.pop('_minimize', False)
+ self.encoding = kargs.pop('_encoding', 'utf-8')
+ self.reqs = kargs.get('requirements', {})
+ self.decode_errors = 'replace'
+
+ # Don't bother forming stuff we don't need if its a static route
+ self.static = kargs.pop('_static', False)
+ self.filter = kargs.pop('_filter', None)
+ self.absolute = kargs.pop('_absolute', False)
+
+ # Pull out the member/collection name if present, this applies only to
+ # map.resource
+ self.member_name = kargs.pop('_member_name', None)
+ self.collection_name = kargs.pop('_collection_name', None)
+ self.parent_resource = kargs.pop('_parent_resource', None)
+
+ # Pull out route conditions
+ self.conditions = kargs.pop('conditions', None)
+
+ # Determine if explicit behavior should be used
+ self.explicit = kargs.pop('_explicit', False)
+
+ # Since static need to be generated exactly, treat them as
+ # non-minimized
+ if self.static:
+ self.external = '://' in self.routepath
+ self.minimization = False
+
+ # Strip preceding '/' if present, and not minimizing
+ if routepath.startswith('/') and self.minimization:
+ self.routepath = routepath[1:]
+ self._setup_route()
+
+ def _setup_route(self):
+ # Build our routelist, and the keys used in the route
+ self.routelist = routelist = self._pathkeys(self.routepath)
+ routekeys = frozenset([key['name'] for key in routelist
+ if isinstance(key, dict)])
+ self.dotkeys = frozenset([key['name'] for key in routelist
+ if isinstance(key, dict) and
+ key['type'] == '.'])
+
+ if not self.minimization:
+ self.make_full_route()
+
+ # Build a req list with all the regexp requirements for our args
+ self.req_regs = {}
+ for key, val in self.reqs.iteritems():
+ self.req_regs[key] = re.compile('^' + val + '$')
+ # Update our defaults and set new default keys if needed. defaults
+ # needs to be saved
+ (self.defaults, defaultkeys) = self._defaults(routekeys,
+ self.reserved_keys,
+ self._kargs.copy())
+ # Save the maximum keys we could utilize
+ self.maxkeys = defaultkeys | routekeys
+
+ # Populate our minimum keys, and save a copy of our backward keys for
+ # quicker generation later
+ (self.minkeys, self.routebackwards) = self._minkeys(routelist[:])
+
+ # Populate our hardcoded keys, these are ones that are set and don't
+ # exist in the route
+ self.hardcoded = frozenset([key for key in self.maxkeys \
+ if key not in routekeys and self.defaults[key] is not None])
+
+ # Cache our default keys
+ self._default_keys = frozenset(self.defaults.keys())
+
+ def make_full_route(self):
+ """Make a full routelist string for use with non-minimized
+ generation"""
+ regpath = ''
+ for part in self.routelist:
+ if isinstance(part, dict):
+ regpath += '%(' + part['name'] + ')s'
+ else:
+ regpath += part
+ self.regpath = regpath
+
+ def make_unicode(self, s):
+ """Transform the given argument into a unicode string."""
+ if isinstance(s, unicode):
+ return s
+ elif isinstance(s, str):
+ return s.decode(self.encoding)
+ elif callable(s):
+ return s
+ else:
+ return unicode(s)
+
+ def _pathkeys(self, routepath):
+ """Utility function to walk the route, and pull out the valid
+ dynamic/wildcard keys."""
+ collecting = False
+ current = ''
+ done_on = ''
+ var_type = ''
+ just_started = False
+ routelist = []
+ for char in routepath:
+ if char in [':', '*', '{'] and not collecting and not self.static \
+ or char in ['{'] and not collecting:
+ just_started = True
+ collecting = True
+ var_type = char
+ if char == '{':
+ done_on = '}'
+ just_started = False
+ if len(current) > 0:
+ routelist.append(current)
+ current = ''
+ elif collecting and just_started:
+ just_started = False
+ if char == '(':
+ done_on = ')'
+ else:
+ current = char
+ done_on = self.done_chars + ('-',)
+ elif collecting and char not in done_on:
+ current += char
+ elif collecting:
+ collecting = False
+ if var_type == '{':
+ if current[0] == '.':
+ var_type = '.'
+ current = current[1:]
+ else:
+ var_type = ':'
+ opts = current.split(':')
+ if len(opts) > 1:
+ current = opts[0]
+ self.reqs[current] = opts[1]
+ routelist.append(dict(type=var_type, name=current))
+ if char in self.done_chars:
+ routelist.append(char)
+ done_on = var_type = current = ''
+ else:
+ current += char
+ if collecting:
+ routelist.append(dict(type=var_type, name=current))
+ elif current:
+ routelist.append(current)
+ return routelist
+
+ def _minkeys(self, routelist):
+ """Utility function to walk the route backwards
+
+ Will also determine the minimum keys we can handle to generate
+ a working route.
+
+ routelist is a list of the '/' split route path
+ defaults is a dict of all the defaults provided for the route
+
+ """
+ minkeys = []
+ backcheck = routelist[:]
+
+ # If we don't honor minimization, we need all the keys in the
+ # route path
+ if not self.minimization:
+ for part in backcheck:
+ if isinstance(part, dict):
+ minkeys.append(part['name'])
+ return (frozenset(minkeys), backcheck)
+
+ gaps = False
+ backcheck.reverse()
+ for part in backcheck:
+ if not isinstance(part, dict) and part not in self.done_chars:
+ gaps = True
+ continue
+ elif not isinstance(part, dict):
+ continue
+ key = part['name']
+ if self.defaults.has_key(key) and not gaps:
+ continue
+ minkeys.append(key)
+ gaps = True
+ return (frozenset(minkeys), backcheck)
+
+ def _defaults(self, routekeys, reserved_keys, kargs):
+ """Creates default set with values stringified
+
+ Put together our list of defaults, stringify non-None values
+ and add in our action/id default if they use it and didn't
+ specify it.
+
+ defaultkeys is a list of the currently assumed default keys
+ routekeys is a list of the keys found in the route path
+ reserved_keys is a list of keys that are not
+
+ """
+ defaults = {}
+ # Add in a controller/action default if they don't exist
+ if 'controller' not in routekeys and 'controller' not in kargs \
+ and not self.explicit:
+ kargs['controller'] = 'content'
+ if 'action' not in routekeys and 'action' not in kargs \
+ and not self.explicit:
+ kargs['action'] = 'index'
+ defaultkeys = frozenset([key for key in kargs.keys() \
+ if key not in reserved_keys])
+ for key in defaultkeys:
+ if kargs[key] is not None:
+ defaults[key] = self.make_unicode(kargs[key])
+ else:
+ defaults[key] = None
+ if 'action' in routekeys and not defaults.has_key('action') \
+ and not self.explicit:
+ defaults['action'] = 'index'
+ if 'id' in routekeys and not defaults.has_key('id') \
+ and not self.explicit:
+ defaults['id'] = None
+ newdefaultkeys = frozenset([key for key in defaults.keys() \
+ if key not in reserved_keys])
+
+ return (defaults, newdefaultkeys)
+
+ def makeregexp(self, clist, include_names=True):
+ """Create a regular expression for matching purposes
+
+ Note: This MUST be called before match can function properly.
+
+ clist should be a list of valid controller strings that can be
+ matched, for this reason makeregexp should be called by the web
+ framework after it knows all available controllers that can be
+ utilized.
+
+ include_names indicates whether this should be a match regexp
+ assigned to itself using regexp grouping names, or if names
+ should be excluded for use in a single larger regexp to
+ determine if any routes match
+
+ """
+ if self.minimization:
+ reg = self.buildnextreg(self.routelist, clist, include_names)[0]
+ if not reg:
+ reg = '/'
+ reg = reg + '/?' + '$'
+
+ if not reg.startswith('/'):
+ reg = '/' + reg
+ else:
+ reg = self.buildfullreg(clist, include_names)
+
+ reg = '^' + reg
+
+ if not include_names:
+ return reg
+
+ self.regexp = reg
+ self.regmatch = re.compile(reg)
+
+ def buildfullreg(self, clist, include_names=True):
+ """Build the regexp by iterating through the routelist and
+ replacing dicts with the appropriate regexp match"""
+ regparts = []
+ for part in self.routelist:
+ if isinstance(part, dict):
+ var = part['name']
+ if var == 'controller':
+ partmatch = '|'.join(map(re.escape, clist))
+ elif part['type'] == ':':
+ partmatch = self.reqs.get(var) or '[^/]+?'
+ elif part['type'] == '.':
+ partmatch = self.reqs.get(var) or '[^/.]+?'
+ else:
+ partmatch = self.reqs.get(var) or '.+?'
+ if include_names:
+ regpart = '(?P<%s>%s)' % (var, partmatch)
+ else:
+ regpart = '(?:%s)' % partmatch
+ if part['type'] == '.':
+ regparts.append('(?:\.%s)??' % regpart)
+ else:
+ regparts.append(regpart)
+ else:
+ regparts.append(re.escape(part))
+ regexp = ''.join(regparts) + '$'
+ return regexp
+
+ def buildnextreg(self, path, clist, include_names=True):
+ """Recursively build our regexp given a path, and a controller
+ list.
+
+ Returns the regular expression string, and two booleans that
+ can be ignored as they're only used internally by buildnextreg.
+
+ """
+ if path:
+ part = path[0]
+ else:
+ part = ''
+ reg = ''
+
+ # noreqs will remember whether the remainder has either a string
+ # match, or a non-defaulted regexp match on a key, allblank remembers
+ # if the rest could possible be completely empty
+ (rest, noreqs, allblank) = ('', True, True)
+ if len(path[1:]) > 0:
+ self.prior = part
+ (rest, noreqs, allblank) = self.buildnextreg(path[1:], clist, include_names)
+
+ if isinstance(part, dict) and part['type'] in (':', '.'):
+ var = part['name']
+ typ = part['type']
+ partreg = ''
+
+ # First we plug in the proper part matcher
+ if self.reqs.has_key(var):
+ if include_names:
+ partreg = '(?P<%s>%s)' % (var, self.reqs[var])
+ else:
+ partreg = '(?:%s)' % self.reqs[var]
+ if typ == '.':
+ partreg = '(?:\.%s)??' % partreg
+ elif var == 'controller':
+ if include_names:
+ partreg = '(?P<%s>%s)' % (var, '|'.join(map(re.escape, clist)))
+ else:
+ partreg = '(?:%s)' % '|'.join(map(re.escape, clist))
+ elif self.prior in ['/', '#']:
+ if include_names:
+ partreg = '(?P<' + var + '>[^' + self.prior + ']+?)'
+ else:
+ partreg = '(?:[^' + self.prior + ']+?)'
+ else:
+ if not rest:
+ if typ == '.':
+ exclude_chars = '/.'
+ else:
+ exclude_chars = '/'
+ if include_names:
+ partreg = '(?P<%s>[^%s]+?)' % (var, exclude_chars)
+ else:
+ partreg = '(?:[^%s]+?)' % exclude_chars
+ if typ == '.':
+ partreg = '(?:\.%s)??' % partreg
+ else:
+ end = ''.join(self.done_chars)
+ rem = rest
+ if rem[0] == '\\' and len(rem) > 1:
+ rem = rem[1]
+ elif rem.startswith('(\\') and len(rem) > 2:
+ rem = rem[2]
+ else:
+ rem = end
+ rem = frozenset(rem) | frozenset(['/'])
+ if include_names:
+ partreg = '(?P<%s>[^%s]+?)' % (var, ''.join(rem))
+ else:
+ partreg = '(?:[^%s]+?)' % ''.join(rem)
+
+ if self.reqs.has_key(var):
+ noreqs = False
+ if not self.defaults.has_key(var):
+ allblank = False
+ noreqs = False
+
+ # Now we determine if its optional, or required. This changes
+ # depending on what is in the rest of the match. If noreqs is
+ # true, then its possible the entire thing is optional as there's
+ # no reqs or string matches.
+ if noreqs:
+ # The rest is optional, but now we have an optional with a
+ # regexp. Wrap to ensure that if we match anything, we match
+ # our regexp first. It's still possible we could be completely
+ # blank as we have a default
+ if self.reqs.has_key(var) and self.defaults.has_key(var):
+ reg = '(' + partreg + rest + ')?'
+
+ # Or we have a regexp match with no default, so now being
+ # completely blank form here on out isn't possible
+ elif self.reqs.has_key(var):
+ allblank = False
+ reg = partreg + rest
+
+ # If the character before this is a special char, it has to be
+ # followed by this
+ elif self.defaults.has_key(var) and \
+ self.prior in (',', ';', '.'):
+ reg = partreg + rest
+
+ # Or we have a default with no regexp, don't touch the allblank
+ elif self.defaults.has_key(var):
+ reg = partreg + '?' + rest
+
+ # Or we have a key with no default, and no reqs. Not possible
+ # to be all blank from here
+ else:
+ allblank = False
+ reg = partreg + rest
+ # In this case, we have something dangling that might need to be
+ # matched
+ else:
+ # If they can all be blank, and we have a default here, we know
+ # its safe to make everything from here optional. Since
+ # something else in the chain does have req's though, we have
+ # to make the partreg here required to continue matching
+ if allblank and self.defaults.has_key(var):
+ reg = '(' + partreg + rest + ')?'
+
+ # Same as before, but they can't all be blank, so we have to
+ # require it all to ensure our matches line up right
+ else:
+ reg = partreg + rest
+ elif isinstance(part, dict) and part['type'] == '*':
+ var = part['name']
+ if noreqs:
+ if include_names:
+ reg = '(?P<%s>.*)' % var + rest
+ else:
+ reg = '(?:.*)' + rest
+ if not self.defaults.has_key(var):
+ allblank = False
+ noreqs = False
+ else:
+ if allblank and self.defaults.has_key(var):
+ if include_names:
+ reg = '(?P<%s>.*)' % var + rest
+ else:
+ reg = '(?:.*)' + rest
+ elif self.defaults.has_key(var):
+ if include_names:
+ reg = '(?P<%s>.*)' % var + rest
+ else:
+ reg = '(?:.*)' + rest
+ else:
+ if include_names:
+ reg = '(?P<%s>.*)' % var + rest
+ else:
+ reg = '(?:.*)' + rest
+ allblank = False
+ noreqs = False
+ elif part and part[-1] in self.done_chars:
+ if allblank:
+ reg = re.escape(part[:-1]) + '(' + re.escape(part[-1]) + rest
+ reg += ')?'
+ else:
+ allblank = False
+ reg = re.escape(part) + rest
+
+ # We have a normal string here, this is a req, and it prevents us from
+ # being all blank
+ else:
+ noreqs = False
+ allblank = False
+ reg = re.escape(part) + rest
+
+ return (reg, noreqs, allblank)
+
+ def match(self, url, environ=None, sub_domains=False,
+ sub_domains_ignore=None, domain_match=''):
+ """Match a url to our regexp.
+
+ While the regexp might match, this operation isn't
+ guaranteed as there's other factors that can cause a match to
+ fail even though the regexp succeeds (Default that was relied
+ on wasn't given, requirement regexp doesn't pass, etc.).
+
+ Therefore the calling function shouldn't assume this will
+ return a valid dict, the other possible return is False if a
+ match doesn't work out.
+
+ """
+ # Static routes don't match, they generate only
+ if self.static:
+ return False
+
+ match = self.regmatch.match(url)
+
+ if not match:
+ return False
+
+ sub_domain = None
+
+ if sub_domains and environ and 'HTTP_HOST' in environ:
+ host = environ['HTTP_HOST'].split(':')[0]
+ sub_match = re.compile('^(.+?)\.%s$' % domain_match)
+ subdomain = re.sub(sub_match, r'\1', host)
+ if subdomain not in sub_domains_ignore and host != subdomain:
+ sub_domain = subdomain
+
+ if self.conditions:
+ if 'method' in self.conditions and environ and \
+ environ['REQUEST_METHOD'] not in self.conditions['method']:
+ return False
+
+ # Check sub-domains?
+ use_sd = self.conditions.get('sub_domain')
+ if use_sd and not sub_domain:
+ return False
+ elif not use_sd and 'sub_domain' in self.conditions and sub_domain:
+ return False
+ if isinstance(use_sd, list) and sub_domain not in use_sd:
+ return False
+
+ matchdict = match.groupdict()
+ result = {}
+ extras = self._default_keys - frozenset(matchdict.keys())
+ for key, val in matchdict.iteritems():
+ if key != 'path_info' and self.encoding:
+ # change back into python unicode objects from the URL
+ # representation
+ try:
+ val = val and val.decode(self.encoding, self.decode_errors)
+ except UnicodeDecodeError:
+ return False
+
+ if not val and key in self.defaults and self.defaults[key]:
+ result[key] = self.defaults[key]
+ else:
+ result[key] = val
+ for key in extras:
+ result[key] = self.defaults[key]
+
+ # Add the sub-domain if there is one
+ if sub_domains:
+ result['sub_domain'] = sub_domain
+
+ # If there's a function, call it with environ and expire if it
+ # returns False
+ if self.conditions and 'function' in self.conditions and \
+ not self.conditions['function'](environ, result):
+ return False
+
+ return result
+
+ def generate_non_minimized(self, kargs):
+ """Generate a non-minimal version of the URL"""
+ # Iterate through the keys that are defaults, and NOT in the route
+ # path. If its not in kargs, or doesn't match, or is None, this
+ # route won't work
+ for k in self.maxkeys - self.minkeys:
+ if k not in kargs:
+ return False
+ elif self.make_unicode(kargs[k]) != \
+ self.make_unicode(self.defaults[k]):
+ return False
+
+ # Ensure that all the args in the route path are present and not None
+ for arg in self.minkeys:
+ if arg not in kargs or kargs[arg] is None:
+ if arg in self.dotkeys:
+ kargs[arg] = ''
+ else:
+ return False
+
+ # Encode all the argument that the regpath can use
+ for k in kargs:
+ if k in self.maxkeys:
+ if k in self.dotkeys:
+ if kargs[k]:
+ kargs[k] = url_quote('.' + kargs[k], self.encoding)
+ else:
+ kargs[k] = url_quote(kargs[k], self.encoding)
+
+ return self.regpath % kargs
+
+ def generate_minimized(self, kargs):
+ """Generate a minimized version of the URL"""
+ routelist = self.routebackwards
+ urllist = []
+ gaps = False
+ for part in routelist:
+ if isinstance(part, dict) and part['type'] in (':', '.'):
+ arg = part['name']
+
+ # For efficiency, check these just once
+ has_arg = kargs.has_key(arg)
+ has_default = self.defaults.has_key(arg)
+
+ # Determine if we can leave this part off
+ # First check if the default exists and wasn't provided in the
+ # call (also no gaps)
+ if has_default and not has_arg and not gaps:
+ continue
+
+ # Now check to see if there's a default and it matches the
+ # incoming call arg
+ if (has_default and has_arg) and self.make_unicode(kargs[arg]) == \
+ self.make_unicode(self.defaults[arg]) and not gaps:
+ continue
+
+ # We need to pull the value to append, if the arg is None and
+ # we have a default, use that
+ if has_arg and kargs[arg] is None and has_default and not gaps:
+ continue
+
+ # Otherwise if we do have an arg, use that
+ elif has_arg:
+ val = kargs[arg]
+
+ elif has_default and self.defaults[arg] is not None:
+ val = self.defaults[arg]
+ # Optional format parameter?
+ elif part['type'] == '.':
+ continue
+ # No arg at all? This won't work
+ else:
+ return False
+
+ urllist.append(url_quote(val, self.encoding))
+ if part['type'] == '.':
+ urllist.append('.')
+
+ if has_arg:
+ del kargs[arg]
+ gaps = True
+ elif isinstance(part, dict) and part['type'] == '*':
+ arg = part['name']
+ kar = kargs.get(arg)
+ if kar is not None:
+ urllist.append(url_quote(kar, self.encoding))
+ gaps = True
+ elif part and part[-1] in self.done_chars:
+ if not gaps and part in self.done_chars:
+ continue
+ elif not gaps:
+ urllist.append(part[:-1])
+ gaps = True
+ else:
+ gaps = True
+ urllist.append(part)
+ else:
+ gaps = True
+ urllist.append(part)
+ urllist.reverse()
+ url = ''.join(urllist)
+ return url
+
+ def generate(self, _ignore_req_list=False, _append_slash=False, **kargs):
+ """Generate a URL from ourself given a set of keyword arguments
+
+ Toss an exception if this
+ set of keywords would cause a gap in the url.
+
+ """
+ # Verify that our args pass any regexp requirements
+ if not _ignore_req_list:
+ for key in self.reqs.keys():
+ val = kargs.get(key)
+ if val and not self.req_regs[key].match(self.make_unicode(val)):
+ return False
+
+ # Verify that if we have a method arg, its in the method accept list.
+ # Also, method will be changed to _method for route generation
+ meth = kargs.get('method')
+ if meth:
+ if self.conditions and 'method' in self.conditions \
+ and meth.upper() not in self.conditions['method']:
+ return False
+ kargs.pop('method')
+
+ if self.minimization:
+ url = self.generate_minimized(kargs)
+ else:
+ url = self.generate_non_minimized(kargs)
+
+ if url is False:
+ return url
+
+ if not url.startswith('/') and not self.static:
+ url = '/' + url
+ extras = frozenset(kargs.keys()) - self.maxkeys
+ if extras:
+ if _append_slash and not url.endswith('/'):
+ url += '/'
+ fragments = []
+ # don't assume the 'extras' set preserves order: iterate
+ # through the ordered kargs instead
+ for key in kargs:
+ if key not in extras:
+ continue
+ if key == 'action' or key == 'controller':
+ continue
+ val = kargs[key]
+ if isinstance(val, (tuple, list)):
+ for value in val:
+ fragments.append((key, _str_encode(value, self.encoding)))
+ else:
+ fragments.append((key, _str_encode(val, self.encoding)))
+ if fragments:
+ url += '?'
+ url += urllib.urlencode(fragments)
+ elif _append_slash and not url.endswith('/'):
+ url += '/'
+ return url
diff --git a/src/routes/util.py b/src/routes/util.py
new file mode 100644
index 0000000000..6c3f845015
--- /dev/null
+++ b/src/routes/util.py
@@ -0,0 +1,503 @@
+"""Utility functions for use in templates / controllers
+
+*PLEASE NOTE*: Many of these functions expect an initialized RequestConfig
+object. This is expected to have been initialized for EACH REQUEST by the web
+framework.
+
+"""
+import os
+import re
+import urllib
+from routes import request_config
+
+
+class RoutesException(Exception):
+ """Tossed during Route exceptions"""
+
+
+class MatchException(RoutesException):
+ """Tossed during URL matching exceptions"""
+
+
+class GenerationException(RoutesException):
+ """Tossed during URL generation exceptions"""
+
+
+def _screenargs(kargs, mapper, environ, force_explicit=False):
+ """
+ Private function that takes a dict, and screens it against the current
+ request dict to determine what the dict should look like that is used.
+ This is responsible for the requests "memory" of the current.
+ """
+ # Coerce any unicode args with the encoding
+ encoding = mapper.encoding
+ for key, val in kargs.iteritems():
+ if isinstance(val, unicode):
+ kargs[key] = val.encode(encoding)
+
+ if mapper.explicit and mapper.sub_domains and not force_explicit:
+ return _subdomain_check(kargs, mapper, environ)
+ elif mapper.explicit and not force_explicit:
+ return kargs
+
+ controller_name = kargs.get('controller')
+
+ if controller_name and controller_name.startswith('/'):
+ # If the controller name starts with '/', ignore route memory
+ kargs['controller'] = kargs['controller'][1:]
+ return kargs
+ elif controller_name and not kargs.has_key('action'):
+ # Fill in an action if we don't have one, but have a controller
+ kargs['action'] = 'index'
+
+ route_args = environ.get('wsgiorg.routing_args')
+ if route_args:
+ memory_kargs = route_args[1].copy()
+ else:
+ memory_kargs = {}
+
+ # Remove keys from memory and kargs if kargs has them as None
+ for key in [key for key in kargs.keys() if kargs[key] is None]:
+ del kargs[key]
+ if memory_kargs.has_key(key):
+ del memory_kargs[key]
+
+ # Merge the new args on top of the memory args
+ memory_kargs.update(kargs)
+
+ # Setup a sub-domain if applicable
+ if mapper.sub_domains:
+ memory_kargs = _subdomain_check(memory_kargs, mapper, environ)
+ return memory_kargs
+
+
+def _subdomain_check(kargs, mapper, environ):
+ """Screen the kargs for a subdomain and alter it appropriately depending
+ on the current subdomain or lack therof."""
+ if mapper.sub_domains:
+ subdomain = kargs.pop('sub_domain', None)
+ if isinstance(subdomain, unicode):
+ subdomain = str(subdomain)
+
+ fullhost = environ.get('HTTP_HOST') or environ.get('SERVER_NAME')
+
+ # In case environ defaulted to {}
+ if not fullhost:
+ return kargs
+
+ hostmatch = fullhost.split(':')
+ host = hostmatch[0]
+ port = ''
+ if len(hostmatch) > 1:
+ port += ':' + hostmatch[1]
+ sub_match = re.compile('^.+?\.(%s)$' % mapper.domain_match)
+ domain = re.sub(sub_match, r'\1', host)
+ if subdomain and not host.startswith(subdomain) and \
+ subdomain not in mapper.sub_domains_ignore:
+ kargs['_host'] = subdomain + '.' + domain + port
+ elif (subdomain in mapper.sub_domains_ignore or \
+ subdomain is None) and domain != host:
+ kargs['_host'] = domain + port
+ return kargs
+ else:
+ return kargs
+
+
+def _url_quote(string, encoding):
+ """A Unicode handling version of urllib.quote."""
+ if encoding:
+ if isinstance(string, unicode):
+ s = string.encode(encoding)
+ elif isinstance(string, str):
+ # assume the encoding is already correct
+ s = string
+ else:
+ s = unicode(string).encode(encoding)
+ else:
+ s = str(string)
+ return urllib.quote(s, '/')
+
+
+def _str_encode(string, encoding):
+ if encoding:
+ if isinstance(string, unicode):
+ s = string.encode(encoding)
+ elif isinstance(string, str):
+ # assume the encoding is already correct
+ s = string
+ else:
+ s = unicode(string).encode(encoding)
+ return s
+
+
+def url_for(*args, **kargs):
+ """Generates a URL
+
+ All keys given to url_for are sent to the Routes Mapper instance for
+ generation except for::
+
+ anchor specified the anchor name to be appened to the path
+ host overrides the default (current) host if provided
+ protocol overrides the default (current) protocol if provided
+ qualified creates the URL with the host/port information as
+ needed
+
+ The URL is generated based on the rest of the keys. When generating a new
+ URL, values will be used from the current request's parameters (if
+ present). The following rules are used to determine when and how to keep
+ the current requests parameters:
+
+ * If the controller is present and begins with '/', no defaults are used
+ * If the controller is changed, action is set to 'index' unless otherwise
+ specified
+
+ For example, if the current request yielded a dict of
+ {'controller': 'blog', 'action': 'view', 'id': 2}, with the standard
+ ':controller/:action/:id' route, you'd get the following results::
+
+ url_for(id=4) => '/blog/view/4',
+ url_for(controller='/admin') => '/admin',
+ url_for(controller='admin') => '/admin/view/2'
+ url_for(action='edit') => '/blog/edit/2',
+ url_for(action='list', id=None) => '/blog/list'
+
+ **Static and Named Routes**
+
+ If there is a string present as the first argument, a lookup is done
+ against the named routes table to see if there's any matching routes. The
+ keyword defaults used with static routes will be sent in as GET query
+ arg's if a route matches.
+
+ If no route by that name is found, the string is assumed to be a raw URL.
+ Should the raw URL begin with ``/`` then appropriate SCRIPT_NAME data will
+ be added if present, otherwise the string will be used as the url with
+ keyword args becoming GET query args.
+
+ """
+ anchor = kargs.get('anchor')
+ host = kargs.get('host')
+ protocol = kargs.get('protocol')
+ qualified = kargs.pop('qualified', None)
+
+ # Remove special words from kargs, convert placeholders
+ for key in ['anchor', 'host', 'protocol']:
+ if kargs.get(key):
+ del kargs[key]
+ config = request_config()
+ route = None
+ static = False
+ encoding = config.mapper.encoding
+ url = ''
+ if len(args) > 0:
+ route = config.mapper._routenames.get(args[0])
+
+ # No named route found, assume the argument is a relative path
+ if not route:
+ static = True
+ url = args[0]
+
+ if url.startswith('/') and hasattr(config, 'environ') \
+ and config.environ.get('SCRIPT_NAME'):
+ url = config.environ.get('SCRIPT_NAME') + url
+
+ if static:
+ if kargs:
+ url += '?'
+ query_args = []
+ for key, val in kargs.iteritems():
+ if isinstance(val, (list, tuple)):
+ for value in val:
+ query_args.append("%s=%s" % (
+ urllib.quote(unicode(key).encode(encoding)),
+ urllib.quote(unicode(value).encode(encoding))))
+ else:
+ query_args.append("%s=%s" % (
+ urllib.quote(unicode(key).encode(encoding)),
+ urllib.quote(unicode(val).encode(encoding))))
+ url += '&'.join(query_args)
+ environ = getattr(config, 'environ', {})
+ if 'wsgiorg.routing_args' not in environ:
+ environ = environ.copy()
+ mapper_dict = getattr(config, 'mapper_dict', None)
+ if mapper_dict is not None:
+ match_dict = mapper_dict.copy()
+ else:
+ match_dict = {}
+ environ['wsgiorg.routing_args'] = ((), match_dict)
+
+ if not static:
+ route_args = []
+ if route:
+ if config.mapper.hardcode_names:
+ route_args.append(route)
+ newargs = route.defaults.copy()
+ newargs.update(kargs)
+
+ # If this route has a filter, apply it
+ if route.filter:
+ newargs = route.filter(newargs)
+
+ if not route.static:
+ # Handle sub-domains
+ newargs = _subdomain_check(newargs, config.mapper, environ)
+ else:
+ newargs = _screenargs(kargs, config.mapper, environ)
+ anchor = newargs.pop('_anchor', None) or anchor
+ host = newargs.pop('_host', None) or host
+ protocol = newargs.pop('_protocol', None) or protocol
+ url = config.mapper.generate(*route_args, **newargs)
+ if anchor is not None:
+ url += '#' + _url_quote(anchor, encoding)
+ if host or protocol or qualified:
+ if not host and not qualified:
+ # Ensure we don't use a specific port, as changing the protocol
+ # means that we most likely need a new port
+ host = config.host.split(':')[0]
+ elif not host:
+ host = config.host
+ if not protocol:
+ protocol = config.protocol
+ if url is not None:
+ url = protocol + '://' + host + url
+
+ if not isinstance(url, str) and url is not None:
+ raise GenerationException("url_for can only return a string, got "
+ "unicode instead: %s" % url)
+ if url is None:
+ raise GenerationException(
+ "url_for could not generate URL. Called with args: %s %s" % \
+ (args, kargs))
+ return url
+
+
+class URLGenerator(object):
+ """The URL Generator generates URL's
+
+ It is automatically instantiated by the RoutesMiddleware and put
+ into the ``wsgiorg.routing_args`` tuple accessible as::
+
+ url = environ['wsgiorg.routing_args'][0][0]
+
+ Or via the ``routes.url`` key::
+
+ url = environ['routes.url']
+
+ The url object may be instantiated outside of a web context for use
+ in testing, however sub_domain support and fully qualified URL's
+ cannot be generated without supplying a dict that must contain the
+ key ``HTTP_HOST``.
+
+ """
+ def __init__(self, mapper, environ):
+ """Instantiate the URLGenerator
+
+ ``mapper``
+ The mapper object to use when generating routes.
+ ``environ``
+ The environment dict used in WSGI, alternately, any dict
+ that contains at least an ``HTTP_HOST`` value.
+
+ """
+ self.mapper = mapper
+ if 'SCRIPT_NAME' not in environ:
+ environ['SCRIPT_NAME'] = ''
+ self.environ = environ
+
+ def __call__(self, *args, **kargs):
+ """Generates a URL
+
+ All keys given to url_for are sent to the Routes Mapper instance for
+ generation except for::
+
+ anchor specified the anchor name to be appened to the path
+ host overrides the default (current) host if provided
+ protocol overrides the default (current) protocol if provided
+ qualified creates the URL with the host/port information as
+ needed
+
+ """
+ anchor = kargs.get('anchor')
+ host = kargs.get('host')
+ protocol = kargs.get('protocol')
+ qualified = kargs.pop('qualified', None)
+
+ # Remove special words from kargs, convert placeholders
+ for key in ['anchor', 'host', 'protocol']:
+ if kargs.get(key):
+ del kargs[key]
+
+ route = None
+ use_current = '_use_current' in kargs and kargs.pop('_use_current')
+
+ static = False
+ encoding = self.mapper.encoding
+ url = ''
+
+ more_args = len(args) > 0
+ if more_args:
+ route = self.mapper._routenames.get(args[0])
+
+ if not route and more_args:
+ static = True
+ url = args[0]
+ if url.startswith('/') and self.environ.get('SCRIPT_NAME'):
+ url = self.environ.get('SCRIPT_NAME') + url
+
+ if static:
+ if kargs:
+ url += '?'
+ query_args = []
+ for key, val in kargs.iteritems():
+ if isinstance(val, (list, tuple)):
+ for value in val:
+ query_args.append("%s=%s" % (
+ urllib.quote(unicode(key).encode(encoding)),
+ urllib.quote(unicode(value).encode(encoding))))
+ else:
+ query_args.append("%s=%s" % (
+ urllib.quote(unicode(key).encode(encoding)),
+ urllib.quote(unicode(val).encode(encoding))))
+ url += '&'.join(query_args)
+ if not static:
+ route_args = []
+ if route:
+ if self.mapper.hardcode_names:
+ route_args.append(route)
+ newargs = route.defaults.copy()
+ newargs.update(kargs)
+
+ # If this route has a filter, apply it
+ if route.filter:
+ newargs = route.filter(newargs)
+ if not route.static or (route.static and not route.external):
+ # Handle sub-domains, retain sub_domain if there is one
+ sub = newargs.get('sub_domain', None)
+ newargs = _subdomain_check(newargs, self.mapper,
+ self.environ)
+ # If the route requires a sub-domain, and we have it, restore
+ # it
+ if 'sub_domain' in route.defaults:
+ newargs['sub_domain'] = sub
+
+ elif use_current:
+ newargs = _screenargs(kargs, self.mapper, self.environ, force_explicit=True)
+ elif 'sub_domain' in kargs:
+ newargs = _subdomain_check(kargs, self.mapper, self.environ)
+ else:
+ newargs = kargs
+
+ anchor = anchor or newargs.pop('_anchor', None)
+ host = host or newargs.pop('_host', None)
+ protocol = protocol or newargs.pop('_protocol', None)
+ url = self.mapper.generate(*route_args, **newargs)
+ if anchor is not None:
+ url += '#' + _url_quote(anchor, encoding)
+ if host or protocol or qualified:
+ if 'routes.cached_hostinfo' not in self.environ:
+ cache_hostinfo(self.environ)
+ hostinfo = self.environ['routes.cached_hostinfo']
+
+ if not host and not qualified:
+ # Ensure we don't use a specific port, as changing the protocol
+ # means that we most likely need a new port
+ host = hostinfo['host'].split(':')[0]
+ elif not host:
+ host = hostinfo['host']
+ if not protocol:
+ protocol = hostinfo['protocol']
+ if url is not None:
+ if host[-1] != '/':
+ host += '/'
+ url = protocol + '://' + host + url.lstrip('/')
+
+ if not isinstance(url, str) and url is not None:
+ raise GenerationException("Can only return a string, got "
+ "unicode instead: %s" % url)
+ if url is None:
+ raise GenerationException(
+ "Could not generate URL. Called with args: %s %s" % \
+ (args, kargs))
+ return url
+
+ def current(self, *args, **kwargs):
+ """Generate a route that includes params used on the current
+ request
+
+ The arguments for this method are identical to ``__call__``
+ except that arguments set to None will remove existing route
+ matches of the same name from the set of arguments used to
+ construct a URL.
+ """
+ return self(_use_current=True, *args, **kwargs)
+
+
+def redirect_to(*args, **kargs):
+ """Issues a redirect based on the arguments.
+
+ Redirect's *should* occur as a "302 Moved" header, however the web
+ framework may utilize a different method.
+
+ All arguments are passed to url_for to retrieve the appropriate URL, then
+ the resulting URL it sent to the redirect function as the URL.
+ """
+ target = url_for(*args, **kargs)
+ config = request_config()
+ return config.redirect(target)
+
+
+def cache_hostinfo(environ):
+ """Processes the host information and stores a copy
+
+ This work was previously done but wasn't stored in environ, nor is
+ it guaranteed to be setup in the future (Routes 2 and beyond).
+
+ cache_hostinfo processes environ keys that may be present to
+ determine the proper host, protocol, and port information to use
+ when generating routes.
+
+ """
+ hostinfo = {}
+ if environ.get('HTTPS') or environ.get('wsgi.url_scheme') == 'https' \
+ or environ.get('HTTP_X_FORWARDED_PROTO') == 'https':
+ hostinfo['protocol'] = 'https'
+ else:
+ hostinfo['protocol'] = 'http'
+ if environ.get('HTTP_X_FORWARDED_HOST'):
+ hostinfo['host'] = environ['HTTP_X_FORWARDED_HOST']
+ elif environ.get('HTTP_HOST'):
+ hostinfo['host'] = environ['HTTP_HOST']
+ else:
+ hostinfo['host'] = environ['SERVER_NAME']
+ if environ.get('wsgi.url_scheme') == 'https':
+ if environ['SERVER_PORT'] != '443':
+ hostinfo['host'] += ':' + environ['SERVER_PORT']
+ else:
+ if environ['SERVER_PORT'] != '80':
+ hostinfo['host'] += ':' + environ['SERVER_PORT']
+ environ['routes.cached_hostinfo'] = hostinfo
+ return hostinfo
+
+
+def controller_scan(directory=None):
+ """Scan a directory for python files and use them as controllers"""
+ if directory is None:
+ return []
+
+ def find_controllers(dirname, prefix=''):
+ """Locate controllers in a directory"""
+ controllers = []
+ for fname in os.listdir(dirname):
+ filename = os.path.join(dirname, fname)
+ if os.path.isfile(filename) and \
+ re.match('^[^_]{1,1}.*\.py$', fname):
+ controllers.append(prefix + fname[:-3])
+ elif os.path.isdir(filename):
+ controllers.extend(find_controllers(filename,
+ prefix=prefix+fname+'/'))
+ return controllers
+ def longest_first(fst, lst):
+ """Compare the length of one string to another, shortest goes first"""
+ return cmp(len(lst), len(fst))
+ controllers = find_controllers(directory)
+ controllers.sort(longest_first)
+ return controllers
From 73753b67d882e733bf619011176d6b35580efa45 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Sun, 23 May 2010 21:35:00 -0600
Subject: [PATCH 200/324] Move mobile server templates to lxml from genshi
---
src/calibre/__init__.py | 14 ++
src/calibre/gui2/__init__.py | 13 --
src/calibre/gui2/widgets.py | 5 +-
src/calibre/library/server/mobile.py | 249 +++++++++++++++++----------
4 files changed, 171 insertions(+), 110 deletions(-)
diff --git a/src/calibre/__init__.py b/src/calibre/__init__.py
index e44f8d8ec6..ff4bab6a9a 100644
--- a/src/calibre/__init__.py
+++ b/src/calibre/__init__.py
@@ -451,6 +451,20 @@ def prepare_string_for_xml(raw, attribute=False):
def isbytestring(obj):
return isinstance(obj, (str, bytes))
+def human_readable(size):
+ """ Convert a size in bytes into a human readable form """
+ divisor, suffix = 1, "B"
+ for i, candidate in enumerate(('B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB')):
+ if size < 1024**(i+1):
+ divisor, suffix = 1024**(i), candidate
+ break
+ size = str(float(size)/divisor)
+ if size.find(".") > -1:
+ size = size[:size.find(".")+2]
+ if size.endswith('.0'):
+ size = size[:-2]
+ return size + " " + suffix
+
if isosx:
import glob, shutil
fdir = os.path.expanduser('~/.fonts')
diff --git a/src/calibre/gui2/__init__.py b/src/calibre/gui2/__init__.py
index 0cf565c928..3ee5e67b6b 100644
--- a/src/calibre/gui2/__init__.py
+++ b/src/calibre/gui2/__init__.py
@@ -229,19 +229,6 @@ def info_dialog(parent, title, msg, det_msg='', show=False):
return d
-def human_readable(size):
- """ Convert a size in bytes into a human readable form """
- divisor, suffix = 1, "B"
- for i, candidate in enumerate(('B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB')):
- if size < 1024**(i+1):
- divisor, suffix = 1024**(i), candidate
- break
- size = str(float(size)/divisor)
- if size.find(".") > -1:
- size = size[:size.find(".")+2]
- if size.endswith('.0'):
- size = size[:-2]
- return size + " " + suffix
class Dispatcher(QObject):
'''Convenience class to ensure that a function call always happens in the
diff --git a/src/calibre/gui2/widgets.py b/src/calibre/gui2/widgets.py
index 8083cd4ba0..093fa3fc5c 100644
--- a/src/calibre/gui2/widgets.py
+++ b/src/calibre/gui2/widgets.py
@@ -13,11 +13,10 @@ from PyQt4.Qt import QListView, QIcon, QFont, QLabel, QListWidget, \
QAbstractButton, QPainter, QLineEdit, QComboBox, \
QMenu, QStringListModel, QCompleter, QStringList
-from calibre.gui2 import human_readable, NONE, \
- error_dialog, pixmap_to_data, dynamic
+from calibre.gui2 import NONE, error_dialog, pixmap_to_data, dynamic
from calibre.gui2.filename_pattern_ui import Ui_Form
-from calibre import fit_image
+from calibre import fit_image, human_readable
from calibre.utils.fonts import fontconfig
from calibre.ebooks import BOOK_EXTENSIONS
from calibre.ebooks.metadata.meta import metadata_from_filename
diff --git a/src/calibre/library/server/mobile.py b/src/calibre/library/server/mobile.py
index afb31815d5..6a227a6366 100644
--- a/src/calibre/library/server/mobile.py
+++ b/src/calibre/library/server/mobile.py
@@ -5,34 +5,143 @@ __license__ = 'GPL v3'
__copyright__ = '2010, Kovid Goyal '
__docformat__ = 'restructuredtext en'
-import re, copy
+import re
import __builtin__
import cherrypy
+from lxml import html
+from lxml.html.builder import HTML, HEAD, TITLE, STYLE, LINK, DIV, IMG, BODY, \
+ OPTION, SELECT, INPUT, FORM, SPAN, TABLE, TR, TD, A, HR
-from calibre.utils.genshi.template import MarkupTemplate
from calibre.library.server.utils import strftime
from calibre.ebooks.metadata import fmt_sidx
+from calibre.constants import __appname__
+from calibre import human_readable
-# Templates {{{
-MOBILE_BOOK = '''\
-
-
-
-
-
-
- ${format.lower()}
-
- ${r[FM['title']]}${(' ['+r[FM['series']]+'-'+r[FM['series_index']]+']') if r[FM['series']] else ''} by ${authors} - ${r[FM['size']]/1024}k - ${r[FM['publisher']] if r[FM['publisher']] else ''} ${pubdate} ${'['+r[FM['tags']]+']' if r[FM['tags']] else ''}
-
-
-'''
+def CLASS(*args, **kwargs): # class is a reserved word in Python
+ kwargs['class'] = ' '.join(args)
+ return kwargs
-MOBILE = MarkupTemplate('''\
-
-
-
-
-
-
-
-
-
-
-
-
-
- Books ${start} to ${ min((start+num-1) , total) } of ${total}
-
-
-
-
-
-'''
-
def send_message(msg=''):
prints('Notifying calibre of the change')
from calibre.utils.ipc import RC
@@ -130,81 +49,67 @@ def get_db(dbpath, options):
return LibraryDatabase2(dbpath)
def do_list(db, fields, afields, sort_by, ascending, search_text, line_width, separator,
- prefix, output_format, subtitle='Books in the calibre database'):
+ prefix, subtitle='Books in the calibre database'):
if sort_by:
db.sort(sort_by, ascending)
if search_text:
db.search(search_text)
- authors_to_string = output_format in ['stanza', 'text']
- data = db.get_data_as_dict(prefix, authors_as_string=authors_to_string)
+ data = db.get_data_as_dict(prefix, authors_as_string=True)
fields = ['id'] + fields
title_fields = fields
fields = [db.custom_column_label_map[x[1:]]['num'] if x[0]=='*'
else x for x in fields]
- if output_format == 'text':
- for f in data:
- fmts = [x for x in f['formats'] if x is not None]
- f['formats'] = u'[%s]'%u','.join(fmts)
- widths = list(map(lambda x : 0, fields))
- for record in data:
- for f in record.keys():
- if hasattr(record[f], 'isoformat'):
- record[f] = isoformat(record[f], as_utc=False)
- else:
- record[f] = unicode(record[f])
- record[f] = record[f].replace('\n', ' ')
- for i in data:
- for j, field in enumerate(fields):
- widths[j] = max(widths[j], len(unicode(i[field])))
- screen_width = terminal_controller.COLS if line_width < 0 else line_width
- if not screen_width:
- screen_width = 80
- field_width = screen_width//len(fields)
- base_widths = map(lambda x: min(x+1, field_width), widths)
+ for f in data:
+ fmts = [x for x in f['formats'] if x is not None]
+ f['formats'] = u'[%s]'%u','.join(fmts)
+ widths = list(map(lambda x : 0, fields))
+ for record in data:
+ for f in record.keys():
+ if hasattr(record[f], 'isoformat'):
+ record[f] = isoformat(record[f], as_utc=False)
+ else:
+ record[f] = unicode(record[f])
+ record[f] = record[f].replace('\n', ' ')
+ for i in data:
+ for j, field in enumerate(fields):
+ widths[j] = max(widths[j], len(unicode(i[field])))
- while sum(base_widths) < screen_width:
- adjusted = False
- for i in range(len(widths)):
- if base_widths[i] < widths[i]:
- base_widths[i] += min(screen_width-sum(base_widths), widths[i]-base_widths[i])
- adjusted = True
- break
- if not adjusted:
+ screen_width = terminal_controller.COLS if line_width < 0 else line_width
+ if not screen_width:
+ screen_width = 80
+ field_width = screen_width//len(fields)
+ base_widths = map(lambda x: min(x+1, field_width), widths)
+
+ while sum(base_widths) < screen_width:
+ adjusted = False
+ for i in range(len(widths)):
+ if base_widths[i] < widths[i]:
+ base_widths[i] += min(screen_width-sum(base_widths), widths[i]-base_widths[i])
+ adjusted = True
break
+ if not adjusted:
+ break
- widths = list(base_widths)
- titles = map(lambda x, y: '%-*s%s'%(x-len(separator), y, separator),
- widths, title_fields)
- print terminal_controller.GREEN + ''.join(titles)+terminal_controller.NORMAL
+ widths = list(base_widths)
+ titles = map(lambda x, y: '%-*s%s'%(x-len(separator), y, separator),
+ widths, title_fields)
+ print terminal_controller.GREEN + ''.join(titles)+terminal_controller.NORMAL
- wrappers = map(lambda x: TextWrapper(x-1), widths)
- o = cStringIO.StringIO()
+ wrappers = map(lambda x: TextWrapper(x-1), widths)
+ o = cStringIO.StringIO()
- for record in data:
- text = [wrappers[i].wrap(unicode(record[field]).encode('utf-8')) for i, field in enumerate(fields)]
- lines = max(map(len, text))
- for l in range(lines):
- for i, field in enumerate(text):
- ft = text[i][l] if l < len(text[i]) else ''
- filler = '%*s'%(widths[i]-len(ft)-1, '')
- o.write(ft)
- o.write(filler+separator)
- print >>o
- return o.getvalue()
- elif output_format == 'xml':
- template = MarkupTemplate(XML_TEMPLATE)
- return template.generate(data=data, os=os).render('xml')
- elif output_format == 'stanza':
- data = [i for i in data if i.has_key('fmt_epub')]
- for x in data:
- if isinstance(x['fmt_epub'], unicode):
- x['fmt_epub'] = x['fmt_epub'].encode('utf-8')
- if isinstance(x['cover'], unicode):
- x['cover'] = x['cover'].encode('utf-8')
- template = MarkupTemplate(STANZA_TEMPLATE)
- return template.generate(id="urn:calibre:main", data=data, subtitle=subtitle,
- sep=os.sep, quote=quote, updated=db.last_modified()).render('xml')
+ for record in data:
+ text = [wrappers[i].wrap(unicode(record[field]).encode('utf-8')) for i, field in enumerate(fields)]
+ lines = max(map(len, text))
+ for l in range(lines):
+ for i, field in enumerate(text):
+ ft = text[i][l] if l < len(text[i]) else ''
+ filler = '%*s'%(widths[i]-len(ft)-1, '')
+ o.write(ft)
+ o.write(filler+separator)
+ print >>o
+ return o.getvalue()
def list_option_parser(db=None):
fields = set(FIELDS)
@@ -236,9 +141,6 @@ List the books available in the calibre database.
help=_('The maximum width of a single line in the output. Defaults to detecting screen size.'))
parser.add_option('--separator', default=' ', help=_('The string used to separate fields. Default is a space.'))
parser.add_option('--prefix', default=None, help=_('The prefix for all file paths. Default is the absolute path to the library folder.'))
- of = ['text', 'xml', 'stanza']
- parser.add_option('--output-format', choices=of, default='text',
- help=_('The format in which to output the data. Available choices: %s. Defaults is text.')%of)
return parser
@@ -272,7 +174,7 @@ def command_list(args, dbpath):
return 1
print do_list(db, fields, afields, opts.sort_by, opts.ascending, opts.search, opts.line_width, opts.separator,
- opts.prefix, opts.output_format)
+ opts.prefix)
return 0
From 0a16be06e8d55f0e4d0ae3dce22946cc987d828f Mon Sep 17 00:00:00 2001
From: Charles Haley <>
Date: Mon, 24 May 2010 15:00:47 +0100
Subject: [PATCH 203/324] 1) Move all tag category code to DB2. 2) Fix bug
where opening preferences resets the folder device menus even when connected
---
src/calibre/ebooks/metadata/book/__init__.py | 10 +-
src/calibre/gui2/__init__.py | 2 -
src/calibre/gui2/dialogs/tag_categories.py | 8 +-
src/calibre/gui2/tag_view.py | 88 +++--------
src/calibre/gui2/ui.py | 15 +-
src/calibre/library/custom_columns.py | 12 +-
src/calibre/library/database2.py | 151 +++++++++++++++----
src/calibre/utils/config.py | 4 +-
8 files changed, 179 insertions(+), 111 deletions(-)
diff --git a/src/calibre/ebooks/metadata/book/__init__.py b/src/calibre/ebooks/metadata/book/__init__.py
index 9a44a36489..2e47ee71e3 100644
--- a/src/calibre/ebooks/metadata/book/__init__.py
+++ b/src/calibre/ebooks/metadata/book/__init__.py
@@ -88,17 +88,25 @@ CALIBRE_METADATA_FIELDS = frozenset([
]
)
+CALIBRE_RESERVED_LABELS = frozenset([
+ # reserved for saved searches
+ 'search',
+ ]
+)
+
RESERVED_METADATA_FIELDS = SOCIAL_METADATA_FIELDS.union(
PUBLICATION_METADATA_FIELDS).union(
BOOK_STRUCTURE_FIELDS).union(
USER_METADATA_FIELDS).union(
DEVICE_METADATA_FIELDS).union(
- CALIBRE_METADATA_FIELDS)
+ CALIBRE_METADATA_FIELDS).union(
+ CALIBRE_RESERVED_LABELS)
assert len(RESERVED_METADATA_FIELDS) == sum(map(len, (
SOCIAL_METADATA_FIELDS, PUBLICATION_METADATA_FIELDS,
BOOK_STRUCTURE_FIELDS, USER_METADATA_FIELDS,
DEVICE_METADATA_FIELDS, CALIBRE_METADATA_FIELDS,
+ CALIBRE_RESERVED_LABELS
)))
SERIALIZABLE_FIELDS = SOCIAL_METADATA_FIELDS.union(
diff --git a/src/calibre/gui2/__init__.py b/src/calibre/gui2/__init__.py
index 0cf565c928..478273dd0e 100644
--- a/src/calibre/gui2/__init__.py
+++ b/src/calibre/gui2/__init__.py
@@ -97,8 +97,6 @@ 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('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 0e15c06828..f49ae4ce83 100644
--- a/src/calibre/gui2/dialogs/tag_categories.py
+++ b/src/calibre/gui2/dialogs/tag_categories.py
@@ -7,7 +7,7 @@ from PyQt4.QtCore import SIGNAL, Qt
from PyQt4.QtGui import QDialog, QIcon, QListWidgetItem
from calibre.gui2.dialogs.tag_categories_ui import Ui_TagCategories
-from calibre.gui2 import config
+from calibre.utils.config import prefs
from calibre.gui2.dialogs.confirm_delete import confirm
from calibre.constants import islinux
@@ -22,7 +22,7 @@ class Item:
return 'name=%s, label=%s, index=%s, exists='%(self.name, self.label, self.index, self.exists)
class TagCategories(QDialog, Ui_TagCategories):
- category_labels_orig = ['', 'author', 'series', 'publisher', 'tag']
+ category_labels_orig = ['', 'authors', 'series', 'publishers', 'tags']
def __init__(self, window, db, index=None):
QDialog.__init__(self, window)
@@ -64,7 +64,7 @@ class TagCategories(QDialog, Ui_TagCategories):
self.all_items.append(t)
self.all_items_dict[label+':'+n] = t
- self.categories = dict.copy(config['user_categories'])
+ self.categories = dict.copy(prefs['user_categories'])
if self.categories is None:
self.categories = {}
for cat in self.categories:
@@ -181,7 +181,7 @@ class TagCategories(QDialog, Ui_TagCategories):
def accept(self):
self.save_category()
- config['user_categories'] = self.categories
+ prefs['user_categories'] = self.categories
QDialog.accept(self)
def save_category(self):
diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py
index 0fb72e071b..ba93b818c2 100644
--- a/src/calibre/gui2/tag_view.py
+++ b/src/calibre/gui2/tag_view.py
@@ -201,29 +201,34 @@ class TagsModel(QAbstractItemModel): # {{{
_('Ratings'), _('News'), _('Tags')]
row_map_orig = ['authors', 'series', 'formats', 'publishers', 'ratings',
'news', 'tags']
- tags_categories_start= 7
search_keys=['search', _('Searches')]
+
def __init__(self, db, parent=None):
QAbstractItemModel.__init__(self, parent)
- self.cat_icon_map_orig = list(map(QIcon, [I('user_profile.svg'),
- I('series.svg'), I('book.svg'), I('publisher.png'), I('star.png'),
- I('news.svg'), I('tags.svg')]))
+
+ # must do this here because 'QPixmap: Must construct a QApplication
+ # before a QPaintDevice'
+ self.category_icon_map = {'authors': QIcon(I('user_profile.svg')),
+ 'series': QIcon(I('series.svg')),
+ 'formats':QIcon(I('book.svg')),
+ 'publishers': QIcon(I('publisher.png')),
+ 'ratings':QIcon(I('star.png')),
+ 'news':QIcon(I('news.svg')),
+ 'tags':QIcon(I('tags.svg')),
+ '*custom':QIcon(I('column.svg')),
+ '*user':QIcon(I('drawer.svg')),
+ 'search':QIcon(I('search.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 = {}
self.ignore_next_search = 0
data = self.get_node_tree(config['sort_by_popularity'])
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.cat_icon_map[i])
+ data=self.categories[i],
+ category_icon=self.category_icon_map[r])
for tag in data[r]:
TagTreeItem(parent=c, data=tag, icon_map=self.icon_state_map)
@@ -233,66 +238,19 @@ class TagsModel(QAbstractItemModel): # {{{
def get_node_tree(self, sort):
self.row_map = []
self.categories = []
- # strip the icons after the 'standard' categories. We will put them back later
- if self.tags_categories_start < len(self.row_map_orig):
- self.cat_icon_map = self.cat_icon_map_orig[:self.tags_categories_start-len(self.row_map_orig)]
- else:
- self.cat_icon_map = self.cat_icon_map_orig[:]
- self.user_categories = dict.copy(config['user_categories'])
- column_map = config['column_map']
-
- 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, icon_map=self.label_to_icon_map,
+ data = self.db.get_categories(sort_on_count=sort, icon_map=self.category_icon_map,
ids=self.db.search(self.search_restriction, return_matches=True))
else:
- data = self.db.get_categories(sort_on_count=sort, icon_map=self.label_to_icon_map)
+ data = self.db.get_categories(sort_on_count=sort, icon_map=self.category_icon_map)
- 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)
+ tb_categories = self.db.get_tag_browser_categories()
+ for category in tb_categories.iterkeys():
+ if category in data: # They should always be there, but ...
+ self.row_map.append(category)
+ self.categories.append(tb_categories[category]['name'])
- # Now the rest of the normal tag categories
- 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.cat_icon_map.append(self.cat_icon_map_orig[i])
-
- # Clean up the author's tags, getting rid of the '|' characters
- if data['authors'] is not None:
- for t in data['authors']:
- t.name = t.name.replace('|', ',')
-
- # 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:
- taglist[c] = dict(map(lambda t:(t.name, t), data[c]))
-
- for c in self.user_categories:
- l = []
- for (name,label,ign) in self.user_categories[c]:
- if label in taglist and 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
- 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)
-
- 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.cat_icon_map.append(self.search_icon)
return data
def get_search_nodes(self, icon):
diff --git a/src/calibre/gui2/ui.py b/src/calibre/gui2/ui.py
index 36848e33cf..91b2353469 100644
--- a/src/calibre/gui2/ui.py
+++ b/src/calibre/gui2/ui.py
@@ -183,7 +183,7 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
_('Error communicating with device'), ' ')
self.device_error_dialog.setModal(Qt.NonModal)
self.tb_wrapper = textwrap.TextWrapper(width=40)
- self.device_connected = False
+ self.device_connected = None
self.viewers = collections.deque()
self.content_server = None
self.system_tray_icon = SystemTrayIcon(QIcon(I('library.png')), self)
@@ -675,6 +675,15 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
self._sync_menu.fetch_annotations.connect(self.fetch_annotations)
self._sync_menu.connect_to_folder.connect(self.connect_to_folder)
self._sync_menu.disconnect_from_folder.connect(self.disconnect_from_folder)
+ if self.device_connected:
+ self._sync_menu.connect_to_folder_action.setEnabled(False)
+ if self.device_connected == 'folder':
+ self._sync_menu.disconnect_from_folder_action.setEnabled(True)
+ else:
+ self._sync_menu.disconnect_from_folder_action.setEnabled(False)
+ else:
+ self._sync_menu.connect_to_folder_action.setEnabled(True)
+ self._sync_menu.disconnect_from_folder_action.setEnabled(False)
def add_spare_server(self, *args):
self.spare_servers.append(Server(limit=int(config['worker_limit']/2.0)))
@@ -944,7 +953,7 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
self.status_bar.showMessage(_('Device: ')+\
self.device_manager.device.__class__.get_gui_name()+\
_(' detected.'), 3000)
- self.device_connected = True
+ self.device_connected = 'device' if not is_folder_device else 'folder'
self._sync_menu.enable_device_actions(True,
self.device_manager.device.card_prefix(),
self.device_manager.device)
@@ -955,7 +964,7 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
self._sync_menu.connect_to_folder_action.setEnabled(True)
self._sync_menu.disconnect_from_folder_action.setEnabled(False)
self.save_device_view_settings()
- self.device_connected = False
+ self.device_connected = None
self._sync_menu.enable_device_actions(False)
self.location_view.model().update_devices()
self.vanity.setText(self.vanity_template%\
diff --git a/src/calibre/library/custom_columns.py b/src/calibre/library/custom_columns.py
index b6ada01b8c..36ea49763e 100644
--- a/src/calibre/library/custom_columns.py
+++ b/src/calibre/library/custom_columns.py
@@ -141,11 +141,15 @@ class CustomColumns(object):
}
# Create Tag Browser categories for custom columns
- for i, v in self.custom_column_num_map.items():
+ for k in sorted(self.custom_column_label_map.keys()):
+ v = self.custom_column_label_map[k]
if v['normalized']:
- tn = 'custom_column_{0}'.format(i)
- self.tag_browser_categories[v['label']] = {'table':tn, 'column':'value', 'type':v['datatype'], 'name':v['name']}
- #self.tag_browser_datatype[v['label']] = v['datatype']
+ tn = 'custom_column_{0}'.format(v['num'])
+ self.tag_browser_categories[v['label']] = {
+ 'table':tn, 'column':'value',
+ 'type':v['datatype'], 'is_multiple':v['is_multiple'],
+ 'kind':'custom', 'name':v['name']
+ }
def get_custom(self, idx, label=None, num=None, index_is_id=False):
if label is not None:
diff --git a/src/calibre/library/database2.py b/src/calibre/library/database2.py
index 12398de918..6ca73d9656 100644
--- a/src/calibre/library/database2.py
+++ b/src/calibre/library/database2.py
@@ -34,6 +34,8 @@ from calibre.customize.ui import run_plugins_on_import
from calibre.utils.filenames import ascii_filename
from calibre.utils.date import utcnow, now as nowf, utcfromtimestamp
from calibre.utils.ordered_dict import OrderedDict
+from calibre.utils.config import prefs
+from calibre.utils.search_query_parser import saved_searches
from calibre.ebooks import BOOK_EXTENSIONS, check_ebook_format
if iswindows:
@@ -125,26 +127,32 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
self.dbpath = self.dbpath.encode(filesystem_encoding)
# Order as has been customary in the tags pane.
- self.tag_browser_categories = OrderedDict([
- ('authors', {'table':'authors', 'column':'name', 'type':'text', 'name':_('Authors')}),
- ('series', {'table':'series', 'column':'name', 'type':None, 'name':_('Series')}),
- ('formats', {'table':None, 'column':None, 'type':None, 'name':_('Formats')}),
- ('publishers',{'table':'publishers', 'column':'name', 'type':'text', 'name':_('Publishers')}),
- ('ratings', {'table':'ratings', 'column':'rating', 'type':'rating', 'name':_('Ratings')}),
- ('news', {'table':'news', 'column':'name', 'type':None, 'name':_('News')}),
- ('tags', {'table':'tags', 'column':'name', 'type':'textmult', 'name':_('Tags')}),
- ])
-
-# self.tag_browser_datatype = {
-# 'tag' : 'textmult',
-# 'series' : None,
-# 'publisher' : 'text',
-# 'author' : 'text',
-# 'news' : None,
-# 'rating' : 'rating',
-# }
-
- self.tag_browser_formatters = {'rating': lambda x:u'\u2605'*int(round(x/2.))}
+ tag_browser_categories_items = [
+ ('authors', {'table':'authors', 'column':'name',
+ 'type':'text', 'is_multiple':False,
+ 'kind':'standard', 'name':_('Authors')}),
+ ('series', {'table':'series', 'column':'name',
+ 'type':None, 'is_multiple':False,
+ 'kind':'standard', 'name':_('Series')}),
+ ('formats', {'table':None, 'column':None,
+ 'type':None, 'is_multiple':False,
+ 'kind':'standard', 'name':_('Formats')}),
+ ('publishers',{'table':'publishers', 'column':'name',
+ 'type':'text', 'is_multiple':False,
+ 'kind':'standard', 'name':_('Publishers')}),
+ ('ratings', {'table':'ratings', 'column':'rating',
+ 'type':'rating', 'is_multiple':False,
+ 'kind':'standard', 'name':_('Ratings')}),
+ ('news', {'table':'news', 'column':'name',
+ 'type':None, 'is_multiple':False,
+ 'kind':'standard', 'name':_('News')}),
+ ('tags', {'table':'tags', 'column':'name',
+ 'type':'text', 'is_multiple':True,
+ 'kind':'standard', 'name':_('Tags')}),
+ ]
+ self.tag_browser_categories = OrderedDict()
+ for k,v in tag_browser_categories_items:
+ self.tag_browser_categories[k] = v
self.connect()
self.is_case_sensitive = not iswindows and not isosx and \
@@ -653,14 +661,19 @@ 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_tag_browser_categories(self):
+ return self.tag_browser_categories
+
def get_categories(self, sort_on_count=False, ids=None, icon_map=None):
self.books_list_filter.change([] if not ids else ids)
categories = {}
+
+ #### First, build the standard and custom-column categories ####
for category in self.tag_browser_categories.keys():
tn = self.tag_browser_categories[category]['table']
- categories[category] = [] #reserve the position in the ordered list
- if tn is None:
+ categories[category] = [] #reserve the position in the ordered list
+ if tn is None: # Nothing to do for the moment
continue
cn = self.tag_browser_categories[category]['column']
if ids is None:
@@ -672,22 +685,41 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
else:
query += ' ORDER BY {0} ASC'.format(cn)
data = self.conn.get(query)
- # category = cn[0]
+
+ # icon_map is not None if get_categories is to store an icon and
+ # possibly a tooltip in the tag structure.
icon, tooltip = None, ''
if icon_map:
- if category in icon_map:
- icon = icon_map[category]
- else:
+ if self.tag_browser_categories[category]['kind'] == 'standard':
+ if category in icon_map:
+ icon = icon_map[category]
+ elif self.tag_browser_categories[category]['kind'] == 'custom':
icon = icon_map['*custom']
+ icon_map[category] = icon_map['*custom']
tooltip = self.custom_column_label_map[category]['name']
+
datatype = self.tag_browser_categories[category]['type']
- formatter = self.tag_browser_formatters.get(datatype, lambda x: x)
+ if datatype == 'rating':
+ item_zero_func = (lambda x: len(formatter(r[1])) > 0)
+ formatter = (lambda x:u'\u2605'*int(round(x/2.)))
+ elif category == 'authors':
+ item_zero_func = (lambda x: x[2] > 0)
+ # Clean up the authors strings to human-readable form
+ formatter = (lambda x: x.replace('|', ','))
+ else:
+ item_zero_func = (lambda x: x[2] > 0)
+ formatter = (lambda x:x)
+
categories[category] = [Tag(formatter(r[1]), count=r[2], id=r[0],
icon=icon, tooltip = tooltip)
- for r in data
- if r[2] > 0 and
- (datatype != 'rating' or len(formatter(r[1])) > 0)]
+ for r in data if item_zero_func(r)]
+
+ # We delayed computing the standard formats category because it does not
+ # use a view, but is computed dynamically
categories['formats'] = []
+ icon = None
+ if icon_map and 'formats' in icon_map:
+ icon = icon_map['formats']
for fmt in self.conn.get('SELECT DISTINCT format FROM data'):
fmt = fmt[0]
if ids is not None:
@@ -702,13 +734,70 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
WHERE format="%s"'''%fmt,
all=False)
if count > 0:
- categories['formats'].append(Tag(fmt, count=count))
+ categories['formats'].append(Tag(fmt, count=count, icon=icon))
if sort_on_count:
categories['formats'].sort(cmp=lambda x,y:cmp(x.count, y.count),
reverse=True)
else:
categories['formats'].sort(cmp=lambda x,y:cmp(x.name, y.name))
+
+ #### Now do the user-defined categories. ####
+ user_categories = dict.copy(prefs['user_categories'])
+
+ # remove all user categories from tag_browser_categories. They can
+ # easily come and go. We will add all the existing ones in below.
+ for k in self.tag_browser_categories.keys():
+ if self.tag_browser_categories[k]['kind'] in ['user', 'search']:
+ del self.tag_browser_categories[k]
+
+ # We want to use same node in the user category as in the source
+ # category. To do that, we need to find the original Tag node. 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
+ # temporarily duplicating the categories lists.
+ taglist = {}
+ for c in categories.keys():
+ taglist[c] = dict(map(lambda t:(t.name, t), categories[c]))
+
+ for user_cat in sorted(user_categories.keys()):
+ items = []
+ for (name,label,ign) in user_categories[user_cat]:
+ if label in taglist and name in taglist[label]:
+ items.append(taglist[label][name])
+ # else: do nothing, to not include nodes w zero counts
+ if len(items):
+ cat_name = user_cat+'*' # add the * to avoid name collision
+ self.tag_browser_categories[cat_name] = {
+ 'table':None, 'column':None,
+ 'type':None, 'is_multiple':False,
+ 'kind':'user', 'name':user_cat}
+ # Not a problem if we accumulate entries in the icon map
+ if icon_map is not None:
+ icon_map[cat_name] = icon_map['*user']
+ if sort_on_count:
+ categories[cat_name] = \
+ sorted(items, cmp=(lambda x, y: cmp(y.count, x.count)))
+ else:
+ categories[cat_name] = \
+ sorted(items, cmp=(lambda x, y: cmp(x.name.lower(), y.name.lower())))
+
+ #### Finally, the saved searches category ####
+ items = []
+ icon = None
+ if icon_map and 'search' in icon_map:
+ icon = icon_map['search']
+ for srch in saved_searches.names():
+ items.append(Tag(srch, tooltip=saved_searches.lookup(srch), icon=icon))
+ if len(items):
+ self.tag_browser_categories['search'] = {
+ 'table':None, 'column':None,
+ 'type':None, 'is_multiple':False,
+ 'kind':'search', 'name':_('Searches')}
+ if icon_map is not None:
+ icon_map['search'] = icon_map['search']
+ categories['search'] = items
+
return categories
def tags_older_than(self, tag, delta):
diff --git a/src/calibre/utils/config.py b/src/calibre/utils/config.py
index 559721c193..69eee4d1ed 100644
--- a/src/calibre/utils/config.py
+++ b/src/calibre/utils/config.py
@@ -694,8 +694,10 @@ def _prefs():
help=_('Add new formats to existing book records'))
c.add_opt('installation_uuid', default=None, help='Installation UUID')
- # this is here instead of the gui preferences because calibredb can execute searches
+ # these are here instead of the gui preferences because calibredb and
+ # calibre server can execute searches
c.add_opt('saved_searches', default={}, help=_('List of named saved searches'))
+ c.add_opt('user_categories', default={}, help=_('User-created tag browser categories'))
c.add_opt('migrated', default=False, help='For Internal use. Don\'t modify.')
return c
From 8d9ddba6cd3052185c4159040d0e4ad60c182583 Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Mon, 24 May 2010 08:56:43 -0600
Subject: [PATCH 204/324] No longer install a UDEV file as the PRS500 is not
supported
---
src/calibre/linux.py | 35 -----------------------------------
1 file changed, 35 deletions(-)
diff --git a/src/calibre/linux.py b/src/calibre/linux.py
index ed806d58ac..26bbe0837b 100644
--- a/src/calibre/linux.py
+++ b/src/calibre/linux.py
@@ -132,7 +132,6 @@ class PostInstall:
self.mime_resources = []
if islinux:
self.setup_completion()
- self.setup_udev_rules()
self.install_man_pages()
if islinux:
self.setup_desktop_integration()
@@ -286,40 +285,6 @@ class PostInstall:
raise
self.task_failed('Setting up completion failed')
- def setup_udev_rules(self):
- self.info('Trying to setup udev rules...')
- try:
- group_file = os.path.join(self.opts.staging_etc, 'group')
- if not os.path.exists(group_file):
- group_file = '/etc/group'
- groups = open(group_file, 'rb').read()
- group = 'plugdev' if 'plugdev' in groups else 'usb'
- old_udev = '/etc/udev/rules.d/95-calibre.rules'
- if not os.path.exists(old_udev):
- old_udev = os.path.join(self.opts.staging_etc, 'udev/rules.d/95-calibre.rules')
- if os.path.exists(old_udev):
- try:
- os.remove(old_udev)
- except:
- self.warn('Old udev rules found, please delete manually:',
- old_udev)
- if self.opts.staging_root == '/usr':
- base = '/lib'
- else:
- base = os.path.join(self.opts.staging_root, 'lib')
- base = os.path.join(base, 'udev', 'rules.d')
- if not os.path.exists(base):
- os.makedirs(base)
- with open(os.path.join(base, '95-calibre.rules'), 'wb') as udev:
- self.manifest.append(udev.name)
- udev.write('''# Sony Reader PRS-500\n'''
- '''SUBSYSTEMS=="usb", SYSFS{idProduct}=="029b", SYSFS{idVendor}=="054c", MODE="660", GROUP="%s"\n'''%(group,)
- )
- except:
- if self.opts.fatal_errors:
- raise
- self.task_failed('Setting up udev rules failed')
-
def install_man_pages(self):
try:
from calibre.utils.help2man import create_man_page
From d91cd4419e5bc70a47965c6eae196893ec71b81e Mon Sep 17 00:00:00 2001
From: Kovid Goyal
Date: Mon, 24 May 2010 09:10:28 -0600
Subject: [PATCH 205/324] iPad image
---
resources/images/devices/ipad.png | Bin 0 -> 17785 bytes
1 file changed, 0 insertions(+), 0 deletions(-)
create mode 100644 resources/images/devices/ipad.png
diff --git a/resources/images/devices/ipad.png b/resources/images/devices/ipad.png
new file mode 100644
index 0000000000000000000000000000000000000000..119d53dc9afbb1dbff06c4cbe0b2db043a9ed29a
GIT binary patch
literal 17785
zcmV)AK*Ya^P)pPPiaF#P*7-ZbZ>KLZ*U+IBfRsybQWXdwQbLP>6pAqfylh#{fb6;Z(vMMVS~$e@S=j*ftg6;Uhf59&ghTmgWD0l;*T
zI709Y^p6lP1rIRMx#05C~cW=H_Aw*bJ-5DT&Z2n+x)QHX^p
z00esgV8|mQcmRZ%02D^@S3L16t`O%c004NIvOKvYIYoh62rY33S640`D9%Y2D-rV&nehQ1i
z007~1e$oCcFS8neI|hJl{-P!B1ZZ9hpmq0)X0i`JwE&>$+E?>%_LC6RbVIkUx0b+_+BaR3cnT7Zv!AJxW
zizFb)h!jyGOOZ85F;a?DAXP{m@;!0_IfqH8(HlgRxt7s3}k3K`kFu>>-2Q$QMFfPW!La{h336o>X
zu_CMttHv6zR;&ZNiS=X8v3CR#fknUxHUxJ0uoBa_M6WNWeqIg~6QE69c9o#eyhGvpiOA@W-aonk<7r1(?fC{oI5N*U!4
zfg=2N-7=cNnjjOr{yriy6mMFgG#l
znCF=fnQv8CDz++o6_Lscl}eQ+l^ZHARH>?_s@|##Rr6KLRFA1%Q+=*RRWnoLsR`7U
zt5vFIcfW3@?wFpwUVxrVZ>QdQz32KIeJ}k~{cZZE^+ya?
z2D1z#2HOnI7(B%_ac?{wFUQ;QQA1tBKtrWrm0_3Rgps+?Jfqb{jYbcQX~taRB;#$y
zZN{S}1|}gUOHJxc?wV3fxuz+mJ4`!F$IZ;mqRrNsHJd##*D~ju=bP7?-?v~|cv>vB
zsJ6IeNwVZxrdjT`yl#bBIa#GxRa#xMMy;K#CDyyGyQdMSxlWT#tDe?p!?5wT$+oGt
z8L;Kp2HUQ-ZMJ=3XJQv;x5ci*?vuTfeY$;({XGW_huIFR9a(?@3)XSs8O^N5RyOM=TTmp(3=8^+zpz2r)C
z^>JO{deZfso3oq3?Wo(Y?l$ge?uXo;%ru`Vo>?<<(8I_>;8Eq#KMS9gFl*neeosSB
zfoHYnBQIkwkyowPu(zdms`p{<7e4kra-ZWq<2*OsGTvEV%s0Td$hXT+!*8Bnh2KMe
zBmZRodjHV?r+_5^X9J0WL4jKW`}lf%A-|44I@@LTvf1rHjG(ze6+w@Jt%Bvjts!X0
z?2xS?_ve_-kiKB_KiJlZ$9G`c^=E@oNG)mWWaNo-3TIW8)$Hg0Ub-~8?KhvJ>$
z3*&nim@mj(aCxE5!t{lw7O5^0EIO7zOo&c6l<+|iDySBWCGrz@C5{St!X3hAA}`T4
z(TLbXTq+(;@<=L8dXnssyft|w#WSTW<++3>sgS%(4NTpeI-VAqb|7ssJvzNHgOZVu
zaYCvgO_R1~>SyL=cFU|~g|hy|Zi}}s9+d~lYqOB71z9Z$wnC=pR9Yz4DhIM>Wmjgu
z&56o6maCpC&F##y%G;1PobR9i?GnNg;gYtchD%p19a!eQtZF&3JaKv33gZ<8D~47E
ztUS1iwkmDaPpj=$m#%)jCVEY4fnLGNg2A-`YwHVD3gv};>)hAvT~AmqS>Lr``i7kw
zJ{5_It`yrBmlc25DBO7E8;5VoznR>Ww5hAaxn$2~(q`%A-YuS64wkBy=9dm`4cXeX
z4c}I@?e+FW+b@^RDBHV(wnMq2zdX3SWv9u`%{xC-q*U}&`cyXV(%rRT*Z6MH?i+i&
z_B8C(+grT%{XWUQ+f@NoP1R=AW&26{v-dx)iK^-Nmiuj8txj!m?Z*Ss1N{dh4z}01
z)YTo*JycSU)+_5r4#yw9{+;i4Ee$peRgIj+;v;ZGdF1K$3E%e~4LaI(jC-u%2h$&R
z9cLXcYC@Xwnns&bn)_Q~Te?roKGD|d-g^8;+aC{{G(1^(O7m37Y1-+6)01cN&y1aw
zoqc{T`P^XJqPBbIW6s}d4{z_f5Om?vMgNQEJG?v2T=KYd^0M3I6IZxbny)%vZR&LD
zJpPl@Psh8QyPB@KTx+@RdcC!KX7}kEo;S|j^u2lU7XQ}Oo;f|;z4Ll+_r>@1-xl3|
zawq-H%e&ckC+@AhPrP6BKT#_XdT7&;F71j}Joy
zkC~6lh7E@6o;W@^IpRNZ{ptLtL(gQ-CY~4mqW;US7Zxvm_|@yz&e53Bp_lTPlfP|z
zrTyx_>lv@x#=^!PzR7qqF<$gm`|ZJZ+;<)Cqu&ot2z=0000WV@Og>004R=004l4008;_004mL004C`008P>0026e000+nl3&F}
z0026qNklJ=6^^hhhb1sL97b3kWGoZ55kg^Pfw}>SX6%8U=^E%7x@*cBGArkNnQy-L
zo^$rz{$uYm-S=MR%MP$0!msMadwGX*&sk^hwb%OAx4tbez4QtYxOnjb*H&&kbmYj9
z9~le=PmG4ccZU!HBGfXaPhJ(fC~aA5rDTjT>xjZ2^LF4(A*P4<01T
zGMsbdd2WkY@mf`tUnq*=!;4FcA0G{e#l835hgH@31pc;){I{R})MpNS`qQ80+_^WI
z&1T&ENBX7_>0S7|;iPkOBwisvx=4D@Nqqy-^U{4!=Q$Nc5Q2C_wE2EfP4AW7@A#ne
zJgC-27KDIk=e6br(D`iqys?U^>D_McJt25N5rKt;@w@K2>&|z5{nx+m#}^jH|2Bm1
zgTS_Y@rzGURn-rkKmXRh{JEd|ANcHNKh2-ykBI3ROp`(C-_1kgh!PRhnBIF-;{}Rn
z`n!EjMNtzkQq`cU7^CU4_50h0h+vGFJ9hg3!I=JQy>qEYe!4k3uA>
zvT}{@{hsgPLm&FCf3tjW`Frik_3LU>|MQ>!-~K6|{p_dHRMS0-D7P@O?08>uECDo)
z(0VG1AZYL8cU-7;jTooAF{T-*^+QxJ>GkG0>HgL|4JCWbtlefA0zuj_9_rb<$G@{I
zmmsC>k!i4A+(3?9(Uf)@AmgzpP7HpUdL>m
zY@n(t08XAb!SDXg@8W&C8ajjq9`=*8FOPi2ee8)Fy^MVI-?&8~Hcio6jp}zEv+co&S@tf}s?G3n@kFRG#
zZMd$1ZWTcbQvUYkw~?H(jOUb^_i9RJ+CvcrT5csV{Wr
z$nP3hZu)ooyK6w{4T#$@eAhi%WdAOvJxEBYMxABu%rV>KGv$o}0T)MK5JUoAh33DY
zDUng|zB#r})77W&F}f8=zCJFz(Z36-f#8#?YHmtMuSZ`WM{tN|F9DwhgK8iIO*b^o
zjOg~_jBKt|NjJC~UA6hi{7kLgq=+^Py!ReUxb<{tjcLO5gC!HUnvB+aNcS#6t$b-yzua`kIcCui|*I{60a
zd0f2N-Nm^(Q|jltF3j$}2(s&{{qVyN@xVJCV0~?avuDrpp7*?)&wcKXSX*1isBIDq
zX^dWHc**?f;(G~HXrGh%f{~PsN|Q;FBA}Be?%ktM^#q5^4fXIsscMi(<}@kgk-dkjD;vPv#9eOs_l$S8F31i
zVwfv}D4oCKv4XVe>&vg6Bg-vC6&HTz=q<`^s2PKTs;Z*lyD{*d%c2U~rKh-2fm_Aq
z%dBk$vW3G<#w%&0G+@~rK0|0PSfmEB5-toZi9
zg+nLMEv!Zj)@G2VzKTW^PKl6!nA)SXi%XpNX>lT5h&GFA&^9)&7cvRjBpm@k
zlF