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

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

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

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

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

calibre.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.h
1 + + 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 @@ 0 0 - 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.h
1 + + 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'%s:%s'%(key, txt) - self.book_data.setText(u''+rows+u'
') + comments = comments_to_html(txt) + else: + rows.append((key, txt)) + rows = '\n'.join([u'%s:%s'%(k,t) for + k, t in rows]) + if comments: + comments = 'Comments:'+comments + left_pane = u'%s
'%rows + right_pane = u'
%s
'%comments + self.book_data.setText(u'
%s%s
' + % (left_pane, right_pane)) self.clear_message() self.book_data.updateGeometry() self.updateGeometry() self.setVisible(True) -class MovieButton(QFrame): - - def __init__(self, jobs_dialog): - QFrame.__init__(self) - 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.Fixed) - self.jobs_dialog = jobs_dialog - self.setCursor(Qt.PointingHandCursor) - self.setToolTip(_('Click to see list of active jobs.')) - 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 CoverFlowButton(QToolButton): - - def __init__(self, parent=None): - QToolButton.__init__(self, parent) - self.setIconSize(QSize(80, 80)) - self.setIcon(QIcon(I('cover_flow.svg'))) - self.setCheckable(True) - self.setChecked(False) - self.setAutoRaise(True) - self.setSizePolicy(QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Expanding)) - self.connect(self, SIGNAL('toggled(bool)'), self.adjust_tooltip) - self.adjust_tooltip(False) - self.setCursor(Qt.PointingHandCursor) - - def adjust_tooltip(self, on): - tt = _('Click to turn off Cover Browsing') if on else _('Click to browse books by their covers') - self.setToolTip(tt) - - def disable(self, reason): - self.setDisabled(True) - self.setToolTip(_('

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 @@ 0 0 - 869 - 698 + 879 + 711 @@ -52,625 +52,639 @@ 0 - + 800 665 - - - - - 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 @@ 0 0 - 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 @@ 0 0 528 - 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_box column_heading_box column_type_box + date_format_box button_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<author>[ \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 <kovid@kovidgoyal.net> 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 <kovid@kovidgoyal.net> 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('<p>'+_('The selected books will be ' + '<b>permanently deleted</b> ' + 'from your device. Are you sure?') + +'</p>', '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 <kovid@kovidgoyal.net> 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 <kovid@kovidgoyal.net>' +__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 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ui version="4.0"> + <class>WizardPage</class> + <widget class="QWizardPage" name="WizardPage"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>400</width> + <height>300</height> + </rect> + </property> + <property name="windowTitle"> + <string>WizardPage</string> + </property> + <property name="title"> + <string>Scanning root folder for books</string> + </property> + <property name="subTitle"> + <string>This may take a few minutes</string> + </property> + </widget> + <resources/> + <connections/> +</ui> 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 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ui version="4.0"> + <class>WizardPage</class> + <widget class="QWizardPage" name="WizardPage"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>704</width> + <height>468</height> + </rect> + </property> + <property name="windowTitle"> + <string>WizardPage</string> + </property> + <property name="title"> + <string>Choose the location to add books from</string> + </property> + <property name="subTitle"> + <string>Select a folder on your hard disk</string> + </property> + <layout class="QGridLayout" name="gridLayout"> + <item row="0" column="0" colspan="3"> + <widget class="QLabel" name="label"> + <property name="text"> + <string><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></string> + </property> + <property name="wordWrap"> + <bool>true</bool> + </property> + </widget> + </item> + <item row="2" column="0"> + <widget class="QLabel" name="label_2"> + <property name="text"> + <string>&Root folder:</string> + </property> + <property name="buddy"> + <cstring>opt_root_folder</cstring> + </property> + </widget> + </item> + <item row="2" column="1"> + <widget class="QLineEdit" name="opt_root_folder"> + <property name="toolTip"> + <string>This folder and its sub-folders will be scanned for books to import into calibre's library</string> + </property> + </widget> + </item> + <item row="2" column="2"> + <widget class="QToolButton" name="button_choose_root_folder"> + <property name="toolTip"> + <string>Choose root folder</string> + </property> + <property name="text"> + <string>...</string> + </property> + <property name="icon"> + <iconset resource="../../../../resources/images.qrc"> + <normaloff>:/images/document_open.svg</normaloff>:/images/document_open.svg</iconset> + </property> + </widget> + </item> + <item row="4" column="0" colspan="3"> + <widget class="QGroupBox" name="groupBox"> + <property name="title"> + <string>Handle multiple files per book</string> + </property> + <layout class="QVBoxLayout" name="verticalLayout"> + <item> + <widget class="QRadioButton" name="opt_one_per_folder"> + <property name="text"> + <string>&One book per folder, assumes every ebook file in a folder is the same book in a different format</string> + </property> + </widget> + </item> + <item> + <widget class="QRadioButton" name="opt_many_per_folder"> + <property name="text"> + <string>&Multiple books per folder, assumes every ebook file is a different book</string> + </property> + </widget> + </item> + </layout> + </widget> + </item> + <item row="5" column="0"> + <spacer name="verticalSpacer"> + <property name="orientation"> + <enum>Qt::Vertical</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>20</width> + <height>40</height> + </size> + </property> + </spacer> + </item> + <item row="3" column="1"> + <spacer name="verticalSpacer_2"> + <property name="orientation"> + <enum>Qt::Vertical</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>20</width> + <height>40</height> + </size> + </property> + </spacer> + </item> + <item row="1" column="1"> + <spacer name="verticalSpacer_3"> + <property name="orientation"> + <enum>Qt::Vertical</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>20</width> + <height>40</height> + </size> + </property> + </spacer> + </item> + </layout> + </widget> + <resources> + <include location="../../../../resources/images.qrc"/> + </resources> + <connections/> +</ui> 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 <kovid@kovidgoyal.net>' +__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 <kovid@kovidgoyal.net> 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 <kovid@kovidgoyal.net> 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 <kovid@kovidgoyal.net> 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 <kovid@kovidgoyal.net> 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 <kovid@kovidgoyal.net> 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 <kovid@kovidgoyal.net> 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 <kovid@kovidgoyal.net> 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 <kovid@kovidgoyal.net> 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 <kovid@kovidgoyal.net> 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 <kovid@kovidgoyal.net> 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 <kovid@kovidgoyal.net> 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 <kovid@kovidgoyal.net> 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 <kovid@kovidgoyal.net> 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 <kovid@kovidgoyal.net> 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 <kovid@kovidgoyal.net> 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 <kovid@kovidgoyal.net> 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 <kovid@kovidgoyal.net> 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 <kovid@kovidgoyal.net>' +__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 <kovid@kovidgoyal.net>' +__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 <kovid at kovidgoyal.net>' +__copyright__ = '2010, Kovid Goyal <kovid@kovidgoyal.net>' +__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 <kovid@kovidgoyal.net>' +__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 @@ <customwidget> <class>BooksView</class> <extends>QTableView</extends> - <header>library.h</header> + <header>calibre/gui2/library/views.h</header> </customwidget> <customwidget> <class>LocationView</class> @@ -803,7 +803,7 @@ <customwidget> <class>DeviceBooksView</class> <extends>QTableView</extends> - <header>library.h</header> + <header>calibre/gui2/library/views.h</header> </customwidget> <customwidget> <class>TagsView</class> 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 <kovid@kovidgoyal.net>' __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 <kovid@kovidgoyal.net> 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 <kovid@kovidgoyal.net> 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 <kovid@kovidgoyal.net>' __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 <kovid@kovidgoyal.net> 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 <kovid@kovidgoyal.net>' __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 <b>edit</b> me<br><br>")) - 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 <kovid@kovidgoyal.net> 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 <kovid@kovidgoyal.net> 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 <kovid@kovidgoyal.net> 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 <kovid@kovidgoyal.net> 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 <b>edit</b> me<br><br>")) 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 <kovid@kovidgoyal.net> 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 <kovid@kovidgoyal.net> 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 <kovid@kovidgoyal.net> 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 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<!-- Created with Inkscape (http://www.inkscape.org/) --> +<svg + xmlns:dc="http://purl.org/dc/elements/1.1/" + xmlns:cc="http://web.resource.org/cc/" + xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:svg="http://www.w3.org/2000/svg" + xmlns="http://www.w3.org/2000/svg" + xmlns:xlink="http://www.w3.org/1999/xlink" + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" + version="1.0" + x="0.0000000" + y="0.0000000" + width="48.000000px" + height="48.000000px" + id="svg1" + sodipodi:version="0.32" + inkscape:version="0.44" + sodipodi:docname="folder.svg" + sodipodi:docbase="/home/lapo/Icone/Crux/crux-icon-theme/scalable/places" + inkscape:export-filename="/home/lapo/Icone/Crux/folderx-daritaliare.png" + inkscape:export-xdpi="90" + inkscape:export-ydpi="90" + inkscape:output_extension="org.inkscape.output.svg.inkscape"> + <metadata + id="metadata162"> + <rdf:RDF> + <cc:Work + rdf:about=""> + <dc:format>image/svg+xml</dc:format> + <dc:type + rdf:resource="http://purl.org/dc/dcmitype/StillImage" /> + <dc:title>Folder</dc:title> + <dc:creator> + <cc:Agent> + <dc:title>Lapo Calamandrei</dc:title> + </cc:Agent> + </dc:creator> + <dc:date>2006-06-26</dc:date> + <cc:license + rdf:resource="http://creativecommons.org/licenses/GPL/2.0/" /> + <dc:identifier /> + <dc:subject> + <rdf:Bag> + <rdf:li>folder</rdf:li> + <rdf:li>directory</rdf:li> + <rdf:li>storage</rdf:li> + </rdf:Bag> + </dc:subject> + </cc:Work> + <cc:License + rdf:about="http://creativecommons.org/licenses/GPL/2.0/"> + <cc:permits + rdf:resource="http://web.resource.org/cc/Reproduction" /> + <cc:permits + rdf:resource="http://web.resource.org/cc/Distribution" /> + <cc:requires + rdf:resource="http://web.resource.org/cc/Notice" /> + <cc:permits + rdf:resource="http://web.resource.org/cc/DerivativeWorks" /> + <cc:requires + rdf:resource="http://web.resource.org/cc/ShareAlike" /> + <cc:requires + rdf:resource="http://web.resource.org/cc/SourceCode" /> + </cc:License> + </rdf:RDF> + </metadata> + <sodipodi:namedview + id="base" + pagecolor="#ffffff" + bordercolor="#666" + borderopacity="1" + inkscape:pageopacity="0.0" + inkscape:pageshadow="2" + inkscape:window-width="1041" + inkscape:window-height="655" + inkscape:cy="24.626698" + inkscape:cx="45.136759" + inkscape:zoom="8" + inkscape:document-units="px" + showgrid="false" + inkscape:window-x="504" + inkscape:window-y="101" + inkscape:current-layer="layer2" + inkscape:showpageshadow="false" + showguides="false" + inkscape:guide-bbox="true" + inkscape:object-paths="false" + gridspacingx="0.5px" + gridspacingy="0.5px" + gridempspacing="2" + inkscape:grid-points="false" + showborder="true" + borderlayer="true"> + <sodipodi:guide + orientation="horizontal" + position="36.062446" + id="guide1934" /> + <sodipodi:guide + orientation="horizontal" + position="15.003922" + id="guide1941" /> + <sodipodi:guide + orientation="vertical" + position="4.5" + id="guide1943" /> + <sodipodi:guide + orientation="vertical" + position="44.503533" + id="guide1945" /> + <sodipodi:guide + orientation="horizontal" + position="43.125" + id="guide1947" /> + <sodipodi:guide + orientation="horizontal" + position="39" + id="guide1949" /> + <sodipodi:guide + orientation="horizontal" + position="19.003495" + id="guide2919" /> + <sodipodi:guide + orientation="vertical" + position="0.97227183" + id="guide2212" /> + <sodipodi:guide + orientation="vertical" + position="47.994873" + id="guide2214" /> + <sodipodi:guide + orientation="horizontal" + position="111" + id="guide4328" /> + <sodipodi:guide + orientation="vertical" + position="65.75" + id="guide3135" /> + <sodipodi:guide + orientation="vertical" + position="129.75" + id="guide3137" /> + <sodipodi:guide + orientation="vertical" + position="190.75" + id="guide3139" /> + <sodipodi:guide + orientation="vertical" + position="212.48559" + id="guide3316" /> + <sodipodi:guide + orientation="horizontal" + position="178.01413" + id="guide3318" /> + </sodipodi:namedview> + <defs + id="defs3"> + <linearGradient + inkscape:collect="always" + id="linearGradient4232"> + <stop + style="stop-color:#ad7fa8;stop-opacity:1;" + offset="0" + id="stop4234" /> + <stop + style="stop-color:#75507b;stop-opacity:1" + offset="1" + id="stop4236" /> + </linearGradient> + <linearGradient + inkscape:collect="always" + id="linearGradient3311"> + <stop + style="stop-color:#888a85;stop-opacity:1;" + offset="0" + id="stop3313" /> + <stop + style="stop-color:#555753;stop-opacity:1" + offset="1" + id="stop3315" /> + </linearGradient> + <linearGradient + id="linearGradient3076"> + <stop + id="stop3078" + offset="0" + style="stop-color:#5c3566;stop-opacity:1" /> + <stop + id="stop3080" + offset="1" + style="stop-color:#5c3566;stop-opacity:0" /> + </linearGradient> + <linearGradient + inkscape:collect="always" + id="linearGradient2994"> + <stop + style="stop-color:#5c3566;stop-opacity:1" + offset="0" + id="stop2996" /> + <stop + style="stop-color:#5c3566;stop-opacity:0" + offset="1" + id="stop2998" /> + </linearGradient> + <linearGradient + inkscape:collect="always" + id="linearGradient3923"> + <stop + style="stop-color:white;stop-opacity:1;" + offset="0" + id="stop3925" /> + <stop + style="stop-color:white;stop-opacity:0;" + offset="1" + id="stop3927" /> + </linearGradient> + <linearGradient + id="linearGradient3908"> + <stop + style="stop-color:white;stop-opacity:1;" + offset="0" + id="stop3910" /> + <stop + style="stop-color:white;stop-opacity:0;" + offset="1" + id="stop3912" /> + </linearGradient> + <linearGradient + id="linearGradient2894"> + <stop + style="stop-color:#39213f;stop-opacity:1;" + offset="0" + id="stop2896" /> + <stop + id="stop2900" + offset="0.47619048" + style="stop-color:#75507b;stop-opacity:1;" /> + <stop + style="stop-color:#5c3566;stop-opacity:1" + offset="1" + id="stop2898" /> + </linearGradient> + <linearGradient + id="linearGradient3100"> + <stop + style="stop-color:white;stop-opacity:1;" + offset="0" + id="stop3102" /> + <stop + id="stop2071" + offset="1" + style="stop-color:white;stop-opacity:0;" /> + </linearGradient> + <linearGradient + id="linearGradient3018"> + <stop + style="stop-color:white;stop-opacity:1;" + offset="0" + id="stop3020" /> + <stop + style="stop-color:white;stop-opacity:0;" + offset="1" + id="stop3022" /> + </linearGradient> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient3100" + id="linearGradient3685" + gradientUnits="userSpaceOnUse" + x1="17.02047" + y1="-16.276186" + x2="17.02047" + y2="-29.344501" /> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient2894" + id="linearGradient3687" + gradientUnits="userSpaceOnUse" + gradientTransform="matrix(1,0,0,0.996152,0,85.74795)" + x1="9.4176369" + y1="-44.922661" + x2="9.4176369" + y2="-59.636772" /> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient3076" + id="linearGradient3689" + gradientUnits="userSpaceOnUse" + x1="16.749592" + y1="21.616077" + x2="16.749592" + y2="32.797989" /> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient2994" + id="linearGradient3691" + gradientUnits="userSpaceOnUse" + x1="16.749592" + y1="40.51022" + x2="16.749592" + y2="36.268337" /> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient3908" + id="linearGradient3693" + gradientUnits="userSpaceOnUse" + x1="8.25" + y1="-14.375" + x2="8.25" + y2="-30.879261" /> + <radialGradient + inkscape:collect="always" + xlink:href="#linearGradient3923" + id="radialGradient3695" + gradientUnits="userSpaceOnUse" + gradientTransform="matrix(2.268741,0,-1.646661e-6,0.184077,-10.86781,18.45272)" + cx="10.189716" + cy="16.554359" + fx="10.189716" + fy="16.554359" + r="22.5" /> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient3311" + id="linearGradient3938" + gradientUnits="userSpaceOnUse" + gradientTransform="translate(0,1)" + x1="20.625" + y1="-90.064087" + x2="20.625" + y2="-84.029831" /> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient3018" + id="linearGradient3129" + gradientUnits="userSpaceOnUse" + x1="9" + y1="-92.805496" + x2="9" + y2="-83.4375" /> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient4232" + id="linearGradient4238" + x1="0.99999888" + y1="30.499076" + x2="53.999733" + y2="37.624077" + gradientUnits="userSpaceOnUse" /> + </defs> + <g + inkscape:groupmode="layer" + id="layer2" + inkscape:label="vectors" + style="display:inline"> + <g + id="g891" + transform="matrix(0.186703,0,0,0.186703,-21.1073,57.62299)" /> + <g + id="g3131" + inkscape:export-filename="/home/lapo/Icone/Crux/folderx.png" + inkscape:export-xdpi="90" + inkscape:export-ydpi="90" + transform="translate(-1,102)"> + <path + inkscape:export-ydpi="90" + inkscape:export-xdpi="90" + inkscape:export-filename="/home/lapo/Icone/Crux/folderx-alt.png" + sodipodi:nodetypes="czcccccccscc" + id="path3108" + d="M 8.5,-94.500002 C 7.5975,-94.500002 7.168127,-94.186392 7,-93.04412 C 5.118026,-83.070943 5.215756,-74.96574 5.5,-64.864899 C 5.5,-64.115602 6.116307,-63.50001 6.875,-63.50001 L 42.125,-63.50001 C 42.88369,-63.50001 43.44816,-64.117293 43.5,-64.864899 C 44.5,-82.91177 42.5,-88.135111 42.5,-88.135111 C 42.5,-88.884408 41.88369,-89.5 41.125,-89.5 L 23.5,-89.5 L 22.5,-92.897058 C 22.254867,-93.729789 21.9025,-94.500002 21,-94.500002 L 8.5,-94.500002 z " + style="fill:url(#linearGradient3938);fill-opacity:1;stroke:#555753;stroke-width:0.99999934;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;display:inline" /> + <path + id="path3110" + d="M 8.5,-93.5 C 8.167757,-93.5 8.1531396,-93.465856 8.15625,-93.46875 C 8.1593604,-93.471644 8.0371607,-93.339789 7.96875,-92.875 C 7.9689149,-92.864584 7.9689149,-92.854166 7.96875,-92.84375 C 6.2697576,-83.840251 6.2178889,-74.709407 6.4375,-65.5 L 42.625,-65.5 C 43.308005,-81.554915 41.5625,-87.875 41.5625,-87.875 C 41.530831,-87.955244 41.509819,-88.039293 41.5,-88.125 C 41.5,-88.333118 41.341065,-88.5 41.125,-88.5 L 23.5,-88.5 C 23.062797,-88.50549 22.681309,-88.797964 22.5625,-89.21875 L 21.5625,-92.625 C 21.45311,-92.996605 21.310238,-93.289949 21.21875,-93.40625 C 21.127262,-93.522551 21.173845,-93.5 21,-93.5 L 8.5,-93.5 z " + style="opacity:0.5;fill:none;fill-opacity:1;stroke:url(#linearGradient3129);stroke-width:0.99999934;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;display:inline" /> + </g> + <g + style="display:inline" + transform="matrix(0.216083,0,0,0.263095,-1.89323,-11.2424)" + id="g3112" + inkscape:export-filename="/home/lapo/Icone/Crux/folderx.png" + inkscape:export-xdpi="90" + inkscape:export-ydpi="90"> + <path + d="M 32.706693,164.36026 C 22.319193,164.36026 13.956693,172.72276 13.956693,183.11026 C 13.956693,193.49776 22.319193,201.86026 32.706693,201.86026 L 205.20669,201.86026 C 215.59419,201.86026 223.95669,193.49776 223.95669,183.11026 C 223.95669,172.72276 215.59419,164.36026 205.20669,164.36026 L 32.706693,164.36026 z " + style="opacity:0.04787233;fill-rule:evenodd;stroke-width:3pt" + id="path3114" /> + <path + d="M 32.706693,165.61026 C 23.011693,165.61026 15.206693,173.41526 15.206693,183.11026 C 15.206693,192.80526 23.011693,200.61026 32.706693,200.61026 L 205.20669,200.61026 C 214.90169,200.61026 222.70669,192.80526 222.70669,183.11026 C 222.70669,173.41526 214.90169,165.61026 205.20669,165.61026 L 32.706693,165.61026 z " + style="opacity:0.04787233;fill-rule:evenodd;stroke-width:3pt" + id="path3116" /> + <path + d="M 32.706694,166.86026 C 23.704194,166.86026 16.456694,174.10776 16.456694,183.11026 C 16.456694,192.11276 23.704194,199.36026 32.706694,199.36026 L 205.20669,199.36026 C 214.20919,199.36026 221.45669,192.11276 221.45669,183.11026 C 221.45669,174.10776 214.20919,166.86026 205.20669,166.86026 L 32.706694,166.86026 z " + style="opacity:0.04787233;fill-rule:evenodd;stroke-width:3pt" + id="path3118" /> + <path + d="M 32.706694,168.11026 C 24.396694,168.11026 17.706694,174.80026 17.706694,183.11026 C 17.706694,191.42026 24.396694,198.11026 32.706694,198.11026 L 205.20669,198.11026 C 213.51669,198.11026 220.20669,191.42026 220.20669,183.11026 C 220.20669,174.80026 213.51669,168.11026 205.20669,168.11026 L 32.706694,168.11026 z " + style="opacity:0.04787233;fill-rule:evenodd;stroke-width:3pt" + id="path3120" /> + <path + d="M 32.707764,169.36026 C 25.090264,169.36026 18.957764,175.49276 18.957764,183.11026 C 18.957764,190.72776 25.090264,196.86026 32.707764,196.86026 L 205.20618,196.86026 C 212.82368,196.86026 218.95618,190.72776 218.95618,183.11026 C 218.95618,175.49276 212.82368,169.36026 205.20618,169.36026 L 32.707764,169.36026 z " + style="opacity:0.04787233;fill-rule:evenodd;stroke-width:3pt" + id="path3122" /> + <path + d="M 32.706694,170.61026 C 25.781694,170.61026 20.206694,176.18526 20.206694,183.11026 C 20.206694,190.03526 25.781694,195.61026 32.706694,195.61026 L 205.20669,195.61026 C 212.13169,195.61026 217.70669,190.03526 217.70669,183.11026 C 217.70669,176.18526 212.13169,170.61026 205.20669,170.61026 L 32.706694,170.61026 z " + style="opacity:0.04787233;fill-rule:evenodd;stroke-width:3pt" + id="path3124" /> + <path + d="M 32.706694,171.86026 C 26.474194,171.86026 21.456694,176.87776 21.456694,183.11026 C 21.456694,189.34276 26.474194,194.36026 32.706694,194.36026 L 205.20669,194.36026 C 211.43919,194.36026 216.45669,189.34276 216.45669,183.11026 C 216.45669,176.87776 211.43919,171.86026 205.20669,171.86026 L 32.706694,171.86026 z " + style="opacity:0.04787233;fill-rule:evenodd;stroke-width:3pt" + id="path3126" /> + <path + d="M 32.706694,173.11026 C 27.166694,173.11026 22.706694,177.57026 22.706694,183.11026 C 22.706694,188.65026 27.166694,193.11026 32.706694,193.11026 L 205.20669,193.11026 C 210.74669,193.11026 215.20669,188.65026 215.20669,183.11026 C 215.20669,177.57026 210.74669,173.11026 205.20669,173.11026 L 32.706694,173.11026 z " + style="opacity:0.04787233;fill-rule:evenodd;stroke-width:3pt" + id="path3128" /> + </g> + <g + style="display:inline" + id="g3661" + transform="translate(-1,-2)" + inkscape:export-filename="/home/lapo/Icone/Crux/folderx.png" + inkscape:export-xdpi="90" + inkscape:export-ydpi="90"> + <g + id="g3663"> + <path + sodipodi:type="inkscape:offset" + inkscape:radius="-0.99436891" + inkscape:original="M 2.65625 -33.5 C 2.021877 -33.5 1.5 -33.140103 1.5 -32.6875 C 4.500001 -25.004483 4.5 -23.258435 4.5 -16.5 C 4.5 -15.5 5.5111718 -13.51291 6.65625 -13.4375 L 42.34375 -13.4375 C 43.989096 -13.51291 44.500268 -15.758435 44.5 -16.5 C 44.5 -21.258435 44.500267 -25.004482 47.5 -32.6875 C 47.5 -33.140104 46.978125 -33.5 46.34375 -33.5 L 2.65625 -33.5 z " + style="opacity:0.32156863;fill:none;fill-opacity:1;stroke:url(#linearGradient3685);stroke-width:1.00000036;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;display:inline" + id="path3667" + d="M 2.625,-32.5 C 5.3597846,-25.370787 5.5,-23.037216 5.5,-16.5 C 5.5,-16.335159 5.6904313,-15.649773 6,-15.15625 C 6.3095687,-14.662727 6.6985247,-14.438832 6.71875,-14.4375 L 42.3125,-14.4375 L 42.34375,-14.4375 C 42.706073,-14.463351 42.916739,-14.679637 43.15625,-15.15625 C 43.401014,-15.643317 43.500051,-16.358058 43.5,-16.5 C 43.5,-21.130327 43.573003,-25.119501 46.375,-32.5 C 46.360642,-32.501163 46.358943,-32.5 46.34375,-32.5 L 2.65625,-32.5 C 2.6410568,-32.5 2.6393578,-32.501163 2.625,-32.5 z " + transform="translate(0,54)" /> + <path + style="opacity:0.3254902;fill:none;fill-rule:evenodd;stroke:white;stroke-width:1px;stroke-linecap:round;stroke-linejoin:miter;stroke-opacity:1" + d="M 2.5,21.5 L 46.5,21.5" + id="path3669" + sodipodi:nodetypes="cc" /> + </g> + <path + sodipodi:nodetypes="ccccccccc" + id="path3671" + d="M 2.645078,20.5 L 46.354654,20.5 C 46.989027,20.5 47.499732,20.862969 47.499732,21.313831 C 44.5,28.967284 44.5,32.694452 44.5,37.434576 C 44.500268,38.173288 44,40.423032 42.354654,40.498152 L 6.645078,40.498152 C 5.5,40.423032 4.5,38.430728 4.5,37.434576 C 4.5,30.702148 4.5,28.967284 1.499999,21.313831 C 1.499999,20.862969 2.010705,20.5 2.645078,20.5 z " + style="fill:url(#linearGradient4238);fill-opacity:1;stroke:url(#linearGradient3687);stroke-width:1.00000024;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;display:inline" /> + <path + id="path3673" + d="M 2.65625,21 C 2.4314409,21 2.2397168,21.044736 2.125,21.125 C 2.0102832,21.205264 2,21.274638 2,21.3125 C 4.2218558,27.007434 4.7883072,29.513938 4.9375,33.3125 L 44.125,33.3125 C 44.328358,30.089308 44.943507,26.607344 47,21.3125 C 47,21.274637 46.989716,21.205264 46.875,21.125 C 46.760284,21.044736 46.56856,21 46.34375,21 L 2.65625,21 z " + style="opacity:0.8;fill:url(#linearGradient3689);fill-opacity:1;stroke:none;stroke-width:1.00000024;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;display:inline" /> + <path + id="path3675" + d="M 4.625,30 C 4.9603306,32.150219 5,34.257337 5,37.4375 C 5,37.771087 5.2086862,38.497978 5.5625,39.0625 C 5.9077942,39.613428 6.3538112,39.964089 6.65625,40 L 6.6875,40 L 42.3125,40 L 42.34375,40 C 42.929781,39.95968 43.308738,39.567494 43.59375,39 C 43.883899,38.422277 44.000093,37.694547 44,37.4375 C 44,34.957143 44.019836,32.683092 44.46875,30 L 4.625,30 z " + style="opacity:0.44313725;fill:url(#linearGradient3691);fill-opacity:1;stroke:none;stroke-width:1.00000024;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;display:inline" /> + <path + transform="translate(0,54)" + d="M 2.625,-32.5 C 5.3597846,-25.370787 5.5,-23.037216 5.5,-16.5 C 5.5,-16.335159 5.6904313,-15.649773 6,-15.15625 C 6.3095687,-14.662727 6.6985247,-14.438832 6.71875,-14.4375 L 42.3125,-14.4375 L 42.34375,-14.4375 C 42.706073,-14.463351 42.916739,-14.679637 43.15625,-15.15625 C 43.401014,-15.643317 43.500051,-16.358058 43.5,-16.5 C 43.5,-21.130327 43.573003,-25.119501 46.375,-32.5 C 46.360642,-32.501163 46.358943,-32.5 46.34375,-32.5 L 2.65625,-32.5 C 2.6410568,-32.5 2.6393578,-32.501163 2.625,-32.5 z " + id="path3677" + style="opacity:0.2;fill:none;fill-opacity:1;stroke:url(#linearGradient3693);stroke-width:1.00000036;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;display:inline" + inkscape:original="M 2.65625 -33.5 C 2.021877 -33.5 1.5 -33.140103 1.5 -32.6875 C 4.500001 -25.004483 4.5 -23.258435 4.5 -16.5 C 4.5 -15.5 5.5111718 -13.51291 6.65625 -13.4375 L 42.34375 -13.4375 C 43.989096 -13.51291 44.500268 -15.758435 44.5 -16.5 C 44.5 -21.258435 44.500267 -25.004482 47.5 -32.6875 C 47.5 -33.140104 46.978125 -33.5 46.34375 -33.5 L 2.65625 -33.5 z " + inkscape:radius="-0.99436891" + sodipodi:type="inkscape:offset" /> + <path + sodipodi:nodetypes="cc" + id="path3679" + d="M 2.5,21.5 L 46.5,21.5" + style="opacity:0.4;fill:none;fill-opacity:1;fill-rule:evenodd;stroke:url(#radialGradient3695);stroke-width:1px;stroke-linecap:round;stroke-linejoin:miter;stroke-opacity:1" /> + </g> + <g + id="g6102" + inkscape:label="pixmap" + style="display:inline" + transform="translate(188.7605,-103.2651)" /> + <g + transform="matrix(0.186703,0,0,0.186703,167.6532,-45.64211)" + id="g6106" /> + <g + inkscape:label="pattern" + id="g30621" + inkscape:r_cx="true" + inkscape:r_cy="true" + transform="translate(190.4218,35.092)" /> + <g + inkscape:label="pattern" + id="g35222" + inkscape:r_cx="true" + inkscape:r_cy="true" + transform="translate(190.4218,81.092)" /> + <g + id="g3198" + inkscape:export-filename="/home/lapo/Icone/Crux/folderx.png" + inkscape:export-xdpi="90" + inkscape:export-ydpi="90" + transform="translate(-1,102)"> + <rect + inkscape:export-ydpi="90" + inkscape:export-xdpi="90" + inkscape:export-filename="/home/lapo/Icone/Crux/folderx-alt.png" + ry="1.5" + rx="1.5" + y="-91.5" + x="9.4999981" + height="3" + width="10.000002" + id="rect2118" + style="fill:#eeeeec;fill-opacity:1;stroke:#d3d7cf;stroke-width:0.99999988;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;display:inline" /> + <g + inkscape:export-ydpi="90" + inkscape:export-xdpi="90" + inkscape:export-filename="/home/lapo/Icone/Crux/folderx-alt.png" + transform="translate(66,0)" + id="g3371" + style="display:inline"> + <rect + style="opacity:1;fill:#2e3436;fill-opacity:1;stroke:none;stroke-width:1;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4.4000001;stroke-dasharray:none;stroke-dashoffset:10;stroke-opacity:1" + id="rect3373" + width="1" + height="1" + x="-55" + y="-90" + rx="0.5" + ry="0.5" /> + <rect + style="opacity:1;fill:#2e3436;fill-opacity:1;stroke:none;stroke-width:1;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4.4000001;stroke-dasharray:none;stroke-dashoffset:10;stroke-opacity:1;display:inline" + id="rect3375" + width="1" + height="1" + x="-53" + y="-90" + rx="0.5" + ry="0.5" /> + <rect + style="opacity:1;fill:#2e3436;fill-opacity:1;stroke:none;stroke-width:1;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4.4000001;stroke-dasharray:none;stroke-dashoffset:10;stroke-opacity:1;display:inline" + id="rect3377" + width="1" + height="1" + x="-51" + y="-90" + rx="0.5" + ry="0.5" /> + <rect + style="opacity:1;fill:#2e3436;fill-opacity:1;stroke:none;stroke-width:1;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4.4000001;stroke-dasharray:none;stroke-dashoffset:10;stroke-opacity:1;display:inline" + id="rect3379" + width="1" + height="1" + x="-49" + y="-90" + rx="0.5" + ry="0.5" /> + </g> + </g> + </g> +</svg> 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 <kovid@kovidgoyal.net> 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 <kovid@kovidgoyal.net> 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 @@ <string>Active Jobs</string> </property> <property name="windowIcon"> - <iconset resource="../../../work/calibre/resources/images.qrc"> + <iconset resource="../../../../resources/images.qrc"> <normaloff>:/images/jobs.svg</normaloff>:/images/jobs.svg</iconset> </property> <layout class="QVBoxLayout"> <item> - <widget class="JobsView" name="jobs_view"> + <widget class="QTableView" name="jobs_view"> <property name="contextMenuPolicy"> <enum>Qt::NoContextMenu</enum> </property> @@ -66,15 +66,8 @@ </item> </layout> </widget> - <customwidgets> - <customwidget> - <class>JobsView</class> - <extends>QTableView</extends> - <header>widgets.h</header> - </customwidget> - </customwidgets> <resources> - <include location="../../../work/calibre/resources/images.qrc"/> + <include location="../../../../resources/images.qrc"/> </resources> <connections/> </ui> 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 <kovid@kovidgoyal.net> 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 <kovid at kovidgoyal.net>' -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 <kovid@kovidgoyal.net> 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 <kovid at kovidgoyal.net>' -''' -''' -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 <kovid@kovidgoyal.net>' +__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 = '''\ +<?xml version="1.0" encoding="UTF-8"?> +<cache xmlns="http://www.kinoma.com/FskCache/1"> +</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 <records>)') + 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 <kovid@kovidgoyal.net> 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 <kovid@kovidgoyal.net> 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 <kovid at kovidgoyal.net>' 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 <kovid@kovidgoyal.net> 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 <kovid@kovidgoyal.net> 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 <kovid@kovidgoyal.net>' __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 = '''\ <?xml version="1.0" encoding="UTF-8"?> @@ -23,12 +24,43 @@ EMPTY_CARD_CACHE = '''\ </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 <records>)') + raise DeviceError('The SONY XML database is corrupted (no' + ' <records>). 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 <text> 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 <john@nachtimwald.com>' __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 <kovid@kovidgoyal.net> 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 <kovid@kovidgoyal.net> 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 <kovid@kovidgoyal.net> 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("<?xml version='1.0' encoding='UTF-8'?>", + '<?xml version="1.0" encoding="UTF-8"?>') 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 <kovid@kovidgoyal.net> 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 = '''\ <?xml version="1.0" encoding="UTF-8"?> <cache xmlns="http://www.kinoma.com/FskCache/1"> @@ -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 <kovid@kovidgoyal.net> 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 <kovid@kovidgoyal.net> 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 <kovid@kovidgoyal.net> 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 <kovid@kovidgoyal.net> 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 <kovid@kovidgoyal.net> 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 <kovid@kovidgoyal.net> 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 <kovid@kovidgoyal.net> 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 <kovid@kovidgoyal.net> 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 <kovid@kovidgoyal.net> 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('''\ <book xmlns:py="http://genshi.edgewall.org/" - id="${r[0]}" - title="${r[1]}" - sort="${r[11]}" - author_sort="${r[12]}" + id="${r[FM['id']]}" + title="${r[FM['title']]}" + sort="${r[FM['sort']]}" + author_sort="${r[FM['author_sort']]}" authors="${authors}" - rating="${r[4]}" + rating="${r[FM['rating']]}" timestamp="${timestamp}" pubdate="${pubdate}" - size="${r[6]}" - isbn="${r[14] if r[14] else ''}" - formats="${r[13] if r[13] else ''}" - series = "${r[9] if r[9] else ''}" - series_index="${r[10]}" - tags="${r[7] if r[7] else ''}" - publisher="${r[3] if r[3] else ''}">${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 ''} </book> ''') @@ -86,13 +86,13 @@ class LibraryServer(object): MOBILE_BOOK = textwrap.dedent('''\ <tr xmlns:py="http://genshi.edgewall.org/"> <td class="thumbnail"> - <img type="image/jpeg" src="/get/thumb/${r[0]}" border="0"/> + <img type="image/jpeg" src="/get/thumb/${r[FM['id']]}" border="0"/> </td> <td> - <py:for each="format in r[13].split(',')"> - <span class="button"><a href="/get/${format}/${authors}-${r[1]}_${r[0]}.${format}">${format.lower()}</a></span>  + <py:for each="format in r[FM['formats']].split(',')"> + <span class="button"><a href="/get/${format}/${authors}-${r[FM['title']]}_${r[FM['id']]}.${format}">${format.lower()}</a></span>  </py:for> - ${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 ''} </td> </tr> ''') @@ -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<br />'%rating) - tags = record[self.db.FIELD_MAP['tags']] + tags = record[FM['tags']] if tags: extra.append('TAGS: %s<br />'%\ 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]<br />'%\ (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 <kovid@kovidgoyal.net> 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 <kovid at kovidgoyal.net> ' \ - '2009, John Schember <john at nachtimwald.com>' +__copyright__ = '2008, Kovid Goyal <kovid at kovidgoyal.net>' __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 <kovid@kovidgoyal.net> 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 <kovid@kovidgoyal.net> 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 <kovid@kovidgoyal.net> 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 <kovid@kovidgoyal.net>' __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 <kovid@kovidgoyal.net> 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 <kovid@kovidgoyal.net> 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 <kovid@kovidgoyal.net> 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 <b>edit</b> me<br><br>")) 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 <kovid@kovidgoyal.net> 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 <kovid@kovidgoyal.net> 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 <font face="serif">%s</font> 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 <kovid@kovidgoyal.net> 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 <kovid@kovidgoyal.net> 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 <kovid@kovidgoyal.net>" 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 <kovid@kovidgoyal.net> 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 <kovid@kovidgoyal.net>" 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 <kovid@kovidgoyal.net> 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 <kovid@kovidgoyal.net>' +__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 <kovid@kovidgoyal.net> 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 <kovid@kovidgoyal.net> 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 <kovid@kovidgoyal.net> 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 <kovid@kovidgoyal.net>' __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 <kovid@kovidgoyal.net>' +__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 <griker@hotmail.com> 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 <kovid at kovidgoyal.net>' \ 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 <griker@hotmail.com> 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 <kovid@kovidgoyal.net> 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 <kovid@kovidgoyal.net> 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 <kovid@kovidgoyal.net>" import re From 0c254d8d64f9de6e9de28279e0f33f69323a7141 Mon Sep 17 00:00:00 2001 From: Kovid Goyal <kovid@kovidgoyal.net> 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 <kovid@kovidgoyal.net> 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 <kovid@kovidgoyal.net> 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 <kovid at kovidgoyal.net>' ''' 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('''\ - <book xmlns:py="http://genshi.edgewall.org/" - id="${r[FM['id']]}" - title="${r[FM['title']]}" - sort="${r[FM['sort']]}" - author_sort="${r[FM['author_sort']]}" - authors="${authors}" - rating="${r[FM['rating']]}" - timestamp="${timestamp}" - pubdate="${pubdate}" - 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 ''} - </book> - ''') - - MOBILE_UA = re.compile('(?i)(?:iPhone|Opera Mini|NetFront|webOS|Mobile|Android|imode|DoCoMo|Minimo|Blackberry|MIDP|Symbian|HD2)') - - MOBILE_BOOK = textwrap.dedent('''\ - <tr xmlns:py="http://genshi.edgewall.org/"> - <td class="thumbnail"> - <img type="image/jpeg" src="/get/thumb/${r[FM['id']]}" border="0"/> - </td> - <td> - <py:for each="format in r[FM['formats']].split(',')"> - <span class="button"><a href="/get/${format}/${authors}-${r[FM['title']]}_${r[FM['id']]}.${format}">${format.lower()}</a></span>  - </py:for> - ${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 ''} - </td> - </tr> - ''') - - MOBILE = MarkupTemplate(textwrap.dedent('''\ - <html xmlns:py="http://genshi.edgewall.org/"> - <head> - <style> - .navigation table.buttons { - width: 100%; - } - .navigation .button { - width: 50%; - } - .button a, .button:visited a { - padding: 0.5em; - font-size: 1.25em; - border: 1px solid black; - text-color: black; - background-color: #ddd; - border-top: 1px solid ThreeDLightShadow; - border-right: 1px solid ButtonShadow; - border-bottom: 1px solid ButtonShadow; - border-left: 1 px solid ThreeDLightShadow; - -moz-border-radius: 0.25em; - -webkit-border-radius: 0.25em; - } - - .button:hover a { - border-top: 1px solid #666; - border-right: 1px solid #CCC; - border-bottom: 1 px solid #CCC; - border-left: 1 px solid #666; - - - } - div.navigation { - padding-bottom: 1em; - clear: both; - } - - #search_box { - border: 1px solid #393; - -moz-border-radius: 0.5em; - -webkit-border-radius: 0.5em; - padding: 1em; - margin-bottom: 0.5em; - float: right; - } - - #listing { - width: 100%; - border-collapse: collapse; - } - #listing td { - padding: 0.25em; - } - - #listing td.thumbnail { - height: 60px; - width: 60px; - } - - #listing tr:nth-child(even) { - - background: #eee; - } - - #listing .button a{ - display: inline-block; - width: 2.5em; - padding-left: 0em; - padding-right: 0em; - overflow: hidden; - text-align: center; - } - - #logo { - float: left; - } - #spacer { - clear: both; - } - - </style> - <link rel="icon" href="http://calibre-ebook.com/favicon.ico" type="image/x-icon" /> - </head> - <body> - <div id="logo"> - <img src="/static/calibre.png" alt="Calibre" /> - </div> - <div id="search_box"> - <form method="get" action="/mobile"> - Show <select name="num"> - <py:for each="option in [5,10,25,100]"> - <option py:if="option == num" value="${option}" SELECTED="SELECTED">${option}</option> - <option py:if="option != num" value="${option}">${option}</option> - </py:for> - </select> - books matching <input name="search" id="s" value="${search}" /> sorted by - - <select name="sort"> - <py:for each="option in ['date','author','title','rating','size','tags','series']"> - <option py:if="option == sort" value="${option}" SELECTED="SELECTED">${option}</option> - <option py:if="option != sort" value="${option}">${option}</option> - </py:for> - </select> - <select name="order"> - <py:for each="option in ['ascending','descending']"> - <option py:if="option == order" value="${option}" SELECTED="SELECTED">${option}</option> - <option py:if="option != order" value="${option}">${option}</option> - </py:for> - </select> - <input id="go" type="submit" value="Search"/> - </form> - </div> - <div class="navigation"> - <span style="display: block; text-align: center;">Books ${start} to ${ min((start+num-1) , total) } of ${total}</span> - <table class="buttons"> - <tr> - <td class="button" style="text-align:left;"> - <a py:if="start > 1" href="${url_base};start=1">First</a> - <a py:if="start > 1" href="${url_base};start=${max(start-(num+1),1)}">Previous</a> - </td> - <td class="button" style="text-align: right;"> - <a py:if=" total > (start + num) " href="${url_base};start=${start+num}">Next</a> - <a py:if=" total > (start + num) " href="${url_base};start=${total-num+1}">Last</a> - </td> - </tr> - </table> - </div> - <hr class="spacer" /> - <table id="listing"> - <py:for each="book in books"> - ${Markup(book)} - </py:for> - </table> - </body> - </html> - ''')) - - LIBRARY = MarkupTemplate(textwrap.dedent('''\ - <?xml version="1.0" encoding="utf-8"?> - <library xmlns:py="http://genshi.edgewall.org/" start="$start" num="${len(books)}" total="$total" updated="${updated.strftime('%Y-%m-%dT%H:%M:%S+00:00')}"> - <py:for each="book in books"> - ${Markup(book)} - </py:for> - </library> - ''')) - - STANZA_ENTRY=MarkupTemplate(textwrap.dedent('''\ - <entry xmlns:py="http://genshi.edgewall.org/"> - <title>${record[FM['title']]} - urn:calibre:${urn} - ${authors} - ${timestamp} - - - - -

${Markup(extra)}${record[FM['comments']]}
- - - ''')) - - STANZA_SUBCATALOG_ENTRY=MarkupTemplate(textwrap.dedent('''\ - - ${title} - urn:calibre:${id} - ${updated.strftime('%Y-%m-%dT%H:%M:%S+00:00')} - - ${count} books - - ''')) - - STANZA = MarkupTemplate(textwrap.dedent('''\ - - - 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(textwrap.dedent('''\ - - - 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 - - - ''')) - - - def __init__(self, db, opts, embedded=False, show_tracebacks=True): - self.db = db - for item in self.db: - item - break - self.opts = opts - self.embedded = embedded - self.max_cover_width, self.max_cover_height = \ - map(int, self.opts.max_cover.split('x')) - self.max_stanza_items = opts.max_opds_items - path = P('content_server') - self.build_time = fromtimestamp(os.stat(path).st_mtime) - self.default_cover = open(P('content_server/default_cover.jpg'), 'rb').read() - cherrypy.config.update({ - 'log.screen' : opts.develop, - 'engine.autoreload_on' : opts.develop, - 'tools.log_headers.on' : opts.develop, - 'checker.on' : opts.develop, - 'request.show_tracebacks': show_tracebacks, - 'server.socket_host' : listen_on, - 'server.socket_port' : opts.port, - 'server.socket_timeout' : opts.timeout, #seconds - 'server.thread_pool' : opts.thread_pool, # number of threads - }) - if embedded: - cherrypy.config.update({'engine.SIGHUP' : None, - 'engine.SIGTERM' : None,}) - self.config = {'global': { - 'tools.gzip.on' : True, - 'tools.gzip.mime_types': ['text/html', 'text/plain', 'text/xml', 'text/javascript', 'text/css'], - }} - if opts.password: - self.config['/'] = { - 'tools.digest_auth.on' : True, - 'tools.digest_auth.realm' : (_('Password to access your calibre library. Username is ') + opts.username.strip()).encode('ascii', 'replace'), - 'tools.digest_auth.users' : {opts.username.strip():opts.password.strip()}, - } - - self.is_running = False - self.exception = None - - def setup_loggers(self): - access_file = log_access_file - error_file = log_error_file - log = cherrypy.log - - maxBytes = getattr(log, "rot_maxBytes", 10000000) - backupCount = getattr(log, "rot_backupCount", 1000) - - # Make a new RotatingFileHandler for the error log. - h = RotatingFileHandler(error_file, 'a', maxBytes, backupCount) - h.setLevel(logging.DEBUG) - h.setFormatter(cherrypy._cplogging.logfmt) - log.error_log.addHandler(h) - - # Make a new RotatingFileHandler for the access log. - h = RotatingFileHandler(access_file, 'a', maxBytes, backupCount) - h.setLevel(logging.DEBUG) - h.setFormatter(cherrypy._cplogging.logfmt) - log.access_log.addHandler(h) - - def start(self): - self.is_running = False - self.setup_loggers() - cherrypy.tree.mount(self, '', config=self.config) - try: - try: - cherrypy.engine.start() - except: - ip = get_external_ip() - if not ip or ip == '127.0.0.1': - raise - cherrypy.log('Trying to bind to single interface: '+ip) - cherrypy.config.update({'server.socket_host' : ip}) - cherrypy.engine.start() - - self.is_running = True - try: - publish_zeroconf('Books in calibre', '_stanza._tcp', - self.opts.port, {'path':'/stanza'}) - except: - import traceback - cherrypy.log.error('Failed to start BonJour:') - cherrypy.log.error(traceback.format_exc()) - cherrypy.engine.block() - except Exception, e: - self.exception = e - finally: - self.is_running = False - try: - stop_zeroconf() - except: - import traceback - cherrypy.log.error('Failed to stop BonJour:') - cherrypy.log.error(traceback.format_exc()) - - def exit(self): - try: - cherrypy.engine.exit() - finally: - cherrypy.server.httpserver = None - - def get_cover(self, id, thumbnail=False): - cover = self.db.cover(id, index_is_id=True, as_file=False) - if cover is None: - cover = self.default_cover - cherrypy.response.headers['Content-Type'] = 'image/jpeg' - cherrypy.response.timeout = 3600 - path = getattr(cover, 'name', False) - updated = fromtimestamp(os.stat(path).st_mtime) if path and \ - os.access(path, os.R_OK) else self.build_time - cherrypy.response.headers['Last-Modified'] = self.last_modified(updated) - try: - f = cStringIO.StringIO(cover) - try: - im = PILImage.open(f) - except IOError: - raise cherrypy.HTTPError(404, 'No valid cover found') - width, height = im.size - scaled, width, height = fit_image(width, height, - 60 if thumbnail else self.max_cover_width, - 80 if thumbnail else self.max_cover_height) - if not scaled: - return cover - im = im.resize((int(width), int(height)), PILImage.ANTIALIAS) - of = cStringIO.StringIO() - im.convert('RGB').save(of, 'JPEG') - return of.getvalue() - except Exception, err: - import traceback - cherrypy.log.error('Failed to generate cover:') - cherrypy.log.error(traceback.print_exc()) - raise cherrypy.HTTPError(404, 'Failed to generate cover: %s'%err) - - def get_format(self, id, format): - format = format.upper() - fmt = self.db.format(id, format, index_is_id=True, as_file=True, - mode='rb') - if fmt is None: - raise cherrypy.HTTPError(404, 'book: %d does not have format: %s'%(id, format)) - if format == 'EPUB': - from tempfile import TemporaryFile - from calibre.ebooks.metadata.meta import set_metadata - raw = fmt.read() - fmt = TemporaryFile() - fmt.write(raw) - fmt.seek(0) - set_metadata(fmt, self.db.get_metadata(id, index_is_id=True), - 'epub') - fmt.seek(0) - mt = guess_type('dummy.'+format.lower())[0] - if mt is None: - mt = 'application/octet-stream' - cherrypy.response.headers['Content-Type'] = mt - cherrypy.response.timeout = 3600 - path = getattr(fmt, 'name', None) - if path and os.path.exists(path): - updated = fromtimestamp(os.stat(path).st_mtime) - cherrypy.response.headers['Last-Modified'] = self.last_modified(updated) - return fmt.read() - - def sort(self, items, field, order): - field = field.lower().strip() - if field == 'author': - field = 'authors' - if field == 'date': - field = 'timestamp' - if field not in ('title', 'authors', 'rating', 'timestamp', 'tags', 'size', 'series'): - raise cherrypy.HTTPError(400, '%s is not a valid sort field'%field) - cmpf = cmp if field in ('rating', 'size', 'timestamp') else \ - lambda x, y: cmp(x.lower() if x else '', y.lower() if y else '') - if field == 'series': - items.sort(cmp=self.seriescmp, reverse=not order) - else: - field = self.db.FIELD_MAP[field] - getter = operator.itemgetter(field) - items.sort(cmp=lambda x, y: cmpf(getter(x), getter(y)), reverse=not order) - - def seriescmp(self, x, y): - si = self.db.FIELD_MAP['series'] - try: - ans = cmp(x[si].lower(), y[si].lower()) - except AttributeError: # Some entries may be None - ans = cmp(x[si], y[si]) - if ans != 0: return ans - return cmp(x[self.db.FIELD_MAP['series_index']], y[self.db.FIELD_MAP['series_index']]) - - - def last_modified(self, updated): - lm = updated.strftime('day, %d month %Y %H:%M:%S GMT') - day ={0:'Sun', 1:'Mon', 2:'Tue', 3:'Wed', 4:'Thu', 5:'Fri', 6:'Sat'} - lm = lm.replace('day', day[int(updated.strftime('%w'))]) - month = {1:'Jan', 2:'Feb', 3:'Mar', 4:'Apr', 5:'May', 6:'Jun', 7:'Jul', - 8:'Aug', 9:'Sep', 10:'Oct', 11:'Nov', 12:'Dec'} - return lm.replace('month', month[updated.month]) - - 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 = [self.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 self.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 self.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(self.STANZA_ENTRY.generate(**data)\ - .render('xml').decode('utf8')) - - return self.STANZA.generate(subtitle='', data=books, FM=FM, - next_link=next_link, updated=updated, id='urn:calibre:main').render('xml') - - - @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(self.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 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 - 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(self.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 self.LIBRARY.generate(books=books, start=start, updated=updated, - total=len(ids), FM=FM).render('xml') - - @expose - def index(self, **kwargs): - 'The / URL' - ua = cherrypy.request.headers.get('User-Agent', '').strip() - want_opds = \ - cherrypy.request.headers.get('Stanza-Device-Name', 919) != 919 or \ - cherrypy.request.headers.get('Want-OPDS-Catalog', 919) != 919 or \ - ua.startswith('Stanza') - - # A better search would be great - want_mobile = self.MOBILE_UA.search(ua) is not None - if self.opts.develop and not want_mobile: - cherrypy.log('User agent: '+ua) - - if want_opds: - return self.stanza(search=kwargs.get('search', None), sortby=kwargs.get('sortby',None), authorid=kwargs.get('authorid',None), - tagid=kwargs.get('tagid',None), - seriesid=kwargs.get('seriesid',None), - offset=kwargs.get('offset', 0)) - - if want_mobile: - return self.mobile() - - return self.static('index.html') - - - @expose - def get(self, what, id, *args, **kwargs): - 'Serves files, covers, thumbnails from the calibre database' - try: - id = int(id) - except ValueError: - id = id.rpartition('_')[-1].partition('.')[0] - match = re.search(r'\d+', id) - if not match: - raise cherrypy.HTTPError(400, 'id:%s not an integer'%id) - id = int(match.group()) - if not self.db.has_id(id): - raise cherrypy.HTTPError(400, 'id:%d does not exist in database'%id) - if what == 'thumb': - return self.get_cover(id, thumbnail=True) - if what == 'cover': - return self.get_cover(id) - return self.get_format(id, what) - - @expose - def static(self, name): - 'Serves static content' - name = name.lower() - cherrypy.response.headers['Content-Type'] = { - 'js' : 'text/javascript', - 'css' : 'text/css', - 'png' : 'image/png', - 'gif' : 'image/gif', - 'html' : 'text/html', - '' : 'application/octet-stream', - }[name.rpartition('.')[-1].lower()] - cherrypy.response.headers['Last-Modified'] = self.last_modified(self.build_time) - path = P('content_server/'+name) - if not os.path.exists(path): - raise cherrypy.HTTPError(404, '%s not found'%name) - if self.opts.develop: - lm = fromtimestamp(os.stat(path).st_mtime) - cherrypy.response.headers['Last-Modified'] = self.last_modified(lm) - return open(path, 'rb').read() - -def start_threaded_server(db, opts): - server = LibraryServer(db, opts, embedded=True) - server.thread = Thread(target=server.start) - server.thread.setDaemon(True) - server.thread.start() - return server - -def stop_threaded_server(server): - server.exit() - server.thread = None - -def option_parser(): - parser = config().option_parser('%prog '+ _('[options]\n\nStart the calibre content server.')) - parser.add_option('--with-library', default=None, - help=_('Path to the library folder to serve with the content server')) - parser.add_option('--pidfile', default=None, - help=_('Write process PID to the specified file')) - parser.add_option('--daemonize', default=False, action='store_true', - help='Run process in background as a daemon. No effect on windows.') - return parser - -def daemonize(stdin='/dev/null', stdout='/dev/null', stderr='/dev/null'): - try: - pid = os.fork() - if pid > 0: - # exit first parent - sys.exit(0) - except OSError, e: - print >>sys.stderr, "fork #1 failed: %d (%s)" % (e.errno, e.strerror) - sys.exit(1) - - # decouple from parent environment - os.chdir("/") - os.setsid() - os.umask(0) - - # do second fork - try: - pid = os.fork() - if pid > 0: - # exit from second parent - sys.exit(0) - except OSError, e: - print >>sys.stderr, "fork #2 failed: %d (%s)" % (e.errno, e.strerror) - sys.exit(1) - - # Redirect standard file descriptors. - si = file(stdin, 'r') - so = file(stdout, 'a+') - se = file(stderr, 'a+', 0) - os.dup2(si.fileno(), sys.stdin.fileno()) - os.dup2(so.fileno(), sys.stdout.fileno()) - os.dup2(se.fileno(), sys.stderr.fileno()) - - - -def main(args=sys.argv): - parser = option_parser() - opts, args = parser.parse_args(args) - if opts.daemonize and not iswindows: - daemonize() - if opts.pidfile is not None: - with open(opts.pidfile, 'wb') as f: - f.write(str(os.getpid())) - cherrypy.log.screen = True - from calibre.utils.config import prefs - if opts.with_library is None: - opts.with_library = prefs['library_path'] - db = LibraryDatabase2(opts.with_library) - server = LibraryServer(db, opts) - server.start() - return 0 - -if __name__ == '__main__': - sys.exit(main()) diff --git a/src/calibre/library/server/__init__.py b/src/calibre/library/server/__init__.py new file mode 100644 index 0000000000..9c092f6c2f --- /dev/null +++ b/src/calibre/library/server/__init__.py @@ -0,0 +1,42 @@ +#!/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 calibre.utils.config import Config, StringConfig, config_dir + + +listen_on = '0.0.0.0' + + +log_access_file = os.path.join(config_dir, 'server_access_log.txt') +log_error_file = os.path.join(config_dir, 'server_error_log.txt') + + +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 + diff --git a/src/calibre/library/server/base.py b/src/calibre/library/server/base.py new file mode 100644 index 0000000000..666ce52ffc --- /dev/null +++ b/src/calibre/library/server/base.py @@ -0,0 +1,130 @@ +#!/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 +import logging +from logging.handlers import RotatingFileHandler + +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.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 + +class LibraryServer(ContentServer, MobileServer, XMLServer, OPDSServer): + + server_name = __appname__ + '/' + __version__ + + def __init__(self, db, opts, embedded=False, show_tracebacks=True): + self.db = db + for item in self.db: + item + break + self.opts = opts + self.embedded = embedded + self.max_cover_width, self.max_cover_height = \ + map(int, self.opts.max_cover.split('x')) + self.max_stanza_items = opts.max_opds_items + path = P('content_server') + self.build_time = fromtimestamp(os.stat(path).st_mtime) + self.default_cover = open(P('content_server/default_cover.jpg'), 'rb').read() + cherrypy.config.update({ + 'log.screen' : opts.develop, + 'engine.autoreload_on' : opts.develop, + 'tools.log_headers.on' : opts.develop, + 'checker.on' : opts.develop, + 'request.show_tracebacks': show_tracebacks, + 'server.socket_host' : listen_on, + 'server.socket_port' : opts.port, + 'server.socket_timeout' : opts.timeout, #seconds + 'server.thread_pool' : opts.thread_pool, # number of threads + }) + if embedded: + cherrypy.config.update({'engine.SIGHUP' : None, + 'engine.SIGTERM' : None,}) + self.config = {'global': { + 'tools.gzip.on' : True, + 'tools.gzip.mime_types': ['text/html', 'text/plain', 'text/xml', 'text/javascript', 'text/css'], + }} + if opts.password: + self.config['/'] = { + 'tools.digest_auth.on' : True, + 'tools.digest_auth.realm' : (_('Password to access your calibre library. Username is ') + opts.username.strip()).encode('ascii', 'replace'), + 'tools.digest_auth.users' : {opts.username.strip():opts.password.strip()}, + } + + self.is_running = False + self.exception = None + + def setup_loggers(self): + access_file = log_access_file + error_file = log_error_file + log = cherrypy.log + + maxBytes = getattr(log, "rot_maxBytes", 10000000) + backupCount = getattr(log, "rot_backupCount", 1000) + + # Make a new RotatingFileHandler for the error log. + h = RotatingFileHandler(error_file, 'a', maxBytes, backupCount) + h.setLevel(logging.DEBUG) + h.setFormatter(cherrypy._cplogging.logfmt) + log.error_log.addHandler(h) + + # Make a new RotatingFileHandler for the access log. + h = RotatingFileHandler(access_file, 'a', maxBytes, backupCount) + h.setLevel(logging.DEBUG) + h.setFormatter(cherrypy._cplogging.logfmt) + log.access_log.addHandler(h) + + def start(self): + self.is_running = False + self.setup_loggers() + cherrypy.tree.mount(self, '', config=self.config) + try: + try: + cherrypy.engine.start() + except: + ip = get_external_ip() + if not ip or ip == '127.0.0.1': + raise + cherrypy.log('Trying to bind to single interface: '+ip) + cherrypy.config.update({'server.socket_host' : ip}) + cherrypy.engine.start() + + self.is_running = True + try: + publish_zeroconf('Books in calibre', '_stanza._tcp', + self.opts.port, {'path':'/stanza'}) + except: + import traceback + cherrypy.log.error('Failed to start BonJour:') + cherrypy.log.error(traceback.format_exc()) + cherrypy.engine.block() + except Exception, e: + self.exception = e + finally: + self.is_running = False + try: + stop_zeroconf() + except: + import traceback + cherrypy.log.error('Failed to stop BonJour:') + cherrypy.log.error(traceback.format_exc()) + + def exit(self): + try: + cherrypy.engine.exit() + finally: + cherrypy.server.httpserver = None + + diff --git a/src/calibre/library/server/content.py b/src/calibre/library/server/content.py new file mode 100644 index 0000000000..d1a695cee1 --- /dev/null +++ b/src/calibre/library/server/content.py @@ -0,0 +1,199 @@ +#!/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, os, cStringIO, operator + +import cherrypy +try: + from PIL import Image as PILImage + PILImage +except ImportError: + import Image as PILImage + +from calibre import fit_image, guess_type +from calibre.utils.date import fromtimestamp +from calibre.library.server.utils import expose + +class ContentServer(object): + + ''' + Handles actually serving content files/covers. Also has + a few utility methods. + ''' + + # Utility methods {{{ + def last_modified(self, updated): + ''' + Generates a local independent, english timestamp from a datetime + object + ''' + lm = updated.strftime('day, %d month %Y %H:%M:%S GMT') + day ={0:'Sun', 1:'Mon', 2:'Tue', 3:'Wed', 4:'Thu', 5:'Fri', 6:'Sat'} + lm = lm.replace('day', day[int(updated.strftime('%w'))]) + month = {1:'Jan', 2:'Feb', 3:'Mar', 4:'Apr', 5:'May', 6:'Jun', 7:'Jul', + 8:'Aug', 9:'Sep', 10:'Oct', 11:'Nov', 12:'Dec'} + return lm.replace('month', month[updated.month]) + + + def sort(self, items, field, order): + field = field.lower().strip() + if field == 'author': + field = 'authors' + if field == 'date': + field = 'timestamp' + if field not in ('title', 'authors', 'rating', 'timestamp', 'tags', 'size', 'series'): + raise cherrypy.HTTPError(400, '%s is not a valid sort field'%field) + cmpf = cmp if field in ('rating', 'size', 'timestamp') else \ + lambda x, y: cmp(x.lower() if x else '', y.lower() if y else '') + if field == 'series': + items.sort(cmp=self.seriescmp, reverse=not order) + else: + lookup = 'sort' if field == 'title' else field + field = self.db.FIELD_MAP[lookup] + getter = operator.itemgetter(field) + items.sort(cmp=lambda x, y: cmpf(getter(x), getter(y)), reverse=not order) + + def seriescmp(self, x, y): + si = self.db.FIELD_MAP['series'] + try: + ans = cmp(x[si].lower(), y[si].lower()) + except AttributeError: # Some entries may be None + ans = cmp(x[si], y[si]) + if ans != 0: return ans + return cmp(x[self.db.FIELD_MAP['series_index']], y[self.db.FIELD_MAP['series_index']]) + # }}} + + + @expose + def get(self, what, id, *args, **kwargs): + 'Serves files, covers, thumbnails from the calibre database' + try: + id = int(id) + except ValueError: + id = id.rpartition('_')[-1].partition('.')[0] + match = re.search(r'\d+', id) + if not match: + raise cherrypy.HTTPError(400, 'id:%s not an integer'%id) + id = int(match.group()) + if not self.db.has_id(id): + raise cherrypy.HTTPError(400, 'id:%d does not exist in database'%id) + if what == 'thumb': + return self.get_cover(id, thumbnail=True) + if what == 'cover': + return self.get_cover(id) + return self.get_format(id, what) + + @expose + def static(self, name): + 'Serves static content' + name = name.lower() + cherrypy.response.headers['Content-Type'] = { + 'js' : 'text/javascript', + 'css' : 'text/css', + 'png' : 'image/png', + 'gif' : 'image/gif', + 'html' : 'text/html', + '' : 'application/octet-stream', + }[name.rpartition('.')[-1].lower()] + cherrypy.response.headers['Last-Modified'] = self.last_modified(self.build_time) + path = P('content_server/'+name) + if not os.path.exists(path): + raise cherrypy.HTTPError(404, '%s not found'%name) + if self.opts.develop: + lm = fromtimestamp(os.stat(path).st_mtime) + 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() + want_opds = \ + cherrypy.request.headers.get('Stanza-Device-Name', 919) != 919 or \ + cherrypy.request.headers.get('Want-OPDS-Catalog', 919) != 919 or \ + ua.startswith('Stanza') + + # A better search would be great + want_mobile = self.MOBILE_UA.search(ua) is not None + if self.opts.develop and not want_mobile: + cherrypy.log('User agent: '+ua) + + if want_opds: + return self.stanza(search=kwargs.get('search', None), sortby=kwargs.get('sortby',None), authorid=kwargs.get('authorid',None), + tagid=kwargs.get('tagid',None), + seriesid=kwargs.get('seriesid',None), + offset=kwargs.get('offset', 0)) + + if want_mobile: + return self.mobile() + + return self.static('index.html') + + + + # Actually get content from the database {{{ + def get_cover(self, id, thumbnail=False): + cover = self.db.cover(id, index_is_id=True, as_file=False) + if cover is None: + cover = self.default_cover + cherrypy.response.headers['Content-Type'] = 'image/jpeg' + cherrypy.response.timeout = 3600 + path = getattr(cover, 'name', False) + updated = fromtimestamp(os.stat(path).st_mtime) if path and \ + os.access(path, os.R_OK) else self.build_time + cherrypy.response.headers['Last-Modified'] = self.last_modified(updated) + try: + f = cStringIO.StringIO(cover) + try: + im = PILImage.open(f) + except IOError: + raise cherrypy.HTTPError(404, 'No valid cover found') + width, height = im.size + scaled, width, height = fit_image(width, height, + 60 if thumbnail else self.max_cover_width, + 80 if thumbnail else self.max_cover_height) + if not scaled: + return cover + im = im.resize((int(width), int(height)), PILImage.ANTIALIAS) + of = cStringIO.StringIO() + im.convert('RGB').save(of, 'JPEG') + return of.getvalue() + except Exception, err: + import traceback + cherrypy.log.error('Failed to generate cover:') + cherrypy.log.error(traceback.print_exc()) + raise cherrypy.HTTPError(404, 'Failed to generate cover: %s'%err) + + def get_format(self, id, format): + format = format.upper() + fmt = self.db.format(id, format, index_is_id=True, as_file=True, + mode='rb') + if fmt is None: + raise cherrypy.HTTPError(404, 'book: %d does not have format: %s'%(id, format)) + if format == 'EPUB': + from tempfile import TemporaryFile + from calibre.ebooks.metadata.meta import set_metadata + raw = fmt.read() + fmt = TemporaryFile() + fmt.write(raw) + fmt.seek(0) + set_metadata(fmt, self.db.get_metadata(id, index_is_id=True), + 'epub') + fmt.seek(0) + mt = guess_type('dummy.'+format.lower())[0] + if mt is None: + mt = 'application/octet-stream' + cherrypy.response.headers['Content-Type'] = mt + cherrypy.response.timeout = 3600 + path = getattr(fmt, 'name', None) + if path and os.path.exists(path): + updated = fromtimestamp(os.stat(path).st_mtime) + cherrypy.response.headers['Last-Modified'] = self.last_modified(updated) + return fmt.read() + # }}} + + diff --git a/src/calibre/library/server/main.py b/src/calibre/library/server/main.py new file mode 100644 index 0000000000..5ca82c6b98 --- /dev/null +++ b/src/calibre/library/server/main.py @@ -0,0 +1,91 @@ +#!/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, sys +from threading import Thread + +from calibre.library.server import server_config as config +from calibre.library.server.base import LibraryServer +from calibre.constants import iswindows +import cherrypy + +def start_threaded_server(db, opts): + server = LibraryServer(db, opts, embedded=True) + server.thread = Thread(target=server.start) + server.thread.setDaemon(True) + server.thread.start() + return server + +def stop_threaded_server(server): + server.exit() + server.thread = None + +def option_parser(): + parser = config().option_parser('%prog '+ _('[options]\n\nStart the calibre content server.')) + parser.add_option('--with-library', default=None, + help=_('Path to the library folder to serve with the content server')) + parser.add_option('--pidfile', default=None, + help=_('Write process PID to the specified file')) + parser.add_option('--daemonize', default=False, action='store_true', + help='Run process in background as a daemon. No effect on windows.') + return parser + +def daemonize(stdin='/dev/null', stdout='/dev/null', stderr='/dev/null'): + try: + pid = os.fork() + if pid > 0: + # exit first parent + sys.exit(0) + except OSError, e: + print >>sys.stderr, "fork #1 failed: %d (%s)" % (e.errno, e.strerror) + sys.exit(1) + + # decouple from parent environment + os.chdir("/") + os.setsid() + os.umask(0) + + # do second fork + try: + pid = os.fork() + if pid > 0: + # exit from second parent + sys.exit(0) + except OSError, e: + print >>sys.stderr, "fork #2 failed: %d (%s)" % (e.errno, e.strerror) + sys.exit(1) + + # Redirect standard file descriptors. + si = file(stdin, 'r') + so = file(stdout, 'a+') + se = file(stderr, 'a+', 0) + os.dup2(si.fileno(), sys.stdin.fileno()) + os.dup2(so.fileno(), sys.stdout.fileno()) + os.dup2(se.fileno(), sys.stderr.fileno()) + + + +def main(args=sys.argv): + from calibre.library.database2 import LibraryDatabase2 + parser = option_parser() + opts, args = parser.parse_args(args) + if opts.daemonize and not iswindows: + daemonize() + if opts.pidfile is not None: + with open(opts.pidfile, 'wb') as f: + f.write(str(os.getpid())) + cherrypy.log.screen = True + from calibre.utils.config import prefs + if opts.with_library is None: + opts.with_library = prefs['library_path'] + db = LibraryDatabase2(opts.with_library) + server = LibraryServer(db, opts) + server.start() + return 0 + +if __name__ == '__main__': + sys.exit(main()) diff --git a/src/calibre/library/server/mobile.py b/src/calibre/library/server/mobile.py new file mode 100644 index 0000000000..9bec6cce35 --- /dev/null +++ b/src/calibre/library/server/mobile.py @@ -0,0 +1,228 @@ +#!/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, copy +import __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 {{{ +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 ''} + + +''' + +MOBILE = MarkupTemplate('''\ + + + + + + + + + +
+ + + ${Markup(book)} + +
+ + +''') + +# }}} + +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('''\ - - - - - - - - - -
- - - ${Markup(book)} - -
- - -''') - -# }}} class MobileServer(object): 'A view optimized for browsers in mobile devices' @@ -195,26 +251,31 @@ class MobileServer(object): 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]) + items = [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), [] + books = [] 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 + book = {'formats':record[FM['formats']], 'size':record[FM['size']]} + if not book['formats']: + book['formats'] = '' + if not book['size']: + book['size'] = 0 + book['size'] = human_readable(book['size']) + 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')) + book['authors'] = authors + book['series_index'] = fmt_sidx(float(record[FM['series_index']])) + book['series'] = record[FM['series']] + book['tags'] = record[FM['tags']] + book['title'] = record[FM['title']] + for x in ('timestamp', 'pubdate'): + book[x] = strftime('%Y/%m/%d %H:%M:%S', record[FM[x]]) + book['id'] = record[FM['id']] + books.append(book) updated = self.db.last_modified() cherrypy.response.headers['Content-Type'] = 'text/html; charset=utf-8' @@ -223,8 +284,8 @@ class MobileServer(object): 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') - + return html.tostring(build_index(books, num, search, sort, order, + start, len(ids), url_base), + encoding='utf-8', include_meta_content_type=True, + pretty_print=True) From c96e77fe4c76cca3d7643706fcf923863bf811ec Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Sun, 23 May 2010 21:37:36 -0600 Subject: [PATCH 201/324] Remove old OPF classes --- src/calibre/ebooks/metadata/opf.py | 538 ----------------------------- 1 file changed, 538 deletions(-) delete mode 100644 src/calibre/ebooks/metadata/opf.py diff --git a/src/calibre/ebooks/metadata/opf.py b/src/calibre/ebooks/metadata/opf.py deleted file mode 100644 index 9f1d12d6d1..0000000000 --- a/src/calibre/ebooks/metadata/opf.py +++ /dev/null @@ -1,538 +0,0 @@ -__license__ = 'GPL v3' -__copyright__ = '2008, Kovid Goyal ' -'''Read/Write metadata from Open Packaging Format (.opf) files.''' - -import re, os -import uuid -from urllib import unquote, quote - -from calibre.constants import __appname__, __version__ -from calibre.ebooks.metadata import MetaInformation, string_to_authors -from calibre.ebooks.BeautifulSoup import BeautifulStoneSoup, BeautifulSoup -from calibre.ebooks.lrf import entity_to_unicode -from calibre.ebooks.metadata import Resource, ResourceCollection -from calibre.ebooks.metadata.toc import TOC - -class OPFSoup(BeautifulStoneSoup): - - def __init__(self, raw): - BeautifulStoneSoup.__init__(self, raw, - convertEntities=BeautifulSoup.HTML_ENTITIES, - selfClosingTags=['item', 'itemref', 'reference']) - -class ManifestItem(Resource): - - @staticmethod - def from_opf_manifest_item(item, basedir): - if item.has_key('href'): - href = item['href'] - if unquote(href) == href: - try: - href = quote(href) - except KeyError: - pass - res = ManifestItem(href, basedir=basedir, is_path=False) - mt = item.get('media-type', '').strip() - if mt: - res.mime_type = mt - return res - - @dynamic_property - def media_type(self): - def fget(self): - return self.mime_type - def fset(self, val): - self.mime_type = val - return property(fget=fget, fset=fset) - - - def __unicode__(self): - return u''%(self.id, self.href(), self.media_type) - - def __str__(self): - return unicode(self).encode('utf-8') - - def __repr__(self): - return unicode(self) - - - def __getitem__(self, index): - if index == 0: - return self.href() - if index == 1: - return self.media_type - raise IndexError('%d out of bounds.'%index) - - -class Manifest(ResourceCollection): - - @staticmethod - def from_opf_manifest_element(manifest, dir): - m = Manifest() - for item in manifest.findAll(re.compile('item')): - try: - m.append(ManifestItem.from_opf_manifest_item(item, dir)) - id = item.get('id', '') - if not id: - id = 'id%d'%m.next_id - m[-1].id = id - m.next_id += 1 - except ValueError: - continue - return m - - @staticmethod - def from_paths(entries): - ''' - `entries`: List of (path, mime-type) If mime-type is None it is autodetected - ''' - m = Manifest() - for path, mt in entries: - mi = ManifestItem(path, is_path=True) - if mt: - mi.mime_type = mt - mi.id = 'id%d'%m.next_id - m.next_id += 1 - m.append(mi) - return m - - def __init__(self): - ResourceCollection.__init__(self) - self.next_id = 1 - - - def item(self, id): - for i in self: - if i.id == id: - return i - - def id_for_path(self, path): - path = os.path.normpath(os.path.abspath(path)) - for i in self: - if i.path and os.path.normpath(i.path) == path: - return i.id - - def path_for_id(self, id): - for i in self: - if i.id == id: - return i.path - -class Spine(ResourceCollection): - - class Item(Resource): - - def __init__(self, idfunc, *args, **kwargs): - Resource.__init__(self, *args, **kwargs) - self.is_linear = True - self.id = idfunc(self.path) - - @staticmethod - def from_opf_spine_element(spine, manifest): - s = Spine(manifest) - for itemref in spine.findAll(re.compile('itemref')): - if itemref.has_key('idref'): - r = Spine.Item(s.manifest.id_for_path, - s.manifest.path_for_id(itemref['idref']), is_path=True) - r.is_linear = itemref.get('linear', 'yes') == 'yes' - s.append(r) - return s - - @staticmethod - def from_paths(paths, manifest): - s = Spine(manifest) - for path in paths: - try: - s.append(Spine.Item(s.manifest.id_for_path, path, is_path=True)) - except: - continue - return s - - - - def __init__(self, manifest): - ResourceCollection.__init__(self) - self.manifest = manifest - - - def linear_items(self): - for r in self: - if r.is_linear: - yield r.path - - def nonlinear_items(self): - for r in self: - if not r.is_linear: - yield r.path - - def items(self): - for i in self: - yield i.path - - -class Guide(ResourceCollection): - - class Reference(Resource): - - @staticmethod - def from_opf_resource_item(ref, basedir): - title, href, type = ref.get('title', ''), ref['href'], ref['type'] - res = Guide.Reference(href, basedir, is_path=False) - res.title = title - res.type = type - return res - - def __repr__(self): - ans = '' - - - @staticmethod - def from_opf_guide(guide_elem, base_dir=os.getcwdu()): - coll = Guide() - for ref in guide_elem.findAll('reference'): - try: - ref = Guide.Reference.from_opf_resource_item(ref, base_dir) - coll.append(ref) - except: - continue - return coll - - def set_cover(self, path): - map(self.remove, [i for i in self if 'cover' in i.type.lower()]) - for type in ('cover', 'other.ms-coverimage-standard', 'other.ms-coverimage'): - self.append(Guide.Reference(path, is_path=True)) - self[-1].type = type - self[-1].title = '' - - -class standard_field(object): - - def __init__(self, name): - self.name = name - - def __get__(self, obj, typ=None): - return getattr(obj, 'get_'+self.name)() - - -class OPF(MetaInformation): - - MIMETYPE = 'application/oebps-package+xml' - ENTITY_PATTERN = re.compile(r'&(\S+?);') - - uid = standard_field('uid') - application_id = standard_field('application_id') - title = standard_field('title') - authors = standard_field('authors') - language = standard_field('language') - title_sort = standard_field('title_sort') - author_sort = standard_field('author_sort') - comments = standard_field('comments') - category = standard_field('category') - publisher = standard_field('publisher') - isbn = standard_field('isbn') - cover = standard_field('cover') - series = standard_field('series') - series_index = standard_field('series_index') - rating = standard_field('rating') - tags = standard_field('tags') - - def __init__(self): - raise NotImplementedError('Abstract base class') - - @dynamic_property - def package(self): - def fget(self): - return self.soup.find(re.compile('package')) - return property(fget=fget) - - @dynamic_property - def metadata(self): - def fget(self): - return self.package.find(re.compile('metadata')) - return property(fget=fget) - - - def get_title(self): - title = self.metadata.find('dc:title') - if title and title.string: - return self.ENTITY_PATTERN.sub(entity_to_unicode, title.string).strip() - return self.default_title.strip() - - def get_authors(self): - creators = self.metadata.findAll('dc:creator') - for elem in creators: - role = elem.get('role') - if not role: - role = elem.get('opf:role') - if not role: - role = 'aut' - if role == 'aut' and elem.string: - raw = self.ENTITY_PATTERN.sub(entity_to_unicode, elem.string) - return string_to_authors(raw) - return [] - - def get_author_sort(self): - creators = self.metadata.findAll('dc:creator') - for elem in creators: - role = elem.get('role') - if not role: - role = elem.get('opf:role') - if role == 'aut': - fa = elem.get('file-as') - return self.ENTITY_PATTERN.sub(entity_to_unicode, fa).strip() if fa else None - return None - - def get_title_sort(self): - title = self.package.find('dc:title') - if title: - if title.has_key('file-as'): - return title['file-as'].strip() - return None - - def get_comments(self): - comments = self.soup.find('dc:description') - if comments and comments.string: - return self.ENTITY_PATTERN.sub(entity_to_unicode, comments.string).strip() - return None - - def get_uid(self): - package = self.package - if package.has_key('unique-identifier'): - return package['unique-identifier'] - - def get_category(self): - category = self.soup.find('dc:type') - if category and category.string: - return self.ENTITY_PATTERN.sub(entity_to_unicode, category.string).strip() - return None - - def get_publisher(self): - publisher = self.soup.find('dc:publisher') - if publisher and publisher.string: - return self.ENTITY_PATTERN.sub(entity_to_unicode, publisher.string).strip() - return None - - def get_isbn(self): - for item in self.metadata.findAll('dc:identifier'): - scheme = item.get('scheme') - if not scheme: - scheme = item.get('opf:scheme') - if scheme is not None and scheme.lower() == 'isbn' and item.string: - return str(item.string).strip() - return None - - def get_language(self): - item = self.metadata.find('dc:language') - if not item: - return _('Unknown') - return ''.join(item.findAll(text=True)).strip() - - def get_application_id(self): - for item in self.metadata.findAll('dc:identifier'): - scheme = item.get('scheme', None) - if scheme is None: - scheme = item.get('opf:scheme', None) - if scheme in ['libprs500', 'calibre']: - return str(item.string).strip() - return None - - def get_cover(self): - guide = getattr(self, 'guide', []) - if not guide: - guide = [] - references = [ref for ref in guide if 'cover' in ref.type.lower()] - for candidate in ('cover', 'other.ms-coverimage-standard', 'other.ms-coverimage'): - matches = [r for r in references if r.type.lower() == candidate and r.path] - if matches: - return matches[0].path - - def possible_cover_prefixes(self): - isbn, ans = [], [] - for item in self.metadata.findAll('dc:identifier'): - scheme = item.get('scheme') - if not scheme: - scheme = item.get('opf:scheme') - isbn.append((scheme, item.string)) - for item in isbn: - ans.append(item[1].replace('-', '')) - return ans - - def get_series(self): - s = self.metadata.find('series') - if s is not None: - return str(s.string).strip() - return None - - def get_series_index(self): - s = self.metadata.find('series-index') - if s and s.string: - try: - return float(str(s.string).strip()) - except: - return None - return None - - def get_rating(self): - s = self.metadata.find('rating') - if s and s.string: - try: - return int(str(s.string).strip()) - except: - return None - return None - - def get_tags(self): - ans = [] - subs = self.soup.findAll('dc:subject') - for sub in subs: - val = sub.string - if val: - ans.append(val) - return [unicode(a).strip() for a in ans] - - -class OPFReader(OPF): - - def __init__(self, stream, dir=os.getcwdu()): - manage = False - if not hasattr(stream, 'read'): - manage = True - dir = os.path.dirname(stream) - stream = open(stream, 'rb') - self.default_title = stream.name if hasattr(stream, 'name') else 'Unknown' - if hasattr(stream, 'seek'): - stream.seek(0) - self.soup = OPFSoup(stream.read()) - if manage: - stream.close() - self.manifest = Manifest() - m = self.soup.find(re.compile('manifest')) - if m is not None: - self.manifest = Manifest.from_opf_manifest_element(m, dir) - self.spine = None - spine = self.soup.find(re.compile('spine')) - if spine is not None: - self.spine = Spine.from_opf_spine_element(spine, self.manifest) - - self.toc = TOC(base_path=dir) - self.toc.read_from_opf(self) - guide = self.soup.find(re.compile('guide')) - if guide is not None: - self.guide = Guide.from_opf_guide(guide, dir) - self.base_dir = dir - self.cover_data = (None, None) - - -class OPFCreator(MetaInformation): - - def __init__(self, base_path, *args, **kwargs): - ''' - Initialize. - @param base_path: An absolute path to the directory in which this OPF file - will eventually be. This is used by the L{create_manifest} method - to convert paths to files into relative paths. - ''' - MetaInformation.__init__(self, *args, **kwargs) - self.base_path = os.path.abspath(base_path) - if self.application_id is None: - self.application_id = str(uuid.uuid4()) - if not isinstance(self.toc, TOC): - self.toc = None - if not self.authors: - self.authors = [_('Unknown')] - if self.guide is None: - self.guide = Guide() - if self.cover: - self.guide.set_cover(self.cover) - - - def create_manifest(self, entries): - ''' - Create - - `entries`: List of (path, mime-type) If mime-type is None it is autodetected - ''' - entries = map(lambda x: x if os.path.isabs(x[0]) else - (os.path.abspath(os.path.join(self.base_path, x[0])), x[1]), - entries) - self.manifest = Manifest.from_paths(entries) - self.manifest.set_basedir(self.base_path) - - def create_manifest_from_files_in(self, files_and_dirs): - entries = [] - - def dodir(dir): - for spec in os.walk(dir): - root, files = spec[0], spec[-1] - for name in files: - path = os.path.join(root, name) - if os.path.isfile(path): - entries.append((path, None)) - - for i in files_and_dirs: - if os.path.isdir(i): - dodir(i) - else: - entries.append((i, None)) - - self.create_manifest(entries) - - def create_spine(self, entries): - ''' - Create the element. Must first call :method:`create_manifest`. - - `entries`: List of paths - ''' - entries = map(lambda x: x if os.path.isabs(x) else - os.path.abspath(os.path.join(self.base_path, x)), entries) - self.spine = Spine.from_paths(entries, self.manifest) - - def set_toc(self, toc): - ''' - Set the toc. You must call :method:`create_spine` before calling this - method. - - :param toc: A :class:`TOC` object - ''' - self.toc = toc - - def create_guide(self, guide_element): - self.guide = Guide.from_opf_guide(guide_element, self.base_path) - self.guide.set_basedir(self.base_path) - - def render(self, opf_stream, ncx_stream=None, ncx_manifest_entry=None): - from calibre.utils.genshi.template import MarkupTemplate - opf_template = open(P('templates/opf.xml'), 'rb').read() - template = MarkupTemplate(opf_template) - if self.manifest: - self.manifest.set_basedir(self.base_path) - if ncx_manifest_entry is not None: - if not os.path.isabs(ncx_manifest_entry): - ncx_manifest_entry = os.path.join(self.base_path, ncx_manifest_entry) - remove = [i for i in self.manifest if i.id == 'ncx'] - for item in remove: - self.manifest.remove(item) - self.manifest.append(ManifestItem(ncx_manifest_entry, self.base_path)) - self.manifest[-1].id = 'ncx' - self.manifest[-1].mime_type = 'application/x-dtbncx+xml' - if not self.guide: - self.guide = Guide() - if self.cover: - cover = self.cover - if not os.path.isabs(cover): - cover = os.path.abspath(os.path.join(self.base_path, cover)) - self.guide.set_cover(cover) - self.guide.set_basedir(self.base_path) - - opf = template.generate(__appname__=__appname__, mi=self, __version__=__version__).render('xml') - if not opf.startswith('\n'+opf - opf_stream.write(opf) - opf_stream.flush() - toc = getattr(self, 'toc', None) - if toc is not None and ncx_stream is not None: - toc.render(ncx_stream, self.application_id) - ncx_stream.flush() - From 82d3945702943d60f40a6663dd855175b2f5112a Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Sun, 23 May 2010 21:55:49 -0600 Subject: [PATCH 202/324] Remove --output-format from calibredb list as it is superseded by calibredb catalog --- src/calibre/library/cli.py | 194 +++++++++---------------------------- 1 file changed, 48 insertions(+), 146 deletions(-) diff --git a/src/calibre/library/cli.py b/src/calibre/library/cli.py index 12b7944383..3f71c98238 100644 --- a/src/calibre/library/cli.py +++ b/src/calibre/library/cli.py @@ -9,99 +9,18 @@ Command line interface to the calibre database. import sys, os, cStringIO from textwrap import TextWrapper -from urllib import quote from calibre import terminal_controller, preferred_encoding, prints from calibre.utils.config import OptionParser, prefs from calibre.ebooks.metadata.meta import get_metadata from calibre.library.database2 import LibraryDatabase2 from calibre.ebooks.metadata.opf2 import OPFCreator, OPF -from calibre.utils.genshi.template import MarkupTemplate from calibre.utils.date import isoformat FIELDS = set(['title', 'authors', 'author_sort', 'publisher', 'rating', 'timestamp', 'size', 'tags', 'comments', 'series', 'series_index', 'formats', 'isbn', 'uuid', 'pubdate', 'cover']) -XML_TEMPLATE = '''\ - - - - - ${record['id']} - ${record['uuid']} - ${record['title']} - - - $author - - - ${record['publisher']} - ${record['rating']} - ${record['timestamp'].isoformat()} - ${record['pubdate'].isoformat()} - ${record['size']} - - - $tag - - - ${record['comments']} - ${record['series']} - ${record['isbn']} - ${record['cover'].replace(os.sep, '/')} - - - ${path.replace(os.sep, '/')} - - - - - -''' - -STANZA_TEMPLATE='''\ - - - calibre Library - - calibre - http://calibre-ebook.com - - $id - ${updated.isoformat()} - - ${subtitle} - - - - ${record['title']} - urn:calibre:${record['uuid']} - ${record['author_sort']} - ${record['timestamp'].isoformat()} - - - - -
- - - ${f.capitalize()}:${unicode(', '.join(record[f]) if f=='tags' else record[f])} - # ${str(record['series_index'])} -
-
-
- -
- ${record['comments']} -
-
-
-
-
-
-''' - 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 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 Date: Mon, 24 May 2010 16:24:21 +0100 Subject: [PATCH 206/324] Add tooltips to top-level categories in tags_view --- src/calibre/gui2/tag_view.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py index ba93b818c2..5ff4fc23ba 100644 --- a/src/calibre/gui2/tag_view.py +++ b/src/calibre/gui2/tag_view.py @@ -126,7 +126,7 @@ class TagTreeItem(object): # {{{ TAG = 1 ROOT = 2 - def __init__(self, data=None, category_icon=None, icon_map=None, parent=None): + def __init__(self, data=None, category_icon=None, icon_map=None, parent=None, tooltip=None): self.parent = parent self.children = [] if self.parent is not None: @@ -144,6 +144,7 @@ class TagTreeItem(object): # {{{ elif self.type == self.TAG: icon_map[0] = data.icon self.tag, self.icon_state_map = data, list(map(QVariant, icon_map)) + self.tooltip = tooltip def __str__(self): if self.type == self.ROOT: @@ -175,6 +176,8 @@ class TagTreeItem(object): # {{{ return self.icon if role == Qt.FontRole: return self.bold_font + if role == Qt.ToolTipRole and self.tooltip is not None: + return QVariant(self.tooltip) return NONE def tag_data(self, role): @@ -228,7 +231,8 @@ class TagsModel(QAbstractItemModel): # {{{ for i, r in enumerate(self.row_map): c = TagTreeItem(parent=self.root_item, data=self.categories[i], - category_icon=self.category_icon_map[r]) + category_icon=self.category_icon_map[r], + tooltip=_('The lookup/search name is "{0}"').format(r)) for tag in data[r]: TagTreeItem(parent=c, data=tag, icon_map=self.icon_state_map) From ac8c95135bcbf4d5b152eaeff2cdd18718da1e68 Mon Sep 17 00:00:00 2001 From: GRiker Date: Mon, 24 May 2010 10:30:15 -0600 Subject: [PATCH 207/324] GwR apple driver in progress --- src/calibre/devices/apple/driver.py | 38 ++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/src/calibre/devices/apple/driver.py b/src/calibre/devices/apple/driver.py index 102cc1ebab..180fcf5a89 100644 --- a/src/calibre/devices/apple/driver.py +++ b/src/calibre/devices/apple/driver.py @@ -39,6 +39,7 @@ class ITUNES(DevicePlugin): BCD = [0x01] # Properties + cached_paths = {} iTunes= None sources = None verbose = True @@ -98,16 +99,22 @@ class ITUNES(DevicePlugin): device = self.sources['iPod'] if 'Books' in self.iTunes.sources[device].playlists.name(): booklist = BookList() + cached_paths = {} books = self.iTunes.sources[device].playlists['Books'].file_tracks() for book in books: this_book = Book(book.name(), book.artist()) this_book.datetime = parse_date(str(book.date_added())).timetuple() this_book.db_id = None this_book.device_collections = [] - this_book.path = '%s.epub' % book.name() + this_book.path = 'iTunes/%s - %s.epub' % (book.name(), book.artist()) this_book.size = book.size() this_book.thumbnail = None booklist.add_book(this_book, False) + cached_paths[this_book.path] = { 'title':book.name(), + 'author':book.artist(), + 'book':book} + self.cached_paths = cached_paths + print self.cached_paths return booklist else: # No books installed on this device @@ -202,8 +209,33 @@ class ITUNES(DevicePlugin): def delete_books(self, paths, end_session=True): ''' Delete books at paths on device. + Since we're deleting through iTunes, we'll use the cached handle to the book ''' - raise NotImplementedError() + for path in paths: + title = self.cached_paths[path]['title'] + author = self.cached_paths[path]['author'] + book = self.cached_paths[path]['book'] + print "ITUNES.delete_books(): Searching for '%s - %s'" % (title,author) + if True: + results = self.iTunes.playlists['library'].file_tracks[ + (appscript.its.name == title).AND + (appscript.its.artist == author).AND + (appscript.its.kind == 'Book')].get() + if len(results) == 1: + book_to_delete = results[0] + print "book_to_delete: %s" % book_to_delete + if self.verbose: + print "ITUNES:delete_books(): Deleting '%s - %s'" % (title, author) + self.iTunes.delete(results[0]) + elif len(results) > 1: + print "ITUNES.delete_books(): More than one book matches '%s - %s'" % (title, author) + else: + print "ITUNES.delete_books(): No book '%s - %s' found in iTunes" % (title, author) + else: + if self.verbose: + print "ITUNES:delete_books(): Deleting '%s - %s'" % (title, author) + self.iTunes.delete(book) + def eject(self): ''' @@ -279,7 +311,7 @@ class ITUNES(DevicePlugin): (L{books}(oncard=None), L{books}(oncard='carda'), L{books}(oncard='cardb')). ''' - raise NotImplementedError() + print "ITUNES.remove_books_from_metadata(): need to implement" def reset(self, key='-1', log_packets=False, report_progress=None, detected_device=None) : From c92c3312ed91978a190b8c191e3462badeb3bc8e Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Mon, 24 May 2010 11:43:59 -0600 Subject: [PATCH 208/324] Replace use of genshi with lxml for templates in the news download subsystem --- src/calibre/web/feeds/templates.py | 332 ++++++++++++++--------------- 1 file changed, 159 insertions(+), 173 deletions(-) diff --git a/src/calibre/web/feeds/templates.py b/src/calibre/web/feeds/templates.py index 4b2156b6a1..4de7c42daa 100644 --- a/src/calibre/web/feeds/templates.py +++ b/src/calibre/web/feeds/templates.py @@ -2,207 +2,193 @@ __license__ = 'GPL v3' __copyright__ = '2008, Kovid Goyal ' -from calibre.utils.genshi.template import MarkupTemplate -from calibre import preferred_encoding, strftime +from lxml import html, etree +from lxml.html.builder import HTML, HEAD, TITLE, STYLE, DIV, BODY, \ + STRONG, BR, H1, SPAN, A, HR, UL, LI, H2, IMG, P as PT -class Template(MarkupTemplate): +from calibre import preferred_encoding, strftime, isbytestring + +def CLASS(*args, **kwargs): # class is a reserved word in Python + kwargs['class'] = ' '.join(args) + return kwargs + +class Template(object): + + IS_HTML = True def generate(self, *args, **kwargs): if not kwargs.has_key('style'): kwargs['style'] = '' for key in kwargs.keys(): - if isinstance(kwargs[key], basestring) and not isinstance(kwargs[key], unicode): - kwargs[key] = unicode(kwargs[key], 'utf-8', 'replace') - for arg in args: - if isinstance(arg, basestring) and not isinstance(arg, unicode): - arg = unicode(arg, 'utf-8', 'replace') + if isbytestring(kwargs[key]): + kwargs[key] = kwargs[key].decode('utf-8', 'replace') + if kwargs[key] is None: + kwargs[key] = u'' + args = list(args) + for i in range(len(args)): + if isbytestring(args[i]): + args[i] = args[i].decode('utf-8', 'replace') + if args[i] is None: + args[i] = u'' - return MarkupTemplate.generate(self, *args, **kwargs) + self._generate(*args, **kwargs) + + return self + + def render(self, *args, **kwargs): + if self.IS_HTML: + return html.tostring(self.root, encoding='utf-8', + include_meta_content_type=True, pretty_print=True) + return etree.tostring(self.root, encoding='utf-8', xml_declaration=True, + pretty_print=True) class NavBarTemplate(Template): - def __init__(self): - Template.__init__(self, u'''\ - - - - - - -
-
-

- This article was downloaded by ${__appname__} from ${url} -

-

- - | Next - - - | Next - - | Section menu - - | Main menu - - - | Previous - - | -
-
- - -''') - - def generate(self, bottom, feed, art, number_of_articles_in_feed, + def _generate(self, bottom, feed, art, number_of_articles_in_feed, two_levels, url, __appname__, prefix='', center=True, - extra_css=None): + extra_css=None, style=None): + head = HEAD(TITLE('navbar')) + if style: + head.append(STYLE(style, type='text/css')) + if extra_css: + head.append(STYLE(extra_css, type='text/css')) + if prefix and not prefix.endswith('/'): prefix += '/' - return Template.generate(self, bottom=bottom, art=art, feed=feed, - num=number_of_articles_in_feed, - two_levels=two_levels, url=url, - __appname__=__appname__, prefix=prefix, - center=center, extra_css=extra_css) + align = 'center' if center else 'left' + navbar = DIV(CLASS('calibre_navbar', 'calibre_rescale_70', + style='text-align:'+align)) + if bottom: + navbar.append(HR()) + text = 'This article was downloaded by ' + p = PT(text, STRONG(__appname__), A(url, href=url), style='text-align:left') + p[0].tail = ' from ' + navbar.append(BR()) + navbar.append(BR()) + else: + next = 'feed_%d'%(feed+1) if art == number_of_articles_in_feed - 1 \ + else 'article_%d'%(art+1) + up = '../..' if art == number_of_articles_in_feed - 1 else '..' + href = '%s%s/%s/index.html'%(prefix, up, next) + navbar.text = '| ' + navbar.append(A('Next', href=href)) + href = '%s../index.html#article_%d'%(prefix, art) + navbar.iterchildren(reversed=True).next().tail = ' | ' + navbar.append(A('Section Menu', href=href)) + href = '%s../../index.html#feed_%d'%(prefix, feed) + navbar.iterchildren(reversed=True).next().tail = ' | ' + navbar.append(A('Main Menu', href=href)) + if art > 0 and not bottom: + href = '%s../article_%d/index.html'%(prefix, art-1) + navbar.iterchildren(reversed=True).next().tail = ' | ' + navbar.append(A('Previous', href=href)) + navbar.iterchildren(reversed=True).next().tail = ' | ' + if not bottom: + navbar.append(HR()) + + self.root = HTML(head, BODY(navbar)) + + class IndexTemplate(Template): - def __init__(self): - Template.__init__(self, u'''\ - - - - - ${title} - - - - -
-

${title}

-

${date}

- -
- - -''') - - def generate(self, title, datefmt, feeds, extra_css=None): + def _generate(self, title, datefmt, feeds, extra_css=None, style=None): if isinstance(datefmt, unicode): datefmt = datefmt.encode(preferred_encoding) date = strftime(datefmt) - return Template.generate(self, title=title, date=date, feeds=feeds, - extra_css=extra_css) - + head = HEAD(TITLE(title)) + if style: + head.append(STYLE(style, type='text/css')) + if extra_css: + head.append(STYLE(extra_css, type='text/css')) + ul = UL(CLASS('calibre_feed_list')) + for i, feed in enumerate(feeds): + if feed: + li = LI(A(feed.title, CLASS('feed', 'calibre_rescale_120', + href='feed_%d/index.html'%i)), id='feed_%d'%i) + ul.append(li) + div = DIV( + H1(title, CLASS('calibre_recipe_title', 'calibre_rescale_180')), + PT(date, style='text-align:right'), + ul, + CLASS('calibre_rescale_100')) + self.root = HTML(head, BODY(div)) class FeedTemplate(Template): - def __init__(self): - Template.__init__(self, u'''\ - - - - - ${feed.title} - - - - -
-

${feed.title}

- -
- ${feed.image_alt} -
-
-
- ${feed.description}
-
-
    - -
  • - ${article.title} - -
    - ${Markup(cutoff(article.text_summary))} -
    -
  • -
    -
-
- | Up one level | -
-
- - -''') + self.root = HTML(head, body) - def generate(self, feed, cutoff, extra_css=None): - return Template.generate(self, feed=feed, cutoff=cutoff, - extra_css=extra_css) class EmbeddedContent(Template): - def __init__(self): - Template.__init__(self, u'''\ - - len(summary) else summary + head = HEAD(TITLE(article.title)) + if style: + head.append(STYLE(style, type='text/css')) + if extra_css: + head.append(STYLE(extra_css, type='text/css')) -> - - ${article.title} - + if isbytestring(text): + text = text.decode('utf-8', 'replace') + elements = html.fragments_fromstring(text) + self.root = HTML(head, + BODY(H2(article.title), DIV())) + div = self.root.find('body').find('div') + if elements and isinstance(elements[0], unicode): + div.text = elements[0] + elements = list(elements)[1:] + for elem in elements: + elem.getparent().remove(elem) + div.append(elem) - -

${article.title}

-
- ${Markup(article.content if len(article.content if article.content else '') > len(article.summary if article.summary else '') else article.summary)} -
- - -''') - - def generate(self, article): - return Template.generate(self, article=article) From cf90d5f1e55373520201316cf572ee0ebc1ecb71 Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Mon, 24 May 2010 21:08:55 +0100 Subject: [PATCH 209/324] Small spelling mistake in default_tweaks.py --- resources/default_tweaks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/default_tweaks.py b/resources/default_tweaks.py index 5c15651f9c..e9ad64cee2 100644 --- a/resources/default_tweaks.py +++ b/resources/default_tweaks.py @@ -35,7 +35,7 @@ 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 +# The argument is None if 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 From 3bdf4e61f36507247db7dac00c4ee3797ab4a58d Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Mon, 24 May 2010 21:27:27 +0100 Subject: [PATCH 210/324] Fix stupidities: 1) I left a bunch of unused declarations in tag_view.py 2) I needlessly made a copy of user_categories in DB2 --- src/calibre/gui2/tag_view.py | 13 ------------- src/calibre/library/database2.py | 2 +- 2 files changed, 1 insertion(+), 14 deletions(-) diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py index 5ff4fc23ba..6d1bf5ab28 100644 --- a/src/calibre/gui2/tag_view.py +++ b/src/calibre/gui2/tag_view.py @@ -200,13 +200,6 @@ class TagTreeItem(object): # {{{ class TagsModel(QAbstractItemModel): # {{{ - categories_orig = [_('Authors'), _('Series'), _('Formats'), _('Publishers'), - _('Ratings'), _('News'), _('Tags')] - row_map_orig = ['authors', 'series', 'formats', 'publishers', 'ratings', - 'news', 'tags'] - search_keys=['search', _('Searches')] - - def __init__(self, db, parent=None): QAbstractItemModel.__init__(self, parent) @@ -257,12 +250,6 @@ class TagsModel(QAbstractItemModel): # {{{ return data - def get_search_nodes(self, icon): - l = [] - for i in saved_searches.names(): - l.append(Tag(i, tooltip=saved_searches.lookup(i), icon=icon)) - return l - def refresh(self): data = self.get_node_tree(config['sort_by_popularity']) # get category data for i, r in enumerate(self.row_map): diff --git a/src/calibre/library/database2.py b/src/calibre/library/database2.py index 6ca73d9656..8278386b8e 100644 --- a/src/calibre/library/database2.py +++ b/src/calibre/library/database2.py @@ -743,7 +743,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): 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']) + user_categories = 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. From 907ae0c3724ca7f537feb94ac61edd2ee49e9329 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Mon, 24 May 2010 14:28:20 -0600 Subject: [PATCH 211/324] Add parameter to ignore restriction when searching and fix bug when return matches with an empty query in ResultCache.search --- src/calibre/library/caches.py | 18 +- src/calibre/library/server/cache.py | 27 ++- src/calibre/library/server/opds.py | 312 ++++++---------------------- 3 files changed, 98 insertions(+), 259 deletions(-) diff --git a/src/calibre/library/caches.py b/src/calibre/library/caches.py index 17853b818f..5e6c10c27b 100644 --- a/src/calibre/library/caches.py +++ b/src/calibre/library/caches.py @@ -626,20 +626,24 @@ 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, + ignore_search_restriction=False): if not query or not query.strip(): - q = self.search_restriction - else: - q = '%s (%s)' % (self.search_restriction, query) + q = '' + if not ignore_search_restriction: + q = self.search_restriction + elif not ignore_search_restriction: + q = u'%s (%s)' % (self.search_restriction, query) if not q: if return_matches: - return list(self.map) # when return_matches, do not update the maps! + return list(self._map) # when return_matches, do not update the maps! self._map_filtered = list(self._map) return [] matches = sorted(self.parse(q)) + ans = [id for id in self._map if id in matches] 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 ans + self._map_filtered = ans return [] def set_search_restriction(self, s): diff --git a/src/calibre/library/server/cache.py b/src/calibre/library/server/cache.py index 89dc140434..5c9be367d0 100644 --- a/src/calibre/library/server/cache.py +++ b/src/calibre/library/server/cache.py @@ -6,13 +6,28 @@ __copyright__ = '2010, Kovid Goyal ' __docformat__ = 'restructuredtext en' from calibre.utils.date import utcnow +from calibre.utils.ordered_dict import OrderedDict class Cache(object): - @property - def categories_cache(self): - old = getattr(self, '_category_cache', None) + def add_routes(self, c): + self._category_cache = OrderedDict() + self._search_cache = OrderedDict() + + def search_cache(self, search): + old = self._search_cache.get(search, 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] + matches = self.db.data.search(search) + self._search_cache[search] = frozenset(matches) + if len(self._search_cache) > 10: + self._search_cache.popitem(last=False) + + + def categories_cache(self, restrict_to=frozenset([])): + old = self._category_cache.get(frozenset(restrict_to), None) + if old is None or old[0] <= self.db.last_modified(): + categories = self.db.get_categories(ids=restrict_to) + self._category_cache[restrict_to] = (utcnow(), categories) + if len(self._category_cache) > 10: + self._category_cache.popitem(last=False) + return self._category_cache[restrict_to][1] diff --git a/src/calibre/library/server/opds.py b/src/calibre/library/server/opds.py index 359449a838..23ee58da7f 100644 --- a/src/calibre/library/server/opds.py +++ b/src/calibre/library/server/opds.py @@ -5,20 +5,20 @@ __license__ = 'GPL v3' __copyright__ = '2010, Kovid Goyal ' __docformat__ = 'restructuredtext en' -import re, hashlib -from itertools import repeat +import hashlib, binascii from functools import partial -import cherrypy from lxml import etree from lxml.builder import ElementMaker +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 from calibre.constants import __appname__ +BASE_HREFS = { + 0 : '/stanza', + 1 : '/opds', +} + # Vocabulary for building OPDS feeds {{{ E = ElementMaker(namespace='http://www.w3.org/2005/Atom', nsmap={ @@ -42,7 +42,7 @@ NAVLINK = partial(E.link, def SEARCH(base_href, *args, **kwargs): kwargs['rel'] = 'search' kwargs['title'] = 'Search' - kwargs['href'] = base_href+'/?search={searchTerms}' + kwargs['href'] = base_href+'/search/{searchTerms}' return LINK(*args, **kwargs) def AUTHOR(name, uri=None): @@ -53,11 +53,9 @@ def AUTHOR(name, uri=None): 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()) +def NAVCATALOG_ENTRY(base_href, updated, title, description, query): + href = base_href+'/navcatalog/'+binascii.hexlify(query) + id_ = 'calibre-navcatalog:'+str(hashlib.sha1(href).hexdigest()) return E.entry( TITLE(title), ID(id_), @@ -79,14 +77,15 @@ class TopLevel(Feed): def __init__(self, updated, # datetime object in UTC categories, + version, id_ = 'urn:calibre:main', - base_href = '/stanza' ): + base_href = BASE_HREFS[version] 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 + subcatalogs = [subc(_('By ')+title, + _('Books sorted by ') + desc, q) for title, desc, q in categories] self.root = \ @@ -100,248 +99,69 @@ class TopLevel(Feed): *subcatalogs ) - - -# 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 - -''') - -# Feed of 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_FORMATS = frozenset(['epub', 'pdb']) 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|')) + def add_routes(self, connect): + for base in ('stanza', 'opds'): + version = 0 if base == 'stanza' else 1 + base_href = BASE_HREFS[version] + connect(base, base_href, self.opds, version=version) + connect('opdsnavcatalog_'+base, base_href+'/navcatalog/{which}', + self.opds_navcatalog, version=version) + connect('opdssearch_'+base, base_href+'/search/{terms}', + self.opds_search, version=version) - return TopLevel(updated, categories, base_href=base_href) + def get_opds_allowed_ids_for_version(self, version): + search = '' if version > 0 else ' '.join(['format:='+x for x in + STANZA_FORMATS]) + self.seach_cache(search) - 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 opds_search(self, terms=None, version=0): + version = int(version) + if not terms or version not in BASE_HREFS: + raise cherrypy.HTTPError(404, 'Not found') - def stanza_sortby_subcategory(self, updated, sortby, offset): - pat = re.compile(r'\(.*\)') + def opds_navcatalog(self, which=None, version=0): + version = int(version) + if not which or version not in BASE_HREFS: + raise cherrypy.HTTPError(404, 'Not found') + which = binascii.unhexlify(which) + type_ = which[0] + which = which[1:] + if type_ == 'O': + return self.get_opds_all_books(which) + elif type_ == 'N': + return self.get_opds_navcatalog(which) + raise cherrypy.HTTPError(404, 'Not found') - 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') - - @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 = [] + def opds(self, version=0): + version = int(version) + if version not in BASE_HREFS: + raise cherrypy.HTTPError(404, 'Not found') + categories = self.categories_cache( + self.get_opds_allowed_ids_for_version(version)) + category_meta = self.db.get_tag_browser_categories() + cats = [ + (_('Newest'), _('Date'), 'Onewest'), + (_('Title'), _('Title'), 'Otitle'), + ] + for category in categories: + if category == 'formats': + continue + meta = category_meta.get(category, None) + if meta is None: + continue + cats.append((meta['name'], meta['name'], 'N'+category)) updated = self.db.last_modified() - offset = int(offset) + cherrypy.response.headers['Last-Modified'] = self.last_modified(updated) cherrypy.response.headers['Content-Type'] = 'text/xml' - # Top Level feed - if not sortby and not search and not authorid and not tagid and not seriesid: - return str(self.build_top_level(updated)) + feed = TopLevel(updated, cats, version) - 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) + return str(feed) - 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') - - -if __name__ == '__main__': - from datetime import datetime - f = TopLevel(datetime.utcnow()) - print f From afe594d5aa8968eb551bc6f9819cc114c631491d Mon Sep 17 00:00:00 2001 From: GRiker Date: Mon, 24 May 2010 14:53:21 -0600 Subject: [PATCH 212/324] GwR apple driver wip --- src/calibre/devices/apple/driver.py | 75 ++++++++++++++++------------- 1 file changed, 41 insertions(+), 34 deletions(-) diff --git a/src/calibre/devices/apple/driver.py b/src/calibre/devices/apple/driver.py index 180fcf5a89..1c596f9da8 100644 --- a/src/calibre/devices/apple/driver.py +++ b/src/calibre/devices/apple/driver.py @@ -139,38 +139,25 @@ class ITUNES(DevicePlugin): ''' # print "ITUNES:can_handle()" if isosx: - # Launch iTunes if not already running - if not self.iTunes: - if self.verbose: - print "ITUNES:can_handle(): Instantiating iTunes" - running_apps = appscript.app('System Events') - if not 'iTunes' in running_apps.processes.name(): + if self.iTunes: + # Check for connected book-capable device + names = [s.name() for s in self.iTunes.sources()] + kinds = [str(s.kind()).rpartition('.')[2] for s in self.iTunes.sources()] + self.sources = sources = dict(zip(kinds,names)) + if 'iPod' in sources: if self.verbose: - print "ITUNES:can_handle(): Launching iTunes" - self.iTunes = iTunes= appscript.app('iTunes', hide=True) - iTunes.run() - if self.verbose: - print "%s - %s (launched)" % (self.iTunes.name(), self.iTunes.version()) + sys.stdout.write('.') + sys.stdout.flush() + return True else: - self.iTunes = appscript.app('iTunes') if self.verbose: - print " %s - %s (already running)" % (self.iTunes.name(), self.iTunes.version()) - - # Check for connected book-capable device - names = [s.name() for s in self.iTunes.sources()] - kinds = [str(s.kind()).rpartition('.')[2] for s in self.iTunes.sources()] - self.sources = sources = dict(zip(kinds,names)) - if 'iPod' in sources: - if self.verbose: - sys.stdout.write('.') - sys.stdout.flush() - return True + print "ITUNES.can_handle(): device not connected" + return False else: - if self.verbose: - print "ITUNES.can_handle(): device not connected" - self.iTunes = None - self.sources = None - return False + # can_handle() is called once before open(), so need to return True + # to keep things going + print "ITUNES:can_handle(): iTunes not yet instantiated" + return True def can_handle_windows(self, device_id, debug=False): ''' @@ -236,7 +223,6 @@ class ITUNES(DevicePlugin): print "ITUNES:delete_books(): Deleting '%s - %s'" % (title, author) self.iTunes.delete(book) - def eject(self): ''' Un-mount / eject the device from the OS. This does not check if there @@ -294,7 +280,22 @@ class ITUNES(DevicePlugin): this function that should serve as a good example for USB Mass storage devices. ''' - print "ITUNES.open()" + if isosx: + # Launch iTunes if not already running + if self.verbose: + print "ITUNES:open(): Instantiating iTunes" + running_apps = appscript.app('System Events') + if not 'iTunes' in running_apps.processes.name(): + if self.verbose: + print "ITUNES:open(): Launching iTunes" + self.iTunes = iTunes= appscript.app('iTunes', hide=True) + iTunes.run() + if self.verbose: + print "%s - %s (launched)" % (self.iTunes.name(), self.iTunes.version()) + else: + self.iTunes = appscript.app('iTunes') + if self.verbose: + print " %s - %s (already running)" % (self.iTunes.name(), self.iTunes.version()) def post_yank_cleanup(self): ''' @@ -373,7 +374,16 @@ class ITUNES(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. """ - print "ITUNES:total_space()" + if self.verbose: + print "ITUNES:total_space()" + capacity = 0 + if isosx: + if 'iPod' in self.sources: + connected_device = self.sources['iPod'] + capacity = self.iTunes.sources[connected_device].capacity() + + return (capacity,-1,-1) + def upload_books(self, files, names, on_card=None, end_session=True, metadata=None): @@ -397,9 +407,6 @@ class ITUNES(DevicePlugin): ''' raise NotImplementedError() - # Private methods - - class BookList(list): ''' A list of books. Each Book object must have the fields: From 786ab0ef804c271a7d84ae25a40f716d5c18018e Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Mon, 24 May 2010 15:26:18 -0600 Subject: [PATCH 213/324] Fix search() behavior --- src/calibre/library/caches.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/calibre/library/caches.py b/src/calibre/library/caches.py index 5e6c10c27b..e943a4141e 100644 --- a/src/calibre/library/caches.py +++ b/src/calibre/library/caches.py @@ -632,19 +632,21 @@ class ResultCache(SearchQueryParser): q = '' if not ignore_search_restriction: q = self.search_restriction - elif not ignore_search_restriction: - q = u'%s (%s)' % (self.search_restriction, query) + else: + if ignore_search_restriction: + q = u'%s' % query + else: + q = u'%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 [] + return matches = sorted(self.parse(q)) ans = [id for id in self._map if id in matches] if return_matches: return ans self._map_filtered = ans - return [] def set_search_restriction(self, s): self.search_restriction = s From c0fbc32e4caf64715d7fb2014563d2aad947f05c Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Mon, 24 May 2010 15:53:00 -0600 Subject: [PATCH 214/324] Fix search() behavior again --- src/calibre/library/caches.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/calibre/library/caches.py b/src/calibre/library/caches.py index e943a4141e..10487af75a 100644 --- a/src/calibre/library/caches.py +++ b/src/calibre/library/caches.py @@ -628,14 +628,13 @@ class ResultCache(SearchQueryParser): def search(self, query, return_matches=False, ignore_search_restriction=False): + q = '' if not query or not query.strip(): - q = '' if not ignore_search_restriction: q = self.search_restriction else: - if ignore_search_restriction: - q = u'%s' % query - else: + q = query + if not ignore_search_restriction: q = u'%s (%s)' % (self.search_restriction, query) if not q: if return_matches: From 3e926bb612c8a833fd81f3d3b4876b1001678e1f Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Tue, 25 May 2010 14:29:51 +0100 Subject: [PATCH 215/324] 1) Ensure that Calibres always uses the same names for tag and search categories. 2) Add code to verify the names are reserved. 3) add compatibility names to the reserved words list 4) fix model to show the correct search label 'data' for the timestamp column 5) change internal naming of icons and user categories to avoid namespace collisions 6) fix get_categories to do the right thing with rating fields --- src/calibre/ebooks/metadata/book/__init__.py | 17 ++++++--- src/calibre/gui2/library/models.py | 15 +++++--- src/calibre/gui2/tag_view.py | 38 +++++++++++--------- src/calibre/library/database2.py | 23 ++++++------ src/calibre/utils/search_query_parser.py | 33 ++++++++++------- 5 files changed, 76 insertions(+), 50 deletions(-) diff --git a/src/calibre/ebooks/metadata/book/__init__.py b/src/calibre/ebooks/metadata/book/__init__.py index 0edf08c405..c106582bfa 100644 --- a/src/calibre/ebooks/metadata/book/__init__.py +++ b/src/calibre/ebooks/metadata/book/__init__.py @@ -89,11 +89,18 @@ CALIBRE_METADATA_FIELDS = frozenset([ ) CALIBRE_RESERVED_LABELS = frozenset([ - 'search', # reserved for saved searches - 'date', - 'all', - 'ondevice', - 'inlibrary', + 'all', # search term + 'author_sort', # can appear in device collection customization + 'date', # search term + 'formats', # search term + 'inlibrary', # search term + 'news', # search term + 'ondevice', # search term + 'search', # search term + 'format', # The next four are here for backwards compatibility + 'tag', # with searching. The terms can be used without the + 'author', # trailing 's'. + 'comment', # Sigh ... ] ) diff --git a/src/calibre/gui2/library/models.py b/src/calibre/gui2/library/models.py index bc0367b766..18af6d8560 100644 --- a/src/calibre/gui2/library/models.py +++ b/src/calibre/gui2/library/models.py @@ -631,7 +631,10 @@ class BooksModel(QAbstractTableModel): # {{{ 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])) + ht = self.column_map[section] + if ht == 'timestamp': # change help text because users know this field as 'date' + ht = 'date' + return QVariant(_('The lookup/search name is "{0}"').format(ht)) if role == Qt.DisplayRole: return QVariant(self.headers[self.column_map[section]]) return NONE @@ -730,11 +733,13 @@ class BooksModel(QAbstractTableModel): # {{{ class OnDeviceSearch(SearchQueryParser): # {{{ USABLE_LOCATIONS = [ - 'collections', - 'title', - 'author', - 'format', 'all', + 'author', + 'authors', + 'collections', + 'format', + 'formats', + 'title', ] diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py index 6d1bf5ab28..8bbdc69c62 100644 --- a/src/calibre/gui2/tag_view.py +++ b/src/calibre/gui2/tag_view.py @@ -14,8 +14,7 @@ from PyQt4.Qt import Qt, QTreeView, QApplication, pyqtSignal, \ 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 +from calibre.ebooks.metadata.book import RESERVED_METADATA_FIELDS class TagsView(QTreeView): # {{{ @@ -204,17 +203,22 @@ class TagsModel(QAbstractItemModel): # {{{ QAbstractItemModel.__init__(self, parent) # 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'))} + # before a QPaintDevice'. The ':' in front avoids polluting either the + # user-defined categories (':' at end) or columns namespaces (no ':'). + self.category_icon_map = { + 'authors' : QIcon(I('user_profile.svg')), + 'series' : QIcon(I('series.svg')), + 'formats' : QIcon(I('book.svg')), + 'publisher' : QIcon(I('publisher.png')), + 'rating' : 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'))} + for k in self.category_icon_map.keys(): + if not k.startswith(':') and k not in RESERVED_METADATA_FIELDS: + raise ValueError('Tag category [%s] is not a reserved word.' %(k)) self.icon_state_map = [None, QIcon(I('plus.svg')), QIcon(I('minus.svg'))] self.db = db self.search_restriction = '' @@ -381,9 +385,9 @@ class TagsModel(QAbstractItemModel): # {{{ def tokens(self): ans = [] - tags_seen = [] + tags_seen = set() 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 + 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: @@ -394,10 +398,10 @@ class TagsModel(QAbstractItemModel): # {{{ 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 category == 'tags': if tag.name in tags_seen: continue - tags_seen.append(tag.name) + tags_seen.add(tag.name) ans.append('%s%s:"=%s"'%(prefix, category, tag.name)) return ans diff --git a/src/calibre/library/database2.py b/src/calibre/library/database2.py index 8278386b8e..84124b6ce9 100644 --- a/src/calibre/library/database2.py +++ b/src/calibre/library/database2.py @@ -37,6 +37,7 @@ 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 +from calibre.ebooks.metadata.book import RESERVED_METADATA_FIELDS if iswindows: import calibre.utils.winshell as winshell @@ -137,10 +138,10 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): ('formats', {'table':None, 'column':None, 'type':None, 'is_multiple':False, 'kind':'standard', 'name':_('Formats')}), - ('publishers',{'table':'publishers', 'column':'name', + ('publisher', {'table':'publishers', 'column':'name', 'type':'text', 'is_multiple':False, 'kind':'standard', 'name':_('Publishers')}), - ('ratings', {'table':'ratings', 'column':'rating', + ('rating', {'table':'ratings', 'column':'rating', 'type':'rating', 'is_multiple':False, 'kind':'standard', 'name':_('Ratings')}), ('news', {'table':'news', 'column':'name', @@ -152,6 +153,8 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): ] self.tag_browser_categories = OrderedDict() for k,v in tag_browser_categories_items: + if k not in RESERVED_METADATA_FIELDS: + raise ValueError('Tag category [%s] is not a reserved word.' %(k)) self.tag_browser_categories[k] = v self.connect() @@ -694,25 +697,25 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): 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'] + icon = icon_map[':custom'] + icon_map[category] = icon tooltip = self.custom_column_label_map[category]['name'] datatype = self.tag_browser_categories[category]['type'] if datatype == 'rating': - item_zero_func = (lambda x: len(formatter(r[1])) > 0) + item_not_zero_func = (lambda x: x[1] > 0 and x[2] > 0) formatter = (lambda x:u'\u2605'*int(round(x/2.))) elif category == 'authors': - item_zero_func = (lambda x: x[2] > 0) + item_not_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) + item_not_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 item_zero_func(r)] + for r in data if item_not_zero_func(r)] # We delayed computing the standard formats category because it does not # use a view, but is computed dynamically @@ -767,14 +770,14 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): 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 + 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'] + 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))) diff --git a/src/calibre/utils/search_query_parser.py b/src/calibre/utils/search_query_parser.py index 11991727b7..5fe0a242f8 100644 --- a/src/calibre/utils/search_query_parser.py +++ b/src/calibre/utils/search_query_parser.py @@ -22,7 +22,7 @@ from calibre.utils.pyparsing import Keyword, Group, Forward, CharsNotIn, Suppres OneOrMore, oneOf, CaselessLiteral, Optional, NoMatch, ParseException from calibre.constants import preferred_encoding from calibre.utils.config import prefs - +from calibre.ebooks.metadata.book import RESERVED_METADATA_FIELDS ''' This class manages access to the preference holding the saved search queries. @@ -87,21 +87,25 @@ class SearchQueryParser(object): ''' DEFAULT_LOCATIONS = [ - 'tag', - 'title', - 'author', + 'all', + 'author', # compatibility + 'authors', + 'comment', # compatibility + 'comments', + 'cover', + 'date', + 'format', # compatibility + 'formats', + 'isbn', + 'ondevice', + 'pubdate', 'publisher', + 'search', 'series', 'rating', - 'cover', - 'comments', - 'format', - 'isbn', - 'search', - 'date', - 'pubdate', - 'ondevice', - 'all', + 'tag', # compatibility + 'tags', + 'title', ] @staticmethod @@ -118,6 +122,9 @@ class SearchQueryParser(object): return failed def __init__(self, locations=None, test=False): + for k in self.DEFAULT_LOCATIONS: + if k not in RESERVED_METADATA_FIELDS: + raise ValueError('Search location [%s] is not a reserved word.' %(k)) if locations is None: locations = self.DEFAULT_LOCATIONS self._tests_failed = False From cdbfc91effd0330dbb931addbc56fa5d9db1f8ff Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Tue, 25 May 2010 09:15:04 -0600 Subject: [PATCH 216/324] Fix cleanup of device jobs on device yank --- src/calibre/gui2/device.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/calibre/gui2/device.py b/src/calibre/gui2/device.py index 19d0c5f068..41abc6cb95 100644 --- a/src/calibre/gui2/device.py +++ b/src/calibre/gui2/device.py @@ -37,6 +37,7 @@ class DeviceJob(BaseJob): self.exception = None self.job_manager = job_manager self._details = _('No details available.') + self._aborted = False def start_work(self): self.start_time = time.time() @@ -55,7 +56,11 @@ class DeviceJob(BaseJob): self.start_work() try: self.result = self.func(*self.args, **self.kwargs) + if self._aborted: + return except (Exception, SystemExit), err: + if self._aborted: + return self.failed = True self._details = unicode(err) + '\n\n' + \ traceback.format_exc() @@ -63,6 +68,12 @@ class DeviceJob(BaseJob): finally: self.job_done() + def abort(self, err): + self._aborted = True + self.failed = True + self._details = unicode(err) + self.exception = err + @property def log_file(self): return cStringIO.StringIO(self._details.encode('utf-8')) From 0042f90879a2de50ace70f007e44b829921424da Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Tue, 25 May 2010 09:15:43 -0600 Subject: [PATCH 217/324] More work on the OPDS catalogs --- src/calibre/library/server/base.py | 1 - src/calibre/library/server/opds.py | 125 ++++++++++++++++++++++++----- 2 files changed, 104 insertions(+), 22 deletions(-) diff --git a/src/calibre/library/server/base.py b/src/calibre/library/server/base.py index a8d4ae899c..68d3a40bab 100644 --- a/src/calibre/library/server/base.py +++ b/src/calibre/library/server/base.py @@ -66,7 +66,6 @@ class LibraryServer(ContentServer, MobileServer, XMLServer, OPDSServer, Cache): self.embedded = embedded self.max_cover_width, self.max_cover_height = \ map(int, self.opts.max_cover.split('x')) - self.max_stanza_items = opts.max_opds_items path = P('content_server') self.build_time = fromtimestamp(os.stat(path).st_mtime) self.default_cover = open(P('content_server/default_cover.jpg'), 'rb').read() diff --git a/src/calibre/library/server/opds.py b/src/calibre/library/server/opds.py index 23ee58da7f..d6702cbe75 100644 --- a/src/calibre/library/server/opds.py +++ b/src/calibre/library/server/opds.py @@ -36,10 +36,10 @@ 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, +NAVLINK = partial(E.link, rel='subsection', type='application/atom+xml;type=feed;profile=opds-catalog') -def SEARCH(base_href, *args, **kwargs): +def SEARCH_LINK(base_href, *args, **kwargs): kwargs['rel'] = 'search' kwargs['title'] = 'Search' kwargs['href'] = base_href+'/search/{searchTerms}' @@ -64,43 +64,105 @@ def NAVCATALOG_ENTRY(base_href, updated, title, description, query): NAVLINK(href=href) ) +START_LINK = partial(NAVLINK, rel='start') +UP_LINK = partial(NAVLINK, rel='up') +FIRST_LINK = partial(NAVLINK, rel='first') +LAST_LINK = partial(NAVLINK, rel='last') +NEXT_LINK = partial(NAVLINK, rel='next') +PREVIOUS_LINK = partial(NAVLINK, rel='previous') + # }}} class Feed(object): + def __init__(self, id_, updated, version, subtitle=None, + title=__appname__ + ' ' + _('Library'), + up_link=None, first_link=None, last_link=None, + next_link=None, previous_link=None): + self.base_href = BASE_HREFS[version] + + self.root = \ + FEED( + TITLE(title), + AUTHOR(__appname__, uri='http://calibre-ebook.com'), + ID(id_), + UPDATED(updated), + SEARCH_LINK(self.base_href), + START_LINK(self.base_href) + ) + if up_link: + self.root.append(UP_LINK(up_link)) + if first_link: + self.root.append(FIRST_LINK(first_link)) + if last_link: + self.root.append(LAST_LINK(last_link)) + if next_link: + self.root.append(NEXT_LINK(next_link)) + if previous_link: + self.root.append(PREVIOUS_LINK(previous_link)) + if subtitle: + self.root.insert(1, SUBTITLE(subtitle)) + def __str__(self): return etree.tostring(self.root, pretty_print=True, encoding='utf-8', xml_declaration=True) -class TopLevel(Feed): +class TopLevel(Feed): # {{{ def __init__(self, updated, # datetime object in UTC categories, version, id_ = 'urn:calibre:main', + subtitle = _('Books in your library') ): - base_href = BASE_HREFS[version] - self.base_href = base_href - subc = partial(NAVCATALOG_ENTRY, base_href, updated) + Feed.__init__(self, id_, updated, version, subtitle=subtitle) + subc = partial(NAVCATALOG_ENTRY, self.base_href, updated) subcatalogs = [subc(_('By ')+title, _('Books sorted by ') + desc, q) for title, desc, q in categories] + for x in subcatalogs: + self.root.append(x) +# }}} - 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 - ) +class AcquisitionFeed(Feed): + + def __init__(self, updated, id_, items, offsets, page_url, up_url, version): + kwargs = {'up_link': up_url} + kwargs['first_link'] = page_url + kwargs['last_link'] = page_url+'?offset=%d'%offsets.last_offset + if offsets.offset > 0: + kwargs['previous_link'] = \ + page_url+'?offset=%d'%offsets.previous_offset + if offsets.next_offset > -1: + kwargs['next_offset'] = \ + page_url+'?offset=%d'%offsets.next_offset + Feed.__init__(self, id_, updated, version, **kwargs) STANZA_FORMATS = frozenset(['epub', 'pdb']) +class OPDSOffsets(object): + + def __init__(self, offset, delta, total): + if offset < 0: + offset = 0 + if offset >= total: + raise cherrypy.HTTPError(404, 'Invalid offset: %r'%offset) + self.offset = offset + self.next_offset = offset + delta + if self.next_offset >= total: + self.next_offset = -1 + if self.next_offset >= total: + self.next_offset = -1 + self.previous_offset = self.offset - delta + if self.previous_offset < 0: + self.previous_offset = 0 + self.last_offset = total - delta + if self.last_offset < 0: + self.last_offset = 0 + + class OPDSServer(object): def add_routes(self, connect): @@ -110,18 +172,39 @@ class OPDSServer(object): connect(base, base_href, self.opds, version=version) connect('opdsnavcatalog_'+base, base_href+'/navcatalog/{which}', self.opds_navcatalog, version=version) - connect('opdssearch_'+base, base_href+'/search/{terms}', + connect('opdssearch_'+base, base_href+'/search/{query}', self.opds_search, version=version) def get_opds_allowed_ids_for_version(self, version): search = '' if version > 0 else ' '.join(['format:='+x for x in STANZA_FORMATS]) - self.seach_cache(search) + self.search_cache(search) - def opds_search(self, terms=None, version=0): - version = int(version) - if not terms or version not in BASE_HREFS: + def get_opds_acquisition_feed(self, ids, offset, page_url, up_url, id_, + sort_by='title', ascending=True, version=0): + idx = self.db.FIELD_MAP['id'] + ids &= self.get_opds_allowed_ids_for_version(version) + items = [x for x in self.db.data.iterall() if x[idx] in ids] + self.sort(items, sort_by, ascending) + max_items = self.opts.max_opds_items + offsets = OPDSOffsets(offset, max_items, len(items)) + items = items[offsets.offset:offsets.next_offset] + return str(AcquisitionFeed(self.db.last_modified(), id_, items, offsets, page_url, up_url, version)) + + def opds_search(self, query=None, version=0, offset=0): + try: + offset = int(offset) + version = int(version) + except: raise cherrypy.HTTPError(404, 'Not found') + if query is None or version not in BASE_HREFS: + raise cherrypy.HTTPError(404, 'Not found') + try: + ids = self.search_cache(query) + except: + raise cherrypy.HTTPError(404, 'Search: %r not understood'%query) + return self.get_opds_acquisition_feed(ids, + sort_by='title', version=version) def opds_navcatalog(self, which=None, version=0): version = int(version) From e43f1b9fd2d11d9ccb42a3d6f312f6f9d469148f Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Tue, 25 May 2010 09:56:44 -0600 Subject: [PATCH 218/324] ... --- src/calibre/ebooks/metadata/book/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/calibre/ebooks/metadata/book/__init__.py b/src/calibre/ebooks/metadata/book/__init__.py index c106582bfa..39fb1920cd 100644 --- a/src/calibre/ebooks/metadata/book/__init__.py +++ b/src/calibre/ebooks/metadata/book/__init__.py @@ -90,7 +90,6 @@ CALIBRE_METADATA_FIELDS = frozenset([ CALIBRE_RESERVED_LABELS = frozenset([ 'all', # search term - 'author_sort', # can appear in device collection customization 'date', # search term 'formats', # search term 'inlibrary', # search term From b92c8f21dd4a881e62367fc59094b49635a680da Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Tue, 25 May 2010 14:06:10 -0600 Subject: [PATCH 219/324] Searching now works in the OPDS feeds --- src/calibre/library/server/cache.py | 8 ++- src/calibre/library/server/opds.py | 100 +++++++++++++++++++++++++--- 2 files changed, 96 insertions(+), 12 deletions(-) diff --git a/src/calibre/library/server/cache.py b/src/calibre/library/server/cache.py index 5c9be367d0..f8de28a735 100644 --- a/src/calibre/library/server/cache.py +++ b/src/calibre/library/server/cache.py @@ -17,10 +17,14 @@ class Cache(object): def search_cache(self, search): old = self._search_cache.get(search, None) if old is None or old[0] <= self.db.last_modified(): - matches = self.db.data.search(search) - self._search_cache[search] = frozenset(matches) + matches = self.db.data.search(search, return_matches=True, + ignore_search_restriction=True) + if not matches: + matches = [] + self._search_cache[search] = (utcnow(), frozenset(matches)) if len(self._search_cache) > 10: self._search_cache.popitem(last=False) + return self._search_cache[search][1] def categories_cache(self, restrict_to=frozenset([])): diff --git a/src/calibre/library/server/opds.py b/src/calibre/library/server/opds.py index d6702cbe75..149f12644c 100644 --- a/src/calibre/library/server/opds.py +++ b/src/calibre/library/server/opds.py @@ -7,18 +7,24 @@ __docformat__ = 'restructuredtext en' import hashlib, binascii from functools import partial +from itertools import repeat -from lxml import etree +from lxml import etree, html from lxml.builder import ElementMaker import cherrypy from calibre.constants import __appname__ +from calibre.ebooks.metadata import fmt_sidx +from calibre.library.comments import comments_to_html +from calibre import guess_type BASE_HREFS = { 0 : '/stanza', 1 : '/opds', } +STANZA_FORMATS = frozenset(['epub', 'pdb']) + # Vocabulary for building OPDS feeds {{{ E = ElementMaker(namespace='http://www.w3.org/2005/Atom', nsmap={ @@ -71,9 +77,72 @@ LAST_LINK = partial(NAVLINK, rel='last') NEXT_LINK = partial(NAVLINK, rel='next') PREVIOUS_LINK = partial(NAVLINK, rel='previous') +def html_to_lxml(raw): + raw = u'
%s
'%raw + root = html.fragment_fromstring(raw) + root.set('xmlns', "http://www.w3.org/1999/xhtml") + raw = etree.tostring(root, encoding=None) + return etree.fromstring(raw) + +def ACQUISITION_ENTRY(item, version, FM, updated): + title = item[FM['title']] + if not title: + title = _('Unknown') + authors = item[FM['authors']] + if not authors: + authors = _('Unknown') + authors = ' & '.join([i.replace('|', ',') for i in + authors.split(',')]) + extra = [] + rating = item[FM['rating']] + if rating > 0: + rating = u''.join(repeat(u'\u2605', int(rating/2.))) + extra.append(_('RATING: %s
')%rating) + tags = item[FM['tags']] + if tags: + extra.append(_('TAGS: %s
')%\ + ', '.join(tags.split(','))) + series = item[FM['series']] + if series: + extra.append(_('SERIES: %s [%s]
')%\ + (series, + fmt_sidx(float(item[FM['series_index']])))) + comments = item[FM['comments']] + if comments: + comments = comments_to_html(comments) + extra.append(comments) + if extra: + extra = html_to_lxml('\n'.join(extra)) + idm = 'calibre' if version == 0 else 'uuid' + id_ = 'urn:%s:%s'%(idm, item[FM['uuid']]) + ans = E.entry(TITLE(title), E.author(E.name(authors)), ID(id_), + UPDATED(updated)) + if extra: + ans.append(E.content(extra, type='xhtml')) + formats = item[FM['formats']] + if formats: + for fmt in formats.split(','): + fmt = fmt.lower() + mt = guess_type('a.'+fmt)[0] + href = '/get/%s/%s'%(fmt, item[FM['id']]) + if mt: + link = E.link(type=mt, href=href) + if version > 0: + link.set('rel', "http://opds-spec.org/acquisition") + ans.append(link) + ans.append(E.link(type='image/jpeg', href='/get/cover/%s'%item[FM['id']], + rel="x-stanza-cover-image" if version == 0 else + "http://opds-spec.org/cover")) + ans.append(E.link(type='image/jpeg', href='/get/thumb/%s'%item[FM['id']], + rel="x-stanza-cover-image-thumbnail" if version == 0 else + "http://opds-spec.org/thumbnail")) + + return ans + + # }}} -class Feed(object): +class Feed(object): # {{{ def __init__(self, id_, updated, version, subtitle=None, title=__appname__ + ' ' + _('Library'), @@ -106,6 +175,7 @@ class Feed(object): def __str__(self): return etree.tostring(self.root, pretty_print=True, encoding='utf-8', xml_declaration=True) + # }}} class TopLevel(Feed): # {{{ @@ -126,9 +196,9 @@ class TopLevel(Feed): # {{{ self.root.append(x) # }}} -class AcquisitionFeed(Feed): +class NavFeed(Feed): - def __init__(self, updated, id_, items, offsets, page_url, up_url, version): + def __init__(self, id_, updated, version, offsets, page_url, up_url): kwargs = {'up_link': up_url} kwargs['first_link'] = page_url kwargs['last_link'] = page_url+'?offset=%d'%offsets.last_offset @@ -140,7 +210,14 @@ class AcquisitionFeed(Feed): page_url+'?offset=%d'%offsets.next_offset Feed.__init__(self, id_, updated, version, **kwargs) -STANZA_FORMATS = frozenset(['epub', 'pdb']) +class AcquisitionFeed(NavFeed): + + def __init__(self, updated, id_, items, offsets, page_url, up_url, version, + FM): + NavFeed.__init__(self, id_, updated, version, offsets, page_url, up_url) + for item in items: + self.root.append(ACQUISITION_ENTRY(item, version, FM, updated)) + class OPDSOffsets(object): @@ -176,9 +253,10 @@ class OPDSServer(object): self.opds_search, version=version) def get_opds_allowed_ids_for_version(self, version): - search = '' if version > 0 else ' '.join(['format:='+x for x in + search = '' if version > 0 else ' or '.join(['format:='+x for x in STANZA_FORMATS]) - self.search_cache(search) + ids = self.search_cache(search) + return ids def get_opds_acquisition_feed(self, ids, offset, page_url, up_url, id_, sort_by='title', ascending=True, version=0): @@ -189,7 +267,8 @@ class OPDSServer(object): max_items = self.opts.max_opds_items offsets = OPDSOffsets(offset, max_items, len(items)) items = items[offsets.offset:offsets.next_offset] - return str(AcquisitionFeed(self.db.last_modified(), id_, items, offsets, page_url, up_url, version)) + return str(AcquisitionFeed(self.db.last_modified(), id_, items, offsets, + page_url, up_url, version, self.db.FIELD_MAP)) def opds_search(self, query=None, version=0, offset=0): try: @@ -203,8 +282,9 @@ class OPDSServer(object): ids = self.search_cache(query) except: raise cherrypy.HTTPError(404, 'Search: %r not understood'%query) - return self.get_opds_acquisition_feed(ids, - sort_by='title', version=version) + return self.get_opds_acquisition_feed(ids, offset, '/search/'+query, + BASE_HREFS[version], 'calibre-search:'+query, + version=version) def opds_navcatalog(self, which=None, version=0): version = int(version) From 578c468923f37f9c134e6e04b416cbfa7c8c7c6d Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Tue, 25 May 2010 18:45:05 -0600 Subject: [PATCH 220/324] New calibre icon --- icons/library.icns | Bin 60876 -> 130467 bytes icons/library.ico | Bin 16958 -> 161418 bytes resources/images/library.png | Bin 126719 -> 229327 bytes 3 files changed, 0 insertions(+), 0 deletions(-) diff --git a/icons/library.icns b/icons/library.icns index 412b2a3d2189a89ec5c6a2350347aba94d4b9991..39813eb8e6b83a1ba84bc77bd961086489a0d075 100644 GIT binary patch literal 130467 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 z z2;;Cqy7@^$mM=0@AMa!jD8Qf8^O#qTf|M8z(Uf@v6O~Kk0>!0~PTAWpIaA>zc7JvC zCdoe!@m+M-d&I6a;y2!GY=xPN#4}p>e=M3?nF#_f^%WeP%Te%Frlzd9;mAJe3)hwk ztk3uPafp6cbYVcfk&($*-=UCV)s2Bnjm8&K1CZb8( zb#6yga)6n=OtF``#H__(;`>Ipr{o+4n035C=u}ZtZnn53HsuvWKW!?I_Y6p*5$$Qe za`qcSg%WD(`yLs)*CHmE*Y^o+2b^uDEA1bnI7ytw=|@wr%6pKd@V1#fve#rLAlEVZ!#q{N+z*7!SP^G<=AdhsKJL^7y1VS5!fd_VHjD%)*V zWLEF?frQ<2>LsHQWGwJ1V=Exv_H4Ol5zeKPMBY8B6Ah>Cal;b10vULQ!kF#FX)G@p zPY>}Mq3*sGs>fs<>F2MRyfLqT$iB>S7s%)Sm|G*`HHGvS8r3=i1>O4#r_xECfHd zoER2w_~tk)%|+|k>L{V9$~{rP8zTiEn2WgTy%`kBE?Cy!NQ!Xp0)S!8a(WB|<|JFr z0?6q{QX!!zB#{#!!;QSLhtSYUidG;Y6)6tdmt4<`}J0!ZCC48J~{}T-j zbfj$|QyNmQ!uZ=M1)fb{!t&S;wU@S=7x}u|fNq%TiR#`p`mkSKI?p3*=u$ph{4Y6R zMn^`5f2N2s_C+dPpZcofRfyE;hA4NuAs*gt;XpNegN6p}T4hWfWF>qnQ&h%Y4wHj; z_?D3QFF)MOOB}mx27b^m`h&`spnKmW=bXqUMwU8Sjem%D#2AcfbEDlw*mVUCT~08X zC3TAflLdnOx|TxIgltx*WFZ2oldRVz>04W-9Chy$B#(kUG7?7rS_g_y3zsvZaq)#M*=N@j9t^|rvkb;zwseNlB85<|Z=3`E51U`l=L=^l` z#7cRRVQxdN0Pdya+Klk@C{ievnCdjXddM&t$rw^0IhsSE&An*mjb_}t%exy}Jkhie zE+unMw;MH1b2i_j623qYQugO|CvK8ad3(tgGWO~Uzm@6$J2!YrjeaRtOI{!??V{-| zU$D{nYX^Fl@Gi(J8qDkX*t*-MlfmfC`p6;i{T?YEqK_;LOAA$GunMi zux;B_3^_PTI?y9|GTO|sXLu%_i;pUqq|dqFSPV zvT&fS??;JqrJ^|7Zt;0(f55*6^nox_>%IlHg9G~^yn)l3?Q3-MX$MC#e@YwQiw4iN zcQ)a55a^8xJt-aji2onBNt12yG2#up^rZa&A^Hzc3kl*V>C%>rlGL{kmkLgo7SyU5 zHx4;^Xc3JqQHc&u+vMh~eRoG>dwsXeMwBts@(13 zl#-#S1qfdq7Ia>{_-GqRF~UiXNve9%KR+6Bpn$Q1PyOMi%kNZpE|mi%kTzRfxIpKD zo=|Ou!){S%06X_M?DV+g?yhoa1DLq!z#UFMRhfRUgVBuI-Js#FrpFVCF zwE6|*xNNH?&}cy_C5CB`M~fWWe9^a-*R=Bq7KX);P0PG#+0f@s1v%NiORR8Ds_Px?_&P4)a(i1yA#H?@-X5zB86gmj8}e-J zvS$=wtL3w{t;Z@^!DW!Ewz4sQZvyZ=l|w0{-V}#*J|TrdN?qqs0g%9=JBM>km7H(b zm9X%|g?gs~=#ajf!v7?6>`<2}f!|(Z@k6G@-i$I%0Vw>5!J3x7x-`v>98BlYQeir; z!?&RFNl=oyqq{&7(DDhpKq)cH`T}1b7$-5R;)oubE;YHNY%^(9Oie;lUQiU-dt0j^ zn_!wyzm^8`1)MMR#RLxtVQV0zNT3dZ^VGI~#g$=PH$X2xZQ+xTZz!*{9q3Xx(4t$^ zWN`CiPw8NXY}dHsU5oIKJT?xk+zoDDE03y_5zn8W2@DXdHzBzfnLS%It+NZq>=w_L zTNaWIVw0q-H&StdG=iX|Enz5Sq6SU#13^U&7YxJDO0UM+UEZ9LQk3NJAAZd231^}H zygZ5TAVe1T;y+Ct>=4;C`95h|lGw^76=70wkRQ)?dD@LuAsxdaJ^)BAxxGs;nsR7U zX<+4Tp&m?bPKw0@WhHYW0`GQ)0|dLBis$#bNWWtzrm(w}xHCGK5_=>NCjRcx31tToR(~P!7%kOZqln@anILsSX7FRuN{iCl<|yea2%==J9HrnMR}E)*~IZ z(#5I&AWAG!6k3(y@m|^3Whpl+#@8p2JsBBL#-jqmQvDX5~ zJupGXya`&ZIFw`)jdPqr>*Jk8@4+^#GRRtvf5-xa#7Y?LdXmW#^IF0dqfj`qu)JcP z$GzY3*BNKBK=@YiDpDM zG06R=#{!SIv0+J;$@o#0QcskShOgT9s(B2~Nlp=YxF7);?Ry1bAJ?PQk>X+M!x z!^t#)yMUoh$r@}*SJf#{P~}T#CfxGe_45g{D%lwqGyy{}2(D+JqtU%VRdK9EtVIWHuFNK4E|sO6sCe6Ty=7=A8o?7DtgJAGo*MI zK$){bev_MX^x{r9LEBhXM_s`o*V}$+j-#7dYQL4ek z&rF4Y0ie<-U!wm_C7<{2S8&wo%k^5W0CtK4eUELo4DYR-sPbEHbiBa`0EOqW8AoVL zCjT)aB^H>wh#i`zwu~1;YD|=(M`DH)|9qc{!(cAb$ldsZ3%JAfx>4Qo8J~)ixB4lD z4i;Tz%eQcCn|ABqN2e#j!mX_4RbzN~XFe4X*yk+!`WejBlybA^!uHyD64GCHC`>g& zT^uhqvIMCwLAjhBIHrKUr&6V;>0l^Y@#a!_-SChk*%J@Q8yoxS94LPQ zQ=~V;qb?IgC@FrLw?2;P_oV|C`p9QRiEtx$lbMThJic?YfaKWoQd&HJ2-G;k8D)qP zFfZEc`{c^~Y&Gl@&*pwGEAIf?1OEoN&LA`FAlOBUP7euvjbP@uou`o?P3Jn#q7e_< z53H!Wrz_3!=L{~Ax;u$7`%{G~fkx5Oq?=FvM9kX8qEwp3a*|5*L!hbR~L`K^(3cRh~J{ z3=nU%fuY34+MawVKSdco*CsD3=(0K*WRgubfu(4ZR zN4_x+J23&&4;>Cd(I=1AH}dfVq|oXAH;Bn1#+sn!ylY^!_NMtXLd!qNkL3-U)E!6f zf-ihvGR|jjaJ6O?02#}eoXUUSby`S+ymM~%(2cdY&cmi9HIQK(6pZbT^f#V09-cvb zjIcv$5k^F}Bk6<7V$tiu=nv7KTh_&LhrPJln;es)Px$0cHQW%v?C9vSQI=E{a`sLH z$7VcYD!Ga>&$HR_(*t4Y1yWKj&g;yK#h$=VnrPHv@_{|>T^yD6zz9<-U`?^~s@}2i zccLk(4tju@|2NnFUaW8R^!!i;-$ektGJmw!ScN|XP+A9=i0-FHLr(Oq|9_w(JV*FP z@i)KJ>W5JuAE2O1AFaN|fAnMlRpL7(%b*`pKCR#nt7Rd_fBS&i2trvux|?Vk9KhVE z)@DIUqIVg@GC*LD?(p>LQI4w5rw#yVYY#Wg++G*0KXdK$zwY%!-5QO)Rt~g!i>dSf zGU@LMqov1|wP5gG@em$GfxV!tb_=r{c3u0wgrIg1q3?%zdFVrwgt-<;hf zl%$vWHy+~-$lgAOTV5Jf!oXa~n}({zBgIbBlZGgFg*v`oqycG*2~#+;TW_MFX&|+U z_gwKW1mJQ+ch>T=V6z9#dnZ9I3dg(!~=2h`@RQ-a!wncm7kF6{@}U1-9I@ zbO)&1CwKTnv`MLyXlqA^H$D#qK!#*;*BNQh8rt7joiQH`eAu?(F>5c{;qaIaQw3<^x7JZQldmYXNh z7l!xPH$zcAQ;&Bko4&)F#ea4H zuz+Stfcy4EU5iU8y+*^ft=tZsWeQnI82jg7?`x&kyWu8_s5ru&T`QeK?K0IaaoRN- zeJ*s_OtmrQoJLWx%G7@+rEz4HZUg28*3NHn>S612TEi;Q0}{n>4!{341>MQsw#F+V zt7_blV-cg_@2x68mCgD4-*_LnKxcZR8EaErxwpx z4udp6l1_WUPDzC6@=6E3L+v}InaXiirazKkLbo-PX%8dtH-$PAdtwbNi6rA0^c<~} zvfl+p02`-AcpGm_Z6j;}R;_ZhE0m$ixlOq6s!PzV!!XYh4J3587>)tL6_P|@B8^o* zB3eQ>=7NJ8F=Rp(`$6}SR_}MVW_J|IJvCD21;2`>;@F#U5_CMqmu}O2`>MtQ%#eMI zO#510vA=A!8)U|~ML1Ck%lU}REfN^<75Cws8>C<+t|Xi`$oB65oz1*M8HvwFfX8s= zqA2;PEVocp5Hic){aii5dMx=py}yQ+l~UD=NOZtJ(M1G!KeMcbQ#YV-K> zehY=GinPUBC}45M z{^59ZUS#noX^pEPb_@^!jWwRZUpOVr#;F=)H2NaACbzlmz|Q`$4fVo`57mrZ z?tg=Rn}i)n{7E+rfCJMPo}nnG-Ox&HbXF?9-u^vPv4{dXzq9e*x>(RT;mwYzc}NUn zeUWm8;0j-M=^zFaP1d93px*iP$?^?W6Hwtrrj>ItI*U1TV&fD_cM9n#)|}sFnx}N< zZl0dY%Ej@Aol_TeLrl5X>&|rWzyf8%+Epa-yE5bVJo*>Iy28G&F4z_p*l+^t25+X5 z(srt%1EZfh#@)Awg8c3|e2e0q9$G9^2yZ9naXgxTw($5{?zxV}<-V#M@Fi{)8V>vY zXAG@Z0k%6l$3JgS7&7wZdw&{Adi(py)#+}`nMFSM=WO4rm?d&BMo5_Z;b(;tPX^KV z?phU>5;qrr>_}Y0QQ-L%TX13-skCI;qFN9JiAzv2*mT{QeA^oteh2%JmHjX?UvHex z(an86;atAxt;fPgB9cYVTjO}k(j~(CEmT?Qq~+0sjsV{lwf^ zIXYcjV=WnKW^grS4=uTTc{JV`Vi6}RC|TCK#KjVd<=35=Pjn|A@x$cnv=(JxEBR8G zOOCcKpu7?KVVH~`sW)t!c9Myp<$HkHH;{oky4ZC;Fy?-Sb5I`KHCXfe`qDLx(zMeO z?U&k5TdGd$Cn3$cv_eWVG8Ehw!o{LBxtX9;K|}PBrPC1b+lDw5IIj`LLe)lRJ6Sgo zYb~Y`qmC-aKZ=N!xgLc5*2LaWhDmh6Jw(Q?Ll4q#2nz$%X%vM&|6dj=^0kk6h7de# z<`4CR2DQHdj&n)uZbO~N23Iott~4ujHN~jr(r0Y&D^ZpvJb4rETa2@)uui&TzI%@H z=!ZWA0#C|9aro-5gKNvrORzSh0+c>+Xo~lUHw})NGI`(q6!8OV0DBz6G^emK<_f*& zSi-d->;!p4EvePMOy^PdbX&Cq>C?{Ou7g4o67PchMK7)U4ok)5j!>$Od(8-5wYfe@ z8mJ~24PG7`d(5NUNGI!&KjNb!Hruyas;$KVR(ZpO=V@A(Le}Nz7c){1m=|Ue;i@3I z2m+U_^<%v&ZznESl`s7A4sSo%l*Ad0pTVfly0=Q*Jod_ zzyA3cgh_n9oZPT)kil|4v-e_J8oWi%JZ3ENa+dNgv3r*wtLiqH*edRB9LfQsPtx6d z%qwt0sju6dl51D($L*G`QHTP#w*>?0U8Z?{9z9?Oh;DqeNiuS=h$ ziwo+=+;KEw3^)i|vXlZ?{zUJ1=$*|aE?A11ez%!i79!y)bP;!O^kxm*-$vF5bbEE1dY;)2~U-gB0ky`caW!3 zh9z>!Hk`+AYsw8SIYpql=KQ^w=Ak_Fbz5~h50>x_6P;aVe#V-YgY&^8`%xkywRpmm zONv3WfIra5f|gAJG^t-O@V*Z^ug)Rqodi~lhiU}Cx|7hJK&(Vr1|GT z1}}CI2*2$poC<5ooDicCm|cgHnc;cx3@P7@MH7<0c*C;6-le)k>seli<`F>^nJSIz zaYr6wGls>nn8kRMir;$GgRazzsup=bU7oTVD>3%dr|-*Yb^pz1=g^2#wfEb z$19^?h-1qhN?l!}l2D!nzkQZNkI|Df=96^bV3>l`q`$)AA^ue{C|=BNf%ncJe?3Hk zTkstJb6>{WYC)zNj`nnC@VZm-!n$~!4iq$;g*|KYQvWUWsJ-}Pt*HQDiV6VdjzYOv zk9j;t(JGR+DB2TD+nTK0!h>D@b!_|z0;%q|W9uf^ONL=T+aCLd5&@h9UX@}DUpc3q z##w_Grh4E_hrB8zHc&gz?~Z6d=KXnsUaGwxZGDX(jHBSU(ZUP-R*6@IWAhSltxdw) zp};4a85k0?|5YgULsQIGs4^k!?`%LJXlsWGwH7T5}NbbEXu zFVU3R(LVFv+84xU*-p}mjI5lwQe(px>49CbmA|O*N&NY~PA|B5^ZHQAYcx!vxs03h zSx&Z@M{z_|4DpoGpMM}-eP;kVbBi(GNOBF)h|qB@Cv2XZq^R7W>~$}eI<15Omn+`c z<&UX(PMQrvS*cix}qk59Sy51h*8}rY@ zkG!`}?NeSHCt}bQt`HcRae09`Vhp*LNrZ6SFSfh6571e4i~SAUgNKii_o@xwJdx^t1Z-PLXbnU;o)@V{TRe000cXfB*naiv)iF literal 60876 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 BCkhwd}oZeWm&ZIrzWFkUCY* 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 HB{Ts5R49cS literal 16958 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

hOyZ2gHRJGx6d-7# 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ÏV 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 LlqGA$jf4LSm<*CN From 53fb1ef4621cf1a6a88ce15b34b2d49d1210ab81 Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Wed, 26 May 2010 12:19:53 +0100 Subject: [PATCH 221/324] First iteration of refactoring tag category and search term management --- src/calibre/ebooks/metadata/book/__init__.py | 11 -- src/calibre/gui2/tag_view.py | 12 +- src/calibre/library/caches.py | 39 ++-- src/calibre/library/custom_columns.py | 9 +- src/calibre/library/database2.py | 79 +++----- src/calibre/library/tag_categories.py | 194 +++++++++++++++++++ src/calibre/utils/search_query_parser.py | 29 +-- 7 files changed, 254 insertions(+), 119 deletions(-) create mode 100644 src/calibre/library/tag_categories.py diff --git a/src/calibre/ebooks/metadata/book/__init__.py b/src/calibre/ebooks/metadata/book/__init__.py index 39fb1920cd..23fed3171a 100644 --- a/src/calibre/ebooks/metadata/book/__init__.py +++ b/src/calibre/ebooks/metadata/book/__init__.py @@ -89,17 +89,6 @@ CALIBRE_METADATA_FIELDS = frozenset([ ) CALIBRE_RESERVED_LABELS = frozenset([ - 'all', # search term - 'date', # search term - 'formats', # search term - 'inlibrary', # search term - 'news', # search term - 'ondevice', # search term - 'search', # search term - 'format', # The next four are here for backwards compatibility - 'tag', # with searching. The terms can be used without the - 'author', # trailing 's'. - 'comment', # Sigh ... ] ) diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py index 8bbdc69c62..f2729da480 100644 --- a/src/calibre/gui2/tag_view.py +++ b/src/calibre/gui2/tag_view.py @@ -14,7 +14,7 @@ from PyQt4.Qt import Qt, QTreeView, QApplication, pyqtSignal, \ QAbstractItemModel, QVariant, QModelIndex from calibre.gui2 import config, NONE from calibre.utils.config import prefs -from calibre.ebooks.metadata.book import RESERVED_METADATA_FIELDS +from calibre.library.tag_categories import TagsIcons class TagsView(QTreeView): # {{{ @@ -205,7 +205,7 @@ class TagsModel(QAbstractItemModel): # {{{ # must do this here because 'QPixmap: Must construct a QApplication # before a QPaintDevice'. The ':' in front avoids polluting either the # user-defined categories (':' at end) or columns namespaces (no ':'). - self.category_icon_map = { + self.category_icon_map = TagsIcons({ 'authors' : QIcon(I('user_profile.svg')), 'series' : QIcon(I('series.svg')), 'formats' : QIcon(I('book.svg')), @@ -215,10 +215,8 @@ class TagsModel(QAbstractItemModel): # {{{ 'tags' : QIcon(I('tags.svg')), ':custom' : QIcon(I('column.svg')), ':user' : QIcon(I('drawer.svg')), - 'search' : QIcon(I('search.svg'))} - for k in self.category_icon_map.keys(): - if not k.startswith(':') and k not in RESERVED_METADATA_FIELDS: - raise ValueError('Tag category [%s] is not a reserved word.' %(k)) + 'search' : QIcon(I('search.svg'))}) + self.icon_state_map = [None, QIcon(I('plus.svg')), QIcon(I('minus.svg'))] self.db = db self.search_restriction = '' @@ -247,7 +245,7 @@ class TagsModel(QAbstractItemModel): # {{{ data = self.db.get_categories(sort_on_count=sort, icon_map=self.category_icon_map) tb_categories = self.db.get_tag_browser_categories() - for category in tb_categories.iterkeys(): + for category in tb_categories: if category in data: # They should always be there, but ... self.row_map.append(category) self.categories.append(tb_categories[category]['name']) diff --git a/src/calibre/library/caches.py b/src/calibre/library/caches.py index 10487af75a..9e140b4125 100644 --- a/src/calibre/library/caches.py +++ b/src/calibre/library/caches.py @@ -17,6 +17,7 @@ from calibre.utils.config import tweaks from calibre.utils.date import parse_date, now, UNDEFINED_DATE from calibre.utils.search_query_parser import SearchQueryParser from calibre.utils.pyparsing import ParseException +from calibre.library.tag_categories import TagsMetadata class CoverCache(QThread): @@ -149,15 +150,15 @@ class ResultCache(SearchQueryParser): ''' Stores sorted and filtered metadata in memory. ''' - def __init__(self, FIELD_MAP, cc_label_map): + def __init__(self, FIELD_MAP, cc_label_map, tag_browser_categories): 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, - locations=SearchQueryParser.DEFAULT_LOCATIONS + - [c for c in cc_label_map]) + self.tag_browser_categories = tag_browser_categories + self.all_search_locations = tag_browser_categories.get_search_labels() + SearchQueryParser.__init__(self, self.all_search_locations) self.build_date_relop_dict() self.build_numeric_relop_dict() @@ -379,25 +380,33 @@ class ResultCache(SearchQueryParser): if location in ('tag', 'author', 'format', 'comment'): location += 's' - all = ('title', 'authors', 'publisher', 'tags', 'comments', 'series', - 'formats', 'isbn', 'rating', 'cover', 'ondevice') +# all = ('title', 'authors', 'publisher', 'tags', 'comments', 'series', +# 'formats', 'isbn', 'rating', 'cover', 'ondevice') MAP = {} - for x in all: # get the db columns for the standard searchables - MAP[x] = self.FIELD_MAP[x] + # get the db columns for the standard searchables + for x in self.tag_browser_categories: + if (len(self.tag_browser_categories[x]['search_labels']) and \ + self.tag_browser_categories[x]['kind'] in ['standard', 'not_cat']): +# self.tag_browser_categories[x]['kind'] == 'standard') \ +# or self.tag_browser_categories[x]['kind'] == 'not_cat': + MAP[x] = self.FIELD_MAP[self.tag_browser_categories.get_label(x)] + IS_CUSTOM = [] - for x in range(len(self.FIELD_MAP)): # build a list containing '' the size of FIELD_MAP + for x in range(len(self.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']] - IS_CUSTOM[MAP[x]] = self.custom_column_label_map[x]['datatype'] + + # add custom columns to MAP. Put the column's type into IS_CUSTOM + for x in self.tag_browser_categories.get_custom_fields(): + if self.tag_browser_categories[x]['datatype'] != "datetime": + MAP[x] = self.FIELD_MAP[self.tag_browser_categories[x]['colnum']] + IS_CUSTOM[MAP[x]] = self.tag_browser_categories[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']: + for x in self.tag_browser_categories.get_custom_fields(): + if self.tag_browser_categories[x]['is_multiple']: SPLITABLE_FIELDS.append(MAP[x]) try: diff --git a/src/calibre/library/custom_columns.py b/src/calibre/library/custom_columns.py index 36ea49763e..cc7ee7ba7f 100644 --- a/src/calibre/library/custom_columns.py +++ b/src/calibre/library/custom_columns.py @@ -145,11 +145,10 @@ class CustomColumns(object): v = self.custom_column_label_map[k] if v['normalized']: 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'] - } + self.tag_browser_categories.add_custom_field( + field_name = v['label'], table = tn, column='value', + datatype=v['datatype'], is_multiple=v['is_multiple'], + number=v['num'], 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 84124b6ce9..eb27ff8bfb 100644 --- a/src/calibre/library/database2.py +++ b/src/calibre/library/database2.py @@ -20,6 +20,7 @@ from PyQt4.QtGui import QImage from calibre.ebooks.metadata import title_sort from calibre.library.database import LibraryDatabase +from calibre.library.tag_categories import TagsMetadata, TagsIcons from calibre.library.schema_upgrades import SchemaUpgrade from calibre.library.caches import ResultCache from calibre.library.custom_columns import CustomColumns @@ -33,11 +34,10 @@ 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 -from calibre.ebooks.metadata.book import RESERVED_METADATA_FIELDS + if iswindows: import calibre.utils.winshell as winshell @@ -116,6 +116,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): self.books_list_filter = self.conn.create_dynamic_filter('books_list_filter') def __init__(self, library_path, row_factory=False): + self.tag_browser_categories = TagsMetadata() #.get_tag_browser_categories() if not os.path.exists(library_path): os.makedirs(library_path) self.listeners = set([]) @@ -127,36 +128,6 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): if isinstance(self.dbpath, unicode): self.dbpath = self.dbpath.encode(filesystem_encoding) - # Order as has been customary in the tags pane. - 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')}), - ('publisher', {'table':'publishers', 'column':'name', - 'type':'text', 'is_multiple':False, - 'kind':'standard', 'name':_('Publishers')}), - ('rating', {'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: - if k not in RESERVED_METADATA_FIELDS: - raise ValueError('Tag category [%s] is not a reserved word.' %(k)) - self.tag_browser_categories[k] = v - self.connect() self.is_case_sensitive = not iswindows and not isosx and \ not os.path.exists(self.dbpath.replace('metadata.db', 'MeTAdAtA.dB')) @@ -251,7 +222,8 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): self.conn.commit() self.book_on_device_func = None - self.data = ResultCache(self.FIELD_MAP, self.custom_column_label_map) + self.data = ResultCache(self.FIELD_MAP, self.custom_column_label_map, + self.tag_browser_categories) self.search = self.data.search self.refresh = functools.partial(self.data.refresh, self) self.sort = self.data.sort @@ -671,14 +643,20 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): self.books_list_filter.change([] if not ids else ids) categories = {} + if icon_map is not None and type(icon_map) != TagsIcons: + raise TypeError('icon_map passed to get_categories must be of type TagIcons') #### First, build the standard and custom-column categories #### - for category in self.tag_browser_categories.keys(): - tn = self.tag_browser_categories[category]['table'] + tb_cats = self.tag_browser_categories + for category in tb_cats.keys(): + cat = tb_cats[category] + if cat['kind'] == 'not_cat': + continue + tn = cat['table'] 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'] + cn = cat['column'] if ids is None: query = 'SELECT id, {0}, count FROM tag_browser_{1}'.format(cn, tn) else: @@ -692,16 +670,17 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): # icon_map is not None if get_categories is to store an icon and # possibly a tooltip in the tag structure. icon, tooltip = None, '' + label = tb_cats.get_label(category) if icon_map: - if self.tag_browser_categories[category]['kind'] == 'standard': + if cat['kind'] == 'standard': if category in icon_map: - icon = icon_map[category] - elif self.tag_browser_categories[category]['kind'] == 'custom': + icon = icon_map[label] + elif cat['kind'] == 'custom': icon = icon_map[':custom'] icon_map[category] = icon - tooltip = self.custom_column_label_map[category]['name'] + tooltip = self.custom_column_label_map[label]['name'] - datatype = self.tag_browser_categories[category]['type'] + datatype = cat['datatype'] if datatype == 'rating': item_not_zero_func = (lambda x: x[1] > 0 and x[2] > 0) formatter = (lambda x:u'\u2605'*int(round(x/2.))) @@ -711,7 +690,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): formatter = (lambda x: x.replace('|', ',')) else: item_not_zero_func = (lambda x: x[2] > 0) - formatter = (lambda x:x) + formatter = (lambda x:unicode(x)) categories[category] = [Tag(formatter(r[1]), count=r[2], id=r[0], icon=icon, tooltip = tooltip) @@ -750,9 +729,9 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): # 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] + for k in tb_cats.keys(): + if tb_cats[k]['kind'] in ['user', 'search']: + del tb_cats[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 @@ -771,10 +750,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): # 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} + tb_cats.add_user_category(field_name=cat_name, 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'] @@ -793,10 +769,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): 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')} + tb_cats.add_user_category(field_name='search', name=_('Searches')) if icon_map is not None: icon_map['search'] = icon_map['search'] categories['search'] = items diff --git a/src/calibre/library/tag_categories.py b/src/calibre/library/tag_categories.py new file mode 100644 index 0000000000..8cd47c44fb --- /dev/null +++ b/src/calibre/library/tag_categories.py @@ -0,0 +1,194 @@ +''' +Created on 25 May 2010 + +@author: charles +''' + +from UserDict import DictMixin +from calibre.utils.ordered_dict import OrderedDict + +class TagsIcons(dict): + ''' + If the client wants icons to be in the tag structure, this class must be + instantiated and filled in with real icons. If this class is instantiated + and passed to get_categories, All items must be given a value not None + ''' + + category_icons = ['authors', 'series', 'formats', 'publisher', 'rating', + 'news', 'tags', ':custom', ':user', 'search',] + def __init__(self, icon_dict): + for a in self.category_icons: + if a not in icon_dict: + raise ValueError('Missing category icon [%s]'%a) + self[a] = icon_dict[a] + +class TagsMetadata(dict, DictMixin): + + # kind == standard: is tag category. May be a search label. Is db col + # or is specially handled (e.g., news) + # kind == not_cat: Is not a tag category. Should be a search label. Is db col + # kind == user: user-defined tag category + # kind == search: saved-searches category + # Order as has been customary in the tags pane. + category_items_ = [ + ('authors', {'table':'authors', 'column':'name', + 'datatype':'text', 'is_multiple':False, + 'kind':'standard', 'name':_('Authors'), + 'search_labels':['authors', 'author']}), + ('series', {'table':'series', 'column':'name', + 'datatype':None, 'is_multiple':False, + 'kind':'standard', 'name':_('Series'), + 'search_labels':['series']}), + ('formats', {'table':None, 'column':None, + 'datatype':None, 'is_multiple':False, + 'kind':'standard', 'name':_('Formats'), + 'search_labels':['formats', 'format']}), + ('publisher', {'table':'publishers', 'column':'name', + 'datatype':'text', 'is_multiple':False, + 'kind':'standard', 'name':_('Publishers'), + 'search_labels':['publisher']}), + ('rating', {'table':'ratings', 'column':'rating', + 'datatype':'rating', 'is_multiple':False, + 'kind':'standard', 'name':_('Ratings'), + 'search_labels':['rating']}), + ('news', {'table':'news', 'column':'name', + 'datatype':None, 'is_multiple':False, + 'kind':'standard', 'name':_('News'), + 'search_labels':[]}), + ('tags', {'table':'tags', 'column':'name', + 'datatype':'text', 'is_multiple':True, + 'kind':'standard', 'name':_('Tags'), + 'search_labels':['tags', 'tag']}), + ('comments', {'table':None, 'column':None, + 'datatype':None, 'is_multiple':False, + 'kind':'not_cat', 'name':None, + 'search_labels':['comments', 'comment']}), + ('cover', {'table':None, 'column':None, + 'datatype':None, 'is_multiple':False, + 'kind':'not_cat', 'name':None, + 'search_labels':['cover']}), + ('isbn', {'table':None, 'column':None, + 'datatype':None, 'is_multiple':False, + 'kind':'not_cat', 'name':None, + 'search_labels':['isbn']}), + ('pubdate', {'table':None, 'column':None, + 'datatype':None, 'is_multiple':False, + 'kind':'not_cat', 'name':None, + 'search_labels':['pubdate']}), + ('title', {'table':None, 'column':None, + 'datatype':None, 'is_multiple':False, + 'kind':'not_cat', 'name':None, + 'search_labels':['title']}), + ] + + # search labels that are not db columns + search_items = [ 'all', + 'date', + 'search', + ] + + def __init__(self): + self.tb_cats_ = OrderedDict() + for k,v in self.category_items_: + self.tb_cats_[k] = v + + def __getattr__(self, name): + if name in self.tb_cats_: + return self.tb_cats_[name] + return None + +# def __setattr__(self, name, val): +# dict.__setattr__(self, name, val) + + def __getitem__(self, key): + return self.tb_cats_[key] + +# def __setitem__(self, key, val): +# print 'setitem', key, val +# self.tb_cats_[key] = val + + def __delitem__(self, key): + del self.tb_cats_[key] + + def __iter__(self): + for key in self.tb_cats_: + yield key + + def keys(self): + return self.tb_cats_.keys() + + def iterkeys(self): + for key in self.tb_cats_: + yield key + + def iteritems(self): + for key in self.tb_cats_: + yield (key, self.tb_cats_[key]) + + def get_label(self, key): + if 'label' not in self.tb_cats_[key]: + return key + return self.tb_cats_[key]['label'] + + def get_custom_fields(self): + return [l for l in self.tb_cats_ if self.tb_cats_[l]['kind'] == 'custom'] + + def add_custom_field(self, field_name, table, column, datatype, is_multiple, number, name): + fn = '#' + field_name + if fn in self.tb_cats_: + raise ValueError('Duplicate custom field [%s]'%(field_name)) + self.tb_cats_[fn] = {'table':table, 'column':column, + 'datatype':datatype, 'is_multiple':is_multiple, + 'kind':'custom', 'name':name, + 'search_labels':[fn],'label':field_name, + 'colnum':number} + + def add_user_category(self, field_name, name): + if field_name in self.tb_cats_: + raise ValueError('Duplicate user field [%s]'%(field_name)) + self.tb_cats_[field_name] = {'table':None, 'column':None, + 'datatype':None, 'is_multiple':False, + 'kind':'user', 'name':name, + 'search_labels':[]} + + def add_search_category(self, field_name, name): + if field_name in self.tb_cats_: + raise ValueError('Duplicate user field [%s]'%(field_name)) + self.tb_cats_[field_name] = {'table':None, 'column':None, + 'datatype':None, 'is_multiple':False, + 'kind':'search', 'name':name, + 'search_labels':[]} + +# DEFAULT_LOCATIONS = frozenset([ +# 'all', +# 'author', # compatibility +# 'authors', +# 'comment', # compatibility +# 'comments', +# 'cover', +# 'date', +# 'format', # compatibility +# 'formats', +# 'isbn', +# 'ondevice', +# 'pubdate', +# 'publisher', +# 'search', +# 'series', +# 'rating', +# 'tag', # compatibility +# 'tags', +# 'title', +# ]) + + + def get_search_labels(self): + s_labels = [] + for v in self.tb_cats_.itervalues(): + map((lambda x:s_labels.append(x)), v['search_labels']) + for v in self.search_items: + s_labels.append(v) +# if set(s_labels) != self.DEFAULT_LOCATIONS: +# print 'search labels and default_locations do not match:' +# print set(s_labels) ^ self.DEFAULT_LOCATIONS + return s_labels diff --git a/src/calibre/utils/search_query_parser.py b/src/calibre/utils/search_query_parser.py index 5fe0a242f8..509adb49d4 100644 --- a/src/calibre/utils/search_query_parser.py +++ b/src/calibre/utils/search_query_parser.py @@ -22,7 +22,6 @@ from calibre.utils.pyparsing import Keyword, Group, Forward, CharsNotIn, Suppres OneOrMore, oneOf, CaselessLiteral, Optional, NoMatch, ParseException from calibre.constants import preferred_encoding from calibre.utils.config import prefs -from calibre.ebooks.metadata.book import RESERVED_METADATA_FIELDS ''' This class manages access to the preference holding the saved search queries. @@ -86,27 +85,6 @@ class SearchQueryParser(object): * `(author:Asimov or author:Hardy) and not tag:read` [search for unread books by Asimov or Hardy] ''' - DEFAULT_LOCATIONS = [ - 'all', - 'author', # compatibility - 'authors', - 'comment', # compatibility - 'comments', - 'cover', - 'date', - 'format', # compatibility - 'formats', - 'isbn', - 'ondevice', - 'pubdate', - 'publisher', - 'search', - 'series', - 'rating', - 'tag', # compatibility - 'tags', - 'title', - ] @staticmethod def run_tests(parser, result, tests): @@ -121,12 +99,7 @@ class SearchQueryParser(object): failed.append(test[0]) return failed - def __init__(self, locations=None, test=False): - for k in self.DEFAULT_LOCATIONS: - if k not in RESERVED_METADATA_FIELDS: - raise ValueError('Search location [%s] is not a reserved word.' %(k)) - if locations is None: - locations = self.DEFAULT_LOCATIONS + def __init__(self, locations, test=False): self._tests_failed = False # Define a token standard_locations = map(lambda x : CaselessLiteral(x)+Suppress(':'), From fb13f2e7f4a058d574ebe7c1013a73657d9babe4 Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Wed, 26 May 2010 12:30:41 +0100 Subject: [PATCH 222/324] Remove some commented-out code --- src/calibre/library/caches.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/calibre/library/caches.py b/src/calibre/library/caches.py index 9e140b4125..9005686fd2 100644 --- a/src/calibre/library/caches.py +++ b/src/calibre/library/caches.py @@ -380,29 +380,26 @@ class ResultCache(SearchQueryParser): if location in ('tag', 'author', 'format', 'comment'): location += 's' -# all = ('title', 'authors', 'publisher', 'tags', 'comments', 'series', -# 'formats', 'isbn', 'rating', 'cover', 'ondevice') MAP = {} # get the db columns for the standard searchables for x in self.tag_browser_categories: if (len(self.tag_browser_categories[x]['search_labels']) and \ self.tag_browser_categories[x]['kind'] in ['standard', 'not_cat']): -# self.tag_browser_categories[x]['kind'] == 'standard') \ -# or self.tag_browser_categories[x]['kind'] == 'not_cat': MAP[x] = self.FIELD_MAP[self.tag_browser_categories.get_label(x)] + # add custom columns to MAP. Put the column's type into IS_CUSTOM IS_CUSTOM = [] for x in range(len(self.FIELD_MAP)): IS_CUSTOM.append('') - IS_CUSTOM[self.FIELD_MAP['rating']] = 'rating' # normal and custom ratings columns use the same code - - # add custom columns to MAP. Put the column's type into IS_CUSTOM + # normal and custom ratings columns use the same code + IS_CUSTOM[self.FIELD_MAP['rating']] = 'rating' for x in self.tag_browser_categories.get_custom_fields(): if self.tag_browser_categories[x]['datatype'] != "datetime": MAP[x] = self.FIELD_MAP[self.tag_browser_categories[x]['colnum']] IS_CUSTOM[MAP[x]] = self.tag_browser_categories[x]['datatype'] + # Some fields not used when matching against contents EXCLUDE_FIELDS = [MAP['rating'], MAP['cover']] SPLITABLE_FIELDS = [MAP['authors'], MAP['tags'], MAP['formats']] for x in self.tag_browser_categories.get_custom_fields(): From 149f9794092aca74ae9948ca90096cf1949ebbb9 Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Wed, 26 May 2010 13:52:13 +0100 Subject: [PATCH 223/324] Misc cleanups --- src/calibre/gui2/dialogs/tag_categories.py | 2 +- src/calibre/gui2/library/models.py | 2 + src/calibre/gui2/tag_view.py | 6 +- src/calibre/library/caches.py | 2 +- src/calibre/library/database2.py | 4 +- src/calibre/library/tag_categories.py | 68 ++++++++++++---------- 6 files changed, 47 insertions(+), 37 deletions(-) diff --git a/src/calibre/gui2/dialogs/tag_categories.py b/src/calibre/gui2/dialogs/tag_categories.py index f49ae4ce83..fdec767d4d 100644 --- a/src/calibre/gui2/dialogs/tag_categories.py +++ b/src/calibre/gui2/dialogs/tag_categories.py @@ -49,7 +49,7 @@ class TagCategories(QDialog, Ui_TagCategories): cc_map = self.db.custom_column_label_map for cc in cc_map: if cc_map[cc]['datatype'] == 'text': - self.category_labels.append(cc) + self.category_labels.append(db.tag_browser_categories.get_search_label(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']) diff --git a/src/calibre/gui2/library/models.py b/src/calibre/gui2/library/models.py index 18af6d8560..6146ff18df 100644 --- a/src/calibre/gui2/library/models.py +++ b/src/calibre/gui2/library/models.py @@ -632,6 +632,8 @@ class BooksModel(QAbstractTableModel): # {{{ return None if role == Qt.ToolTipRole: ht = self.column_map[section] + if self.is_custom_column(self.column_map[section]): + ht = self.db.tag_browser_categories.custom_field_prefix + ht if ht == 'timestamp': # change help text because users know this field as 'date' ht = 'date' return QVariant(_('The lookup/search name is "{0}"').format(ht)) diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py index f2729da480..5c4c7d82ac 100644 --- a/src/calibre/gui2/tag_view.py +++ b/src/calibre/gui2/tag_view.py @@ -224,10 +224,14 @@ class TagsModel(QAbstractItemModel): # {{{ data = self.get_node_tree(config['sort_by_popularity']) self.root_item = TagTreeItem() for i, r in enumerate(self.row_map): + if self.db.get_tag_browser_categories()[r]['kind'] != 'user': + tt = _('The lookup/search name is "{0}"').format(r) + else: + tt = '' c = TagTreeItem(parent=self.root_item, data=self.categories[i], category_icon=self.category_icon_map[r], - tooltip=_('The lookup/search name is "{0}"').format(r)) + tooltip=tt) for tag in data[r]: TagTreeItem(parent=c, data=tag, icon_map=self.icon_state_map) diff --git a/src/calibre/library/caches.py b/src/calibre/library/caches.py index 9005686fd2..d403c0e7ae 100644 --- a/src/calibre/library/caches.py +++ b/src/calibre/library/caches.py @@ -386,7 +386,7 @@ class ResultCache(SearchQueryParser): for x in self.tag_browser_categories: if (len(self.tag_browser_categories[x]['search_labels']) and \ self.tag_browser_categories[x]['kind'] in ['standard', 'not_cat']): - MAP[x] = self.FIELD_MAP[self.tag_browser_categories.get_label(x)] + MAP[x] = self.FIELD_MAP[self.tag_browser_categories.get_field_label(x)] # add custom columns to MAP. Put the column's type into IS_CUSTOM IS_CUSTOM = [] diff --git a/src/calibre/library/database2.py b/src/calibre/library/database2.py index eb27ff8bfb..7da16c4ec6 100644 --- a/src/calibre/library/database2.py +++ b/src/calibre/library/database2.py @@ -670,7 +670,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): # icon_map is not None if get_categories is to store an icon and # possibly a tooltip in the tag structure. icon, tooltip = None, '' - label = tb_cats.get_label(category) + label = tb_cats.get_field_label(category) if icon_map: if cat['kind'] == 'standard': if category in icon_map: @@ -769,7 +769,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): for srch in saved_searches.names(): items.append(Tag(srch, tooltip=saved_searches.lookup(srch), icon=icon)) if len(items): - tb_cats.add_user_category(field_name='search', name=_('Searches')) + tb_cats.add_search_category(field_name='search', name=_('Searches')) if icon_map is not None: icon_map['search'] = icon_map['search'] categories['search'] = items diff --git a/src/calibre/library/tag_categories.py b/src/calibre/library/tag_categories.py index 8cd47c44fb..41477acba8 100644 --- a/src/calibre/library/tag_categories.py +++ b/src/calibre/library/tag_categories.py @@ -88,73 +88,77 @@ class TagsMetadata(dict, DictMixin): ] def __init__(self): - self.tb_cats_ = OrderedDict() + self._tb_cats = OrderedDict() for k,v in self.category_items_: - self.tb_cats_[k] = v - - def __getattr__(self, name): - if name in self.tb_cats_: - return self.tb_cats_[name] - return None - -# def __setattr__(self, name, val): -# dict.__setattr__(self, name, val) + self._tb_cats[k] = v + self._custom_fields = [] + self.custom_field_prefix = '#' def __getitem__(self, key): - return self.tb_cats_[key] + return self._tb_cats[key] -# def __setitem__(self, key, val): -# print 'setitem', key, val -# self.tb_cats_[key] = val + def __setitem__(self, key, val): + raise AttributeError('Assigning to this object is forbidden') def __delitem__(self, key): - del self.tb_cats_[key] + del self._tb_cats[key] def __iter__(self): - for key in self.tb_cats_: + for key in self._tb_cats: yield key def keys(self): - return self.tb_cats_.keys() + return self._tb_cats.keys() def iterkeys(self): - for key in self.tb_cats_: + for key in self._tb_cats: yield key def iteritems(self): - for key in self.tb_cats_: - yield (key, self.tb_cats_[key]) + for key in self._tb_cats: + yield (key, self._tb_cats[key]) - def get_label(self, key): - if 'label' not in self.tb_cats_[key]: + def is_custom_field(self, label): + return label.startswith(self.custom_field_prefix) or label in self._custom_fields + + def get_field_label(self, key): + if 'label' not in self._tb_cats[key]: return key - return self.tb_cats_[key]['label'] + return self._tb_cats[key]['label'] + + def get_search_label(self, key): + if 'label' in self._tb_cats: + return key + if self.is_custom_field(key): + return self.custom_field_prefix+key + raise ValueError('Unknown key [%s]'%(key)) def get_custom_fields(self): - return [l for l in self.tb_cats_ if self.tb_cats_[l]['kind'] == 'custom'] + return [l for l in self._tb_cats if self._tb_cats[l]['kind'] == 'custom'] def add_custom_field(self, field_name, table, column, datatype, is_multiple, number, name): - fn = '#' + field_name - if fn in self.tb_cats_: + fn = self.custom_field_prefix + field_name + if fn in self._tb_cats: raise ValueError('Duplicate custom field [%s]'%(field_name)) - self.tb_cats_[fn] = {'table':table, 'column':column, + self._custom_fields.append(field_name) + self._tb_cats[fn] = {'table':table, 'column':column, 'datatype':datatype, 'is_multiple':is_multiple, 'kind':'custom', 'name':name, 'search_labels':[fn],'label':field_name, 'colnum':number} def add_user_category(self, field_name, name): - if field_name in self.tb_cats_: + if field_name in self._tb_cats: raise ValueError('Duplicate user field [%s]'%(field_name)) - self.tb_cats_[field_name] = {'table':None, 'column':None, + self._tb_cats[field_name] = {'table':None, 'column':None, 'datatype':None, 'is_multiple':False, 'kind':'user', 'name':name, 'search_labels':[]} def add_search_category(self, field_name, name): - if field_name in self.tb_cats_: + if field_name in self._tb_cats: raise ValueError('Duplicate user field [%s]'%(field_name)) - self.tb_cats_[field_name] = {'table':None, 'column':None, + self._tb_cats[field_name] = {'table':None, 'column':None, 'datatype':None, 'is_multiple':False, 'kind':'search', 'name':name, 'search_labels':[]} @@ -184,7 +188,7 @@ class TagsMetadata(dict, DictMixin): def get_search_labels(self): s_labels = [] - for v in self.tb_cats_.itervalues(): + for v in self._tb_cats.itervalues(): map((lambda x:s_labels.append(x)), v['search_labels']) for v in self.search_items: s_labels.append(v) From 3d38872b56da7cff99bad9f1cc66c9cd2896c16e Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Wed, 26 May 2010 14:48:53 +0100 Subject: [PATCH 224/324] Fix bareword searches trying to match against non-text types. --- src/calibre/library/caches.py | 7 +++++-- src/calibre/library/tag_categories.py | 16 ++++++++++------ 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/calibre/library/caches.py b/src/calibre/library/caches.py index d403c0e7ae..fa73b34b41 100644 --- a/src/calibre/library/caches.py +++ b/src/calibre/library/caches.py @@ -381,12 +381,17 @@ class ResultCache(SearchQueryParser): location += 's' MAP = {} + # Fields not used when matching against text contents. These are + # the non-text fields + EXCLUDE_FIELDS = [] # get the db columns for the standard searchables for x in self.tag_browser_categories: if (len(self.tag_browser_categories[x]['search_labels']) and \ self.tag_browser_categories[x]['kind'] in ['standard', 'not_cat']): MAP[x] = self.FIELD_MAP[self.tag_browser_categories.get_field_label(x)] + if self.tag_browser_categories[x]['datatype'] != 'text': + EXCLUDE_FIELDS.append(MAP[x]) # add custom columns to MAP. Put the column's type into IS_CUSTOM IS_CUSTOM = [] @@ -399,8 +404,6 @@ class ResultCache(SearchQueryParser): MAP[x] = self.FIELD_MAP[self.tag_browser_categories[x]['colnum']] IS_CUSTOM[MAP[x]] = self.tag_browser_categories[x]['datatype'] - # Some fields not used when matching against contents - EXCLUDE_FIELDS = [MAP['rating'], MAP['cover']] SPLITABLE_FIELDS = [MAP['authors'], MAP['tags'], MAP['formats']] for x in self.tag_browser_categories.get_custom_fields(): if self.tag_browser_categories[x]['is_multiple']: diff --git a/src/calibre/library/tag_categories.py b/src/calibre/library/tag_categories.py index 41477acba8..63327fac45 100644 --- a/src/calibre/library/tag_categories.py +++ b/src/calibre/library/tag_categories.py @@ -36,7 +36,7 @@ class TagsMetadata(dict, DictMixin): 'kind':'standard', 'name':_('Authors'), 'search_labels':['authors', 'author']}), ('series', {'table':'series', 'column':'name', - 'datatype':None, 'is_multiple':False, + 'datatype':'text', 'is_multiple':False, 'kind':'standard', 'name':_('Series'), 'search_labels':['series']}), ('formats', {'table':None, 'column':None, @@ -60,30 +60,34 @@ class TagsMetadata(dict, DictMixin): 'kind':'standard', 'name':_('Tags'), 'search_labels':['tags', 'tag']}), ('comments', {'table':None, 'column':None, - 'datatype':None, 'is_multiple':False, + 'datatype':'text', 'is_multiple':False, 'kind':'not_cat', 'name':None, 'search_labels':['comments', 'comment']}), ('cover', {'table':None, 'column':None, 'datatype':None, 'is_multiple':False, 'kind':'not_cat', 'name':None, 'search_labels':['cover']}), + ('timestamp', {'table':None, 'column':None, + 'datatype':'datetime', 'is_multiple':False, + 'kind':'not_cat', 'name':None, + 'search_labels':['date']}), ('isbn', {'table':None, 'column':None, - 'datatype':None, 'is_multiple':False, + 'datatype':'text', 'is_multiple':False, 'kind':'not_cat', 'name':None, 'search_labels':['isbn']}), ('pubdate', {'table':None, 'column':None, - 'datatype':None, 'is_multiple':False, + 'datatype':'datetime', 'is_multiple':False, 'kind':'not_cat', 'name':None, 'search_labels':['pubdate']}), ('title', {'table':None, 'column':None, - 'datatype':None, 'is_multiple':False, + 'datatype':'text', 'is_multiple':False, 'kind':'not_cat', 'name':None, 'search_labels':['title']}), ] # search labels that are not db columns search_items = [ 'all', - 'date', +# 'date', 'search', ] From a124d9c79922407cbdf031a21b2656dee9b79d2f Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Wed, 26 May 2010 16:35:41 +0100 Subject: [PATCH 225/324] Added all standard fields to the metadata dictionary. The datatypes might be wrong in some cases. --- src/calibre/gui2/tag_view.py | 2 +- src/calibre/library/caches.py | 8 +- src/calibre/library/custom_columns.py | 13 +- src/calibre/library/database2.py | 17 +- src/calibre/library/field_metadata.py | 259 ++++++++++++++++++++++++++ src/calibre/library/tag_categories.py | 202 -------------------- 6 files changed, 285 insertions(+), 216 deletions(-) create mode 100644 src/calibre/library/field_metadata.py delete mode 100644 src/calibre/library/tag_categories.py diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py index 5c4c7d82ac..3882e4e174 100644 --- a/src/calibre/gui2/tag_view.py +++ b/src/calibre/gui2/tag_view.py @@ -14,7 +14,7 @@ from PyQt4.Qt import Qt, QTreeView, QApplication, pyqtSignal, \ QAbstractItemModel, QVariant, QModelIndex from calibre.gui2 import config, NONE from calibre.utils.config import prefs -from calibre.library.tag_categories import TagsIcons +from calibre.library.field_metadata import TagsIcons class TagsView(QTreeView): # {{{ diff --git a/src/calibre/library/caches.py b/src/calibre/library/caches.py index fa73b34b41..36698533c5 100644 --- a/src/calibre/library/caches.py +++ b/src/calibre/library/caches.py @@ -17,7 +17,7 @@ from calibre.utils.config import tweaks from calibre.utils.date import parse_date, now, UNDEFINED_DATE from calibre.utils.search_query_parser import SearchQueryParser from calibre.utils.pyparsing import ParseException -from calibre.library.tag_categories import TagsMetadata +# from calibre.library.field_metadata import FieldMetadata class CoverCache(QThread): @@ -387,9 +387,9 @@ class ResultCache(SearchQueryParser): # get the db columns for the standard searchables for x in self.tag_browser_categories: - if (len(self.tag_browser_categories[x]['search_labels']) and \ - self.tag_browser_categories[x]['kind'] in ['standard', 'not_cat']): - MAP[x] = self.FIELD_MAP[self.tag_browser_categories.get_field_label(x)] + if len(self.tag_browser_categories[x]['search_labels']) and \ + not self.tag_browser_categories.is_custom_field(x): + MAP[x] = self.tag_browser_categories[x]['rec_index'] if self.tag_browser_categories[x]['datatype'] != 'text': EXCLUDE_FIELDS.append(MAP[x]) diff --git a/src/calibre/library/custom_columns.py b/src/calibre/library/custom_columns.py index cc7ee7ba7f..535b8cfb72 100644 --- a/src/calibre/library/custom_columns.py +++ b/src/calibre/library/custom_columns.py @@ -144,11 +144,14 @@ class CustomColumns(object): 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(v['num']) - self.tag_browser_categories.add_custom_field( - field_name = v['label'], table = tn, column='value', - datatype=v['datatype'], is_multiple=v['is_multiple'], - number=v['num'], name=v['name']) + searchable = True + else: + searchable = False + tn = 'custom_column_{0}'.format(v['num']) + self.tag_browser_categories.add_custom_field(label=v['label'], + table=tn, column='value', datatype=v['datatype'], + is_multiple=v['is_multiple'], colnum=v['num'], + name=v['name'], searchable=searchable) 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 7da16c4ec6..b8f55d76db 100644 --- a/src/calibre/library/database2.py +++ b/src/calibre/library/database2.py @@ -20,7 +20,7 @@ from PyQt4.QtGui import QImage from calibre.ebooks.metadata import title_sort from calibre.library.database import LibraryDatabase -from calibre.library.tag_categories import TagsMetadata, TagsIcons +from calibre.library.field_metadata import FieldMetadata, TagsIcons from calibre.library.schema_upgrades import SchemaUpgrade from calibre.library.caches import ResultCache from calibre.library.custom_columns import CustomColumns @@ -116,7 +116,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): self.books_list_filter = self.conn.create_dynamic_filter('books_list_filter') def __init__(self, library_path, row_factory=False): - self.tag_browser_categories = TagsMetadata() #.get_tag_browser_categories() + self.tag_browser_categories = FieldMetadata() #.get_tag_browser_categories() if not os.path.exists(library_path): os.makedirs(library_path) self.listeners = set([]) @@ -204,12 +204,21 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): 'sort':11, 'author_sort':12, 'formats':13, 'isbn':14, 'path':15, 'lccn':16, 'pubdate':17, 'flags':18, 'uuid':19} + for k,v in self.FIELD_MAP.iteritems(): + self.tag_browser_categories.set_field_record_index(k, v, prefer_custom=False) + base = max(self.FIELD_MAP.values()) for col in custom_cols: self.FIELD_MAP[col] = base = base+1 + self.tag_browser_categories.set_field_record_index( + self.custom_column_num_map[col]['label'], + base, + prefer_custom=True) self.FIELD_MAP['cover'] = base+1 + self.tag_browser_categories.set_field_record_index('cover', base+1, prefer_custom=False) self.FIELD_MAP['ondevice'] = base+2 + self.tag_browser_categories.set_field_record_index('ondevice', base+2, prefer_custom=False) script = ''' DROP VIEW IF EXISTS meta2; @@ -672,10 +681,10 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): icon, tooltip = None, '' label = tb_cats.get_field_label(category) if icon_map: - if cat['kind'] == 'standard': + if not tb_cats.is_custom_field(category): if category in icon_map: icon = icon_map[label] - elif cat['kind'] == 'custom': + else: icon = icon_map[':custom'] icon_map[category] = icon tooltip = self.custom_column_label_map[label]['name'] diff --git a/src/calibre/library/field_metadata.py b/src/calibre/library/field_metadata.py new file mode 100644 index 0000000000..383d55b2ff --- /dev/null +++ b/src/calibre/library/field_metadata.py @@ -0,0 +1,259 @@ +''' +Created on 25 May 2010 + +@author: charles +''' + +from UserDict import DictMixin +from calibre.utils.ordered_dict import OrderedDict + +class TagsIcons(dict): + ''' + If the client wants icons to be in the tag structure, this class must be + instantiated and filled in with real icons. If this class is instantiated + and passed to get_categories, All items must be given a value not None + ''' + + category_icons = ['authors', 'series', 'formats', 'publisher', 'rating', + 'news', 'tags', ':custom', ':user', 'search',] + def __init__(self, icon_dict): + for a in self.category_icons: + if a not in icon_dict: + raise ValueError('Missing category icon [%s]'%a) + self[a] = icon_dict[a] + +class FieldMetadata(dict, DictMixin): + + # kind == standard: is tag category. May be a search label. Is db col + # or is specially handled (e.g., news) + # kind == not_cat: Is not a tag category. May be a search label. Is db col + # kind == user: user-defined tag category + # kind == search: saved-searches category + # For 'standard', the order below is the order that the categories will + # appear in the tags pane. + # + # label is the column label. key is either the label or in the case of + # custom fields, the label prefixed with 'x'. Because of the prefixing, + # there cannot be a name clash between standard and custom fields, so key + # can be used as the metadata dictionary key. + + category_items_ = [ + ('authors', {'table':'authors', 'column':'name', + 'datatype':'text', 'is_multiple':False, + 'kind':'standard', 'name':_('Authors'), + 'search_labels':['authors', 'author'], + 'is_custom':False}), + ('series', {'table':'series', 'column':'name', + 'datatype':'text', 'is_multiple':False, + 'kind':'standard', 'name':_('Series'), + 'search_labels':['series'], + 'is_custom':False}), + ('formats', {'table':None, 'column':None, + 'datatype':'text', 'is_multiple':False, # must think what type this is! + 'kind':'standard', 'name':_('Formats'), + 'search_labels':['formats', 'format'], + 'is_custom':False}), + ('publisher', {'table':'publishers', 'column':'name', + 'datatype':'text', 'is_multiple':False, + 'kind':'standard', 'name':_('Publishers'), + 'search_labels':['publisher'], + 'is_custom':False}), + ('rating', {'table':'ratings', 'column':'rating', + 'datatype':'rating', 'is_multiple':False, + 'kind':'standard', 'name':_('Ratings'), + 'search_labels':['rating'], + 'is_custom':False}), + ('news', {'table':'news', 'column':'name', + 'datatype':None, 'is_multiple':False, + 'kind':'standard', 'name':_('News'), + 'search_labels':[], + 'is_custom':False}), + ('tags', {'table':'tags', 'column':'name', + 'datatype':'text', 'is_multiple':True, + 'kind':'standard', 'name':_('Tags'), + 'search_labels':['tags', 'tag'], + 'is_custom':False}), + ('author_sort',{'table':None, 'column':None, 'datatype':'text', + 'is_multiple':False, 'kind':'not_cat', 'name':None, + 'search_labels':[], 'is_custom':False}), + ('comments', {'table':None, 'column':None, 'datatype':'text', + 'is_multiple':False, 'kind':'not_cat', 'name':None, + 'search_labels':['comments', 'comment'], 'is_custom':False}), + ('cover', {'table':None, 'column':None, 'datatype':None, + 'is_multiple':False, 'kind':'not_cat', 'name':None, + 'search_labels':['cover'], 'is_custom':False}), + ('flags', {'table':None, 'column':None, 'datatype':'text', + 'is_multiple':False, 'kind':'not_cat', 'name':None, + 'search_labels':[], 'is_custom':False}), + ('id', {'table':None, 'column':None, 'datatype':'int', + 'is_multiple':False, 'kind':'not_cat', 'name':None, + 'search_labels':[], 'is_custom':False}), + ('isbn', {'table':None, 'column':None, 'datatype':'text', + 'is_multiple':False, 'kind':'not_cat', 'name':None, + 'search_labels':['isbn'], 'is_custom':False}), + ('lccn', {'table':None, 'column':None, 'datatype':'text', + 'is_multiple':False, 'kind':'not_cat', 'name':None, + 'search_labels':[], 'is_custom':False}), + ('ondevice', {'table':None, 'column':None, 'datatype':'bool', + 'is_multiple':False, 'kind':'not_cat', 'name':None, + 'search_labels':[], 'is_custom':False}), + ('path', {'table':None, 'column':None, 'datatype':'text', + 'is_multiple':False, 'kind':'not_cat', 'name':None, + 'search_labels':[], 'is_custom':False}), + ('pubdate', {'table':None, 'column':None, 'datatype':'datetime', + 'is_multiple':False, 'kind':'not_cat', 'name':None, + 'search_labels':['pubdate'], 'is_custom':False}), + ('series_index',{'table':None, 'column':None, 'datatype':'float', + 'is_multiple':False, 'kind':'not_cat', 'name':None, + 'search_labels':[], 'is_custom':False}), + ('sort', {'table':None, 'column':None, 'datatype':'text', + 'is_multiple':False, 'kind':'not_cat', 'name':None, + 'search_labels':[], 'is_custom':False}), + ('size', {'table':None, 'column':None, 'datatype':'float', + 'is_multiple':False, 'kind':'not_cat', 'name':None, + 'search_labels':[], 'is_custom':False}), + ('timestamp', {'table':None, 'column':None, 'datatype':'datetime', + 'is_multiple':False, 'kind':'not_cat', 'name':None, + 'search_labels':['date'], 'is_custom':False}), + ('title', {'table':None, 'column':None, 'datatype':'text', + 'is_multiple':False, 'kind':'not_cat', 'name':None, + 'search_labels':['title'], 'is_custom':False}), + ('uuid', {'table':None, 'column':None, 'datatype':'text', + 'is_multiple':False, 'kind':'not_cat', 'name':None, + 'search_labels':[], 'is_custom':False}), + ] + + # search labels that are not db columns + search_items = [ 'all', +# 'date', + 'search', + ] + + def __init__(self): + self._tb_cats = OrderedDict() + for k,v in self.category_items_: + self._tb_cats[k] = v + self._custom_fields = [] + self.custom_field_prefix = '#' + + def __getitem__(self, key): + return self._tb_cats[key] + + def __setitem__(self, key, val): + raise AttributeError('Assigning to this object is forbidden') + + def __delitem__(self, key): + del self._tb_cats[key] + + def __iter__(self): + for key in self._tb_cats: + yield key + + def keys(self): + return self._tb_cats.keys() + + def iterkeys(self): + for key in self._tb_cats: + yield key + + def iteritems(self): + for key in self._tb_cats: + yield (key, self._tb_cats[key]) + + def is_custom_field(self, key): + return key.startswith(self.custom_field_prefix) or key in self._custom_fields + + def get_field_label(self, key): + if 'label' not in self._tb_cats[key]: + return key + return self._tb_cats[key]['label'] + + def get_search_label(self, label): + if 'label' in self._tb_cats: + return label + if self.is_custom_field(label): + return self.custom_field_prefix+label + raise ValueError('Unknown key [%s]'%(label)) + + def get_custom_fields(self): + return [l for l in self._tb_cats if self._tb_cats[l]['is_custom']] + + def add_custom_field(self, label, table, column, datatype, + is_multiple, colnum, name, searchable): + fn = self.custom_field_prefix + label + if fn in self._tb_cats: + raise ValueError('Duplicate custom field [%s]'%(label)) + self._custom_fields.append(label) + if searchable: + sl = [fn] + kind = 'standard' + else: + sl = [] + kind = 'not_cat' + self._tb_cats[fn] = {'table':table, 'column':column, + 'datatype':datatype, 'is_multiple':is_multiple, + 'kind':kind, 'name':name, + 'search_labels':sl, 'label':label, + 'colnum':colnum, 'is_custom':True} + + def set_field_record_index(self, label, index, prefer_custom=False): + if prefer_custom: + key = self.custom_field_prefix+label + if key not in self._tb_cats: + key = label + else: + if label in self._tb_cats: + key = label + else: + key = self.custom_field_prefix+label + self._tb_cats[key]['rec_index'] = index # let the exception fly ... + + def add_user_category(self, label, name): + if label in self._tb_cats: + raise ValueError('Duplicate user field [%s]'%(label)) + self._tb_cats[label] = {'table':None, 'column':None, + 'datatype':None, 'is_multiple':False, + 'kind':'user', 'name':name, + 'search_labels':[], 'is_custom':False} + + def add_search_category(self, label, name): + if label in self._tb_cats: + raise ValueError('Duplicate user field [%s]'%(label)) + self._tb_cats[label] = {'table':None, 'column':None, + 'datatype':None, 'is_multiple':False, + 'kind':'search', 'name':name, + 'search_labels':[], 'is_custom':False} + +# DEFAULT_LOCATIONS = frozenset([ +# 'all', +# 'author', # compatibility +# 'authors', +# 'comment', # compatibility +# 'comments', +# 'cover', +# 'date', +# 'format', # compatibility +# 'formats', +# 'isbn', +# 'ondevice', +# 'pubdate', +# 'publisher', +# 'search', +# 'series', +# 'rating', +# 'tag', # compatibility +# 'tags', +# 'title', +# ]) + + + def get_search_labels(self): + s_labels = [] + for v in self._tb_cats.itervalues(): + map((lambda x:s_labels.append(x)), v['search_labels']) + for v in self.search_items: + s_labels.append(v) +# if set(s_labels) != self.DEFAULT_LOCATIONS: +# print 'search labels and default_locations do not match:' +# print set(s_labels) ^ self.DEFAULT_LOCATIONS + return s_labels diff --git a/src/calibre/library/tag_categories.py b/src/calibre/library/tag_categories.py deleted file mode 100644 index 63327fac45..0000000000 --- a/src/calibre/library/tag_categories.py +++ /dev/null @@ -1,202 +0,0 @@ -''' -Created on 25 May 2010 - -@author: charles -''' - -from UserDict import DictMixin -from calibre.utils.ordered_dict import OrderedDict - -class TagsIcons(dict): - ''' - If the client wants icons to be in the tag structure, this class must be - instantiated and filled in with real icons. If this class is instantiated - and passed to get_categories, All items must be given a value not None - ''' - - category_icons = ['authors', 'series', 'formats', 'publisher', 'rating', - 'news', 'tags', ':custom', ':user', 'search',] - def __init__(self, icon_dict): - for a in self.category_icons: - if a not in icon_dict: - raise ValueError('Missing category icon [%s]'%a) - self[a] = icon_dict[a] - -class TagsMetadata(dict, DictMixin): - - # kind == standard: is tag category. May be a search label. Is db col - # or is specially handled (e.g., news) - # kind == not_cat: Is not a tag category. Should be a search label. Is db col - # kind == user: user-defined tag category - # kind == search: saved-searches category - # Order as has been customary in the tags pane. - category_items_ = [ - ('authors', {'table':'authors', 'column':'name', - 'datatype':'text', 'is_multiple':False, - 'kind':'standard', 'name':_('Authors'), - 'search_labels':['authors', 'author']}), - ('series', {'table':'series', 'column':'name', - 'datatype':'text', 'is_multiple':False, - 'kind':'standard', 'name':_('Series'), - 'search_labels':['series']}), - ('formats', {'table':None, 'column':None, - 'datatype':None, 'is_multiple':False, - 'kind':'standard', 'name':_('Formats'), - 'search_labels':['formats', 'format']}), - ('publisher', {'table':'publishers', 'column':'name', - 'datatype':'text', 'is_multiple':False, - 'kind':'standard', 'name':_('Publishers'), - 'search_labels':['publisher']}), - ('rating', {'table':'ratings', 'column':'rating', - 'datatype':'rating', 'is_multiple':False, - 'kind':'standard', 'name':_('Ratings'), - 'search_labels':['rating']}), - ('news', {'table':'news', 'column':'name', - 'datatype':None, 'is_multiple':False, - 'kind':'standard', 'name':_('News'), - 'search_labels':[]}), - ('tags', {'table':'tags', 'column':'name', - 'datatype':'text', 'is_multiple':True, - 'kind':'standard', 'name':_('Tags'), - 'search_labels':['tags', 'tag']}), - ('comments', {'table':None, 'column':None, - 'datatype':'text', 'is_multiple':False, - 'kind':'not_cat', 'name':None, - 'search_labels':['comments', 'comment']}), - ('cover', {'table':None, 'column':None, - 'datatype':None, 'is_multiple':False, - 'kind':'not_cat', 'name':None, - 'search_labels':['cover']}), - ('timestamp', {'table':None, 'column':None, - 'datatype':'datetime', 'is_multiple':False, - 'kind':'not_cat', 'name':None, - 'search_labels':['date']}), - ('isbn', {'table':None, 'column':None, - 'datatype':'text', 'is_multiple':False, - 'kind':'not_cat', 'name':None, - 'search_labels':['isbn']}), - ('pubdate', {'table':None, 'column':None, - 'datatype':'datetime', 'is_multiple':False, - 'kind':'not_cat', 'name':None, - 'search_labels':['pubdate']}), - ('title', {'table':None, 'column':None, - 'datatype':'text', 'is_multiple':False, - 'kind':'not_cat', 'name':None, - 'search_labels':['title']}), - ] - - # search labels that are not db columns - search_items = [ 'all', -# 'date', - 'search', - ] - - def __init__(self): - self._tb_cats = OrderedDict() - for k,v in self.category_items_: - self._tb_cats[k] = v - self._custom_fields = [] - self.custom_field_prefix = '#' - - def __getitem__(self, key): - return self._tb_cats[key] - - def __setitem__(self, key, val): - raise AttributeError('Assigning to this object is forbidden') - - def __delitem__(self, key): - del self._tb_cats[key] - - def __iter__(self): - for key in self._tb_cats: - yield key - - def keys(self): - return self._tb_cats.keys() - - def iterkeys(self): - for key in self._tb_cats: - yield key - - def iteritems(self): - for key in self._tb_cats: - yield (key, self._tb_cats[key]) - - def is_custom_field(self, label): - return label.startswith(self.custom_field_prefix) or label in self._custom_fields - - def get_field_label(self, key): - if 'label' not in self._tb_cats[key]: - return key - return self._tb_cats[key]['label'] - - def get_search_label(self, key): - if 'label' in self._tb_cats: - return key - if self.is_custom_field(key): - return self.custom_field_prefix+key - raise ValueError('Unknown key [%s]'%(key)) - - def get_custom_fields(self): - return [l for l in self._tb_cats if self._tb_cats[l]['kind'] == 'custom'] - - def add_custom_field(self, field_name, table, column, datatype, is_multiple, number, name): - fn = self.custom_field_prefix + field_name - if fn in self._tb_cats: - raise ValueError('Duplicate custom field [%s]'%(field_name)) - self._custom_fields.append(field_name) - self._tb_cats[fn] = {'table':table, 'column':column, - 'datatype':datatype, 'is_multiple':is_multiple, - 'kind':'custom', 'name':name, - 'search_labels':[fn],'label':field_name, - 'colnum':number} - - def add_user_category(self, field_name, name): - if field_name in self._tb_cats: - raise ValueError('Duplicate user field [%s]'%(field_name)) - self._tb_cats[field_name] = {'table':None, 'column':None, - 'datatype':None, 'is_multiple':False, - 'kind':'user', 'name':name, - 'search_labels':[]} - - def add_search_category(self, field_name, name): - if field_name in self._tb_cats: - raise ValueError('Duplicate user field [%s]'%(field_name)) - self._tb_cats[field_name] = {'table':None, 'column':None, - 'datatype':None, 'is_multiple':False, - 'kind':'search', 'name':name, - 'search_labels':[]} - -# DEFAULT_LOCATIONS = frozenset([ -# 'all', -# 'author', # compatibility -# 'authors', -# 'comment', # compatibility -# 'comments', -# 'cover', -# 'date', -# 'format', # compatibility -# 'formats', -# 'isbn', -# 'ondevice', -# 'pubdate', -# 'publisher', -# 'search', -# 'series', -# 'rating', -# 'tag', # compatibility -# 'tags', -# 'title', -# ]) - - - def get_search_labels(self): - s_labels = [] - for v in self._tb_cats.itervalues(): - map((lambda x:s_labels.append(x)), v['search_labels']) - for v in self.search_items: - s_labels.append(v) -# if set(s_labels) != self.DEFAULT_LOCATIONS: -# print 'search labels and default_locations do not match:' -# print set(s_labels) ^ self.DEFAULT_LOCATIONS - return s_labels From e4619e969cbb0251a518c2107d7ea5c6e5fe03ce Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Wed, 26 May 2010 16:53:35 +0100 Subject: [PATCH 226/324] Correct a renamed parameter --- src/calibre/library/database2.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/calibre/library/database2.py b/src/calibre/library/database2.py index b8f55d76db..03d8bcc53d 100644 --- a/src/calibre/library/database2.py +++ b/src/calibre/library/database2.py @@ -759,7 +759,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): # else: do nothing, to not include nodes w zero counts if len(items): cat_name = user_cat+':' # add the ':' to avoid name collision - tb_cats.add_user_category(field_name=cat_name, name=user_cat) + tb_cats.add_user_category(label=cat_name, 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'] @@ -778,7 +778,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): for srch in saved_searches.names(): items.append(Tag(srch, tooltip=saved_searches.lookup(srch), icon=icon)) if len(items): - tb_cats.add_search_category(field_name='search', name=_('Searches')) + tb_cats.add_search_category(label='search', name=_('Searches')) if icon_map is not None: icon_map['search'] = icon_map['search'] categories['search'] = items From ff8ca58f55b3d3a1dfed5207db9ecb93f72a60e0 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Wed, 26 May 2010 11:44:10 -0600 Subject: [PATCH 227/324] More logo updates --- resources/content_server/calibre.png | Bin 25399 -> 19554 bytes resources/content_server/calibre_banner.png | Bin 32862 -> 58139 bytes resources/images/notify.png | Bin 3891 -> 4181 bytes 3 files changed, 0 insertions(+), 0 deletions(-) diff --git a/resources/content_server/calibre.png b/resources/content_server/calibre.png index 871a5e31a802e1fd907f3a5407cd3ddc026aa244..87950e2bb47a30cf745c2697beee62c2df2b4694 100644 GIT binary patch literal 19554 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�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 m&mq2^-01oGO#+38@c#qQxIHg*f$<3d0000e_od z2sC#XEC~l1^2d)K6vDU@4J7JF0Hmp$l=zSD|DD3_^3?xIFwW9CZa;p&q5kiJ%x?vL z{`UywE~6j;wGM%cLcy%}5$5>g2k{RXaS?Uz^~-LZ8izHHf|q!f6I)NW9KV}(2M&5p z)Yuexm11(;BHi)Ebm~S_v8Y3E#r;E)dP4j)AGse;B8I4aqJ;QBp9nd5aE)|yEp*0` zIH~A_rjlIgkYP>4P!*}u)|Y#yp4}9WB;oP;$+jHZs9X@gGt%y^dVbT4Bhzv37w~yN zbgw_;Q(Y=Khxsl^Eb0iVE{m8H62x1C*#TV_BDoq>C=$J|Oc(G&s$wxZot0gb3wvsK z48#|UGPRGFuQmwa)4pNH$g5>*+J+8ISyA@NZkA?00lQm-rkb|jTcpClcVQ=2XDyPg z@T~9*n(-< zs%MOitD4XTcMal2QVwI_CB8$D9KmCgHSfhXKbxXTj=%w|`3?zrxE&j{wTuv%A7~;| zdW7$at2FYEz_k(6sOUv(XTfI+14jxL1dk9@(@4e#s}n^Q0oMl& zplYKwgwO}@g>?F@oSMGxnC`a|yG1|F9loe`k%F1PMuca+0g9l6oq^Xa5WL>j@9t`Y zD_bq27Sa$hVq?Vqe(_O8b;hiqSYj1v-mfX|jSf^!>JXG4Ge^iNh3+cY@M%He;;2%f zht}qy#7XcWqMlTi4&59b7tEeTIsYgPV z0<{%5$^|vb2ez^%45-X$?ykne+xwV)-eZm9Q|HhsWI8idJ{p*R#M&`o$_?jaq1Z1s z^4{LSxbIRSNfURz%l&E8!-5|DOlBL=z9-Jo-EBH1IdMwA19J#YagqiK4HVbL3feFh zIU*3`=F05VKbn#b+7;HPRRgjhU`a6F{In)E8*r#`C0M=GtEMEcSZzvy(yS zzU1{g^V|B{ObUGUy9w*pS;WdwB)A0)<0Fflb}YlJR< zj}Cdsu3SMT1Cbhpag4%?+9g%@5Jo;XWAIxHrl-*wQqb8)XR#7tv2yBu;%H0&WAXmt6pkYX&z~r068E+j;$h}VW;ko@ zl!dgsa&8!@)td6sJyu5yWe^1)Jmu1QaVR#aCN!yCFK`qRA(6y@F6A{;slx_(Xkkvu zAGBFKaW75(-~q8W5RulO)y8BO&3EI)a@jsY|Mg&Fn3n=U3svO~#)YsLbB;%2&LsZS ze?gGpRl`4O^%|i>;hD4h{bg-wQ~E5@*@GpVA^)vbpS~bD5^wry%mNo`^MS|Ru=|5y zozAwQOeJ^2@V$O<-|HnXO(#8UjOarlGZ*IHYjn0xy}*m$^}juU_NF>8F7btE$?xVf z{Jr<&++()xtrYsA@l4ir$%$8^Bf_=H@@Y3*vbD~i|T97 z?;`T|mCJ7-o;MS-Cj-dddOno@G4vlD@mbvc$#bXhG>l7$*RL00aUH~D4@_DY=vggA z;=PnH;OMi=6Wcijn+3a)wFCWB{Iu(FXSu%!)pgXK*%YDrQW=g0ZeU&6g_E0hHa*e| z`{&Jb-wTSYJ9t^3c|VF=ND}gu3zEdxBWxm$h7>um528za3|Ne<(s(9{i#i&KJ+>-& zIbDf+4s+zD5vZ^`SjsJb|Gl7NDoUS&zhyEh062AnIg`{XC=(*XPrY| z3R>nEC?v;n)kp=QS4JVhQ&p;1$0@2437-&&z!a);#a;P%o9a%YEOb{usTn5N9^vhP+4A&00wGM<|vTlFOvTt8rvlrbR! z@o!3=Wm`*F8_1a~l_F>OgH0mnsc@_CG>4^{Iuv^z8EymrKsUigtbp>Kx*n!g0!0Zf z6ZR$o+rZRZAyyyu;oZr>Up1kbuE0Z^?Uqu|FVjKYnK*A;R1dof|#>`DOOqArw&H^u{MNk@>yGcj(zu@<--A zc7f{%?KArLv%&Z+6{E@T$vtpcTm~(_mNG<1^I!VFgI3QKZ$Ou9&UOg!rr_@x&Fy6K z!sfC+t8|-4w?seJv&+81ss5t7Ke3ZkjrS?&rOc@a|F1ZW-h{P$%>|g8Hv~zMS+ZQ-bjt)$Vjh~wx z7_oFq0bm(p(B8uQCf8B!QReh8jtqu;)}$|L^US}hW}u&7)75`0g5q}krHurm=i*L? zfG7sjmQzekCdUm&K2Dz1P^#aCE9M@rjV;1U?qdy~}782j`X zQOEy5>%qJv-Vv`wfQTt>{1uh3uuq@Wa%s$5^Oa(ryKB=&T;18&>U!K-@^)uJyeT{ptDg6LQ$O zqx$n&WE8#w@x?;~WbEvp3^e$&^b~vTBTpaf@d>T;WKExE?vUo4=%M}Wa$9$E4$_n= z^(WZi2V3&ET*s);dsHPhWIL~xa z;=AeUc-vy_-{UvPJTJ9&t*aYNm7j8pZz{QLa-9!KZ2eXK{Ae;e;$ z+`^7%x@(@wJsF{qd^F#7j^3J{FInhS9uT6CfV>}qCYkMV;STW!(>;22uYkoaRw|XS zbfgC4x5^F>L|2Tb?$)jZC$amOQwa0YH%qijh`eRoZh88<&;-K%$B=!5pAVBnM}rrx zxBQDaI2#)@sX5!=5ew=*7S&^hK+LEb87vkS^q92~3>X!Pw@#whSislm$+)_Qg-dJn>k8uT{GF|>k zh)<)m55)3zMO<_>6x5rB4*^iO_nnE}|Hzzf$pmM!7A8SQNK$~?{LdRdDg54NSfa^9 z?~SUH4v}lWXTg^wJt;2lr+Bx5@B0X+95`SZ&o8}xT{s;gWWF}Ooja6o4bIyON7w(7 z%M}Ft9^BhL8?UYWPD|lT>KrwiP8^|&Aqa<8lz+fx>@5j<5J>6Rgomm7$=BDOv5|G)HqlhxL`v0o|Ahy;(Dw3 zy0!|W=sL)}9?Ud+CAwH4L^k62)d8gDSWb0~@#Y@3ZZ%y-zB;?#Y}eu)t|5qWlTIU6 zBI;6Rf?jcycjrZ?im&3;|3j#f&7;WiAyq6az&;1L(i7ca14d{rmIzHsq@7NGQQ%LF zKG>DVhQ8&NU!13IZnP45ni2nB@G!7rG9wWCr))NLkP@WJyh@&a6al8~*HJ$|P;X9` z`SBoC!{SAoM!OzL4>S!`y4w%IpdvVv{7UN3NCvh;Ke~iXQ_%d(Is0L{Sy(Kbr68?x z!k9xOj!M!_Ei6@gL_;XId8zJj2o70&~|NfHr_xJ7t+X@0}9KF%D1w``2t?O{W@1uF^|C zuT*2^`i)W6`~3d*>pXgG3F>6_eviVn_sI0 zBV5DSB3elSQuBg1o7cHdaZJoKo2N_?J^Mp2v2qIzf~f8aau_q4<;}XWQAGtO4o}^l zbv!ruVjj6U$T_|{Q1ank0YbYuHJmYGrV5Y}^cP)NW|q#8>r?k17%E~|OLJ##4% zK)zcRYFQD4S(fE(^nJNTFI#wu9Z%bWe2_P*VlG7tRC82!N)+4anq|jv!cKsQx>ga_ zbWKXL%BGpX?SG{kVzf)EM<48E#9Y#-sdIr}CCy^o0a-z(Rax%hEKbNX>Ps|LO7g}r zq@!7X7t9*zw^Z#$+ZpT0>5#?9vAk+mc6rBZ!9)j^(J8Rv8Xw8!u*G0tZX2@C+At(( zB&-51?JX`G8HCW7F6-u8dl@%wp{T|W439k-DJP^UrD(P$k&V;iOVXm3_gu%3g8Kg2kTPX`XuY zcSM$vw3;{o{78SNMqgzj`uCIPKP7bj!Z3H5a3G69ne z7&kF01wOA3i>fN>AEQ|j>LS*ffVl_dpE#4=$O9UBt*~yheTCDP^6QV8 zE)^p?t_c&Ul>c$(OCt#$v6w9m1_VVSILFve;Oma6w${=Yi3w&+!yR>Uxq) zu>+`q;^c}KcLnLvmD_P5tDPLa#RyjLA{FAy43D>D78hQPEd3216p3pDTQzaVED|#i z{WmEL_7^G@iM+V;pSjZgYb+o34H9Uo12W~9M`1<-f!{G!5`LTUBpQRp$ci$dNow&` zCH=$GCWB3+c^T+G%Z6-RkizEbQy0+fMWG3*@LXx_8-fU<6m8~1& zDe*+AgDOzri;)gNHuJ(znnG#KOOm4iA4%6YzI*F~F&vKp$pi;liZ)@0sRX^@`M%O+hB{oPXFqm1lu&W+v6v2dksZwWrXbx@!@2WJZS65hOk z`H)@AokPxCH$WiXpzdY6@~zu`T88Y;;)z|4(YxY{nIu&)clGBH0~<2lP8=% z1Hu;BRyUmbRkYi_nXcb07xUfxY#p}XWB;=moxUM5m=!L0$YC6+S&A1mgKGGB)m@#N)8K|_ z#RUw`L&$hJo@?Y{*s=Wc=+gYZodvEv%IVkJ88Fh>Md&-z@^#b!ecTaUJOyahgDCRo zBxW=z?>IaPDhb&dbgkRiFR(Kh$Z!+Q5qVTtDR%8Qtv}cV8%G%$+-UyG|Mm+>SD{~c zWot+aX%8Ue-dw@gLbka%Q<~zt&1pWDgbh$;lJZYEMI|IPNQOr1y(PUvHSpbsAOHRD>jg=iLiehyb58|&8Y>mA@U+ftap9bs!+LzIWe1D z_KI|hhE8{FGc8WqtUqa^!OyLo2SEkJ)!8b4-_8a@(z@&)MbhtZ7q=9&LCe=cFBK)_ zl%)9s1A0%RUr?Tm|Hu3Nr>yR@?y$x6bD$XpsX9?&7kOHu07WtF$%u}ew0PG73eV>Hpp#AyJ`kWeyz?}k+}<^(XS6(qFZP-EAkV5172PW zFDFfV*_cvVnOss|)TKqXv?DNl$wa|X_x&j4Y%UEd9k?%qB@Q0Xw>-5WXW6B9-EY)+ zb!?o(rkus^k@2?_VdE;xpVNo3lw4J^z7W?qmq}bEMiY5K3j^uII=dmL_jMoCL#lHP z?6hd&IX;dQ9J;JG9Y>*4tgIsZ!`d#xOb@5Rs}8w|DwYtYM|ZBoz2s8byC?swIVzqM znynGJ0%Z5z@g^1&;B7C}{r4k=uHb8d%P={n@aCuDuA)X<%!^L%PvWj0{S4h1;*qj% z6Ax#lC{b^#t!ySW+Z3rsfs9jAbh*4_y!-jjtS(nsg6b)|TUW>4cqdHPWQnJV_%Rf% zHp_UD#`Pkc;>aclyZ{$0Ihh2G)M{vy%;H)36p))yjwePCp;d>4u0||fOmXcy>gvnp z|7-t~{Ze3vaT7PC1q-0!MbBQI2#6*hcUy@)p)q66Hwh@K0`plkl@JqgWB9}QU}`8T z3=tjUw8eM)YbGmRk|cH^u2qXjLD8k_jB{wjMV;{Bn%VWXPDwqtAPJST7N;2`Ocr7c z8JJ8)u6%x$S;)C{?w`uMxfSdjF8|51`;`=(PR0^MZGwA>XHhHbIfuChmMwe zgrOUbz|n9%$DKCA=ee3R+O5g~cBeVw4s4@bx5A5=qQPgsgq;-j_zocbO_TDWYZtT$ z`%ig1pEwXx_LX(c0*>UfA?BpjPo-!)iBb5Is}dfiddD`l-5ovfDEIOhaagF!h{Y(I zF${36L+15Jmtz!>wDq?S`U_kH$ul?$)0;RYL`xUsamR6!*$EYkMUCjI;FXxi0V-NNi$t(pvWZbAha?9SB}>J( z(xsMJ3HOQT<58UiB#(Ih2BBMBWQm>}OFpL*s1?t^n36MK(=d9|)-;XQ#c;Rjprke( zV4F%3gfC)Q)l`zT56*?(5x}IC0{Q!C=_5`V7OI?_xxr(;!kKiYCF%b9kE(iWSC!-0VQ|V?IGy}YvMb(2gfw$(38ArWbqX4w3`k9_Y$wkT+x}h z;3UI@`0pl?&~o~$G(2^9`b9mBmYz04x9fRRGUVhBVaanz;9vf=Ez>c=ekAD^mE zjrGjrL4G@YA*yoM_<>O0@v{nuH2YmzrmOcfQ^|ptC9{eqr6wq@$wy6)*6PL|k^Uh~ zID+B1GFgr*Y)iIYAhR^bDQRlGO?o^WFQ%K29^yOT+PsWzQ{QXDq;`1;a%peBupPj-~$Y#m72UqoS{l&;(TJ@{bE3Jkjn}$-Rt;~LhRjS* zLkHPsOAj0Xzj`gqFtr(x0~)r>@=nG3(WDt;DGku$L$q~%wrrVLl7i33i}N5^II@mH zP`Wl?nX|v6ccuM(9BQx+yR1=*(2!avZ;T&ob!&?7BSjk}Atl+bG<$m%Gc=&G967lp znAAp8Y~fIdHuXS&a-07WG+dHd8Dj+(aTT7%3-K5NWJ}4|R<1PVX=W49F$rTfthfHa zR}19OIk5<>ADrTp#QZ#X=zVm&1~^$iuU8sgHpA#{fcUX=)D=QeMizu8Nc%n<=oD*l z9`6sHiGRUQWD>pjtG$BE*?hU70?S-={kd1vsZO56|Ko}+T#6`*1NFf=r57}q9Y@42 zayWo4hJ(_+bypzhMuUs=i$c>6-ZTnZCO)302s5MDy1E9#--ptGsQcp+=cYhwaYj)a zHjK0B5)!>+p=(KAhBT}p(vqx?oNz`akfWZSSX1)2`tEr{tgduB${zVH`N$#(f%Cd# za*NO!=dKUzZ(bEINqtcj!b^ZEz)tx@j|_0kJ2OLK9iqTLpar$1`~39oAuV(?UYkw+ zg`riAnNcHQN#Q5MJ&(dExu5`J!XQ82G^c4{kxfxf$VAziXyltBm=IC#0qP)S&FsY~ zCYgz6S)I^2Giba>JBXXX;l6LqWm+*t;BMp{YemV?UN!p;z9x6L7WZT>YK2U{AP?Y0 z*vQ4CRO8BZ!wj+bvxa`b=YpD0SBsXUH_UMdPBVs$-^#A7_T6)*bc|l!5U+S<&Bg@72z%RG>04vYs+Twp(NqrT%8DZFCRT= z>c*&^@XIsm;4zHTFuD``A4A@C0mw0xq=}L}GK+H9q+Em(P>(o@a0>N7*@pBxL={zO zdKvFd;}#kg3p%E_^15)1-+GczAlgbNJxf|j_llQ)6>Pc7uar*bSgS_4OZWwAG*o>OVH~!fcCIq@BUiKv1 zk~ub|VLZJM@&69nFNkRpSC|nHpSU+N-_@5eXhw#AMI(pz%X)7#qPZW*yDe*Xx^dqf z4Z_ejSo?NWIvDA3BKex|H2b}jZ*5<81f1=B{SZ2N@wuLwbVubef}h;z8~26BW+#SJ z3+`{_Ljm&p^Dvp2HkEzyqH_vfZx{&e74kV0jo|ssZ>#7PmQBFNW!6~6gex%H_;R0` z%^Tl&SZY*o?X6)vB5k$!tIEh^uHe{p@YAQg_j(x2h*=5SHUh?RxF(FnBFxRfcBUm{ zVQe^1N@@EMe{Q0&f`)ksg@Zy6(q~I74X=j|# zRgsYY#g(Dl>w4{7NpV(E)zD78{qOgA2e_K4Is8tuyy!O1HD;QBqG*axIUo9Lm)DP^ z2FjjhEmL^6Tn4A(Uq64yjOw5;{cHmlSw}5B(VbMzm6*Aoa1*r*r1WaOWNG_2s_84*)~A_Ozp#jPhs z3r~GApDJ$%PiGm}pUt?sf;QPm%S~=qfD8HZcR-eC9%U}Yt|XV#RaIRGc{w*`2rx}F zm#aFD)7fIp_8ja8gK^(Nxt6U_?1NIZ9X%_om(`47-))K|1}WfN-0fq|l6ZOg5gTl- zSO=C4-H(5hR}(UA+JO5j&I&sPr7^xhUm`?U)xAVqs}W-Xxg^!&0{&UHA6^HEqS-L7 ztacVTnnjqaUgwdW&TQSncEY>l07$84l`5vSrU>)Ww|E~yn9}p&(N?m4{uxU5;oeZ* z=@Wk4W%-*3*`Q&~E5a@_Vl4BYO8UlFhzOsimtV(jR~&v!NfL`BWz@^z zLJ+}@ao|JRnR;ao1u2K$4PUrzcqDkH(eeEjM70tSs;+HxH`9kCGQ3|M!IINsu^Z>!9B}pIi}^_@|~(TG(n`Zt+{o~I7d(c;4FI09|vX$MKEbadg|Ps>D!H@wrz4fePA)M0zK ztj)8c#>J_DU+ixL@(i?X>1Ltr;Je@<2aBa^AYqfAaHNj-0PIT?Z~`gkFg6ZDw~0GR zc@THVnO**Cv_StyS=<1-Sz(Y4%LIRa&>ue%i%zJN@()ub+375_MwLot>Wa66?&O1G zWOZ5LAI3x1ypW7(Cn*@51AX=`itF@eA2gM=#6dQFXzCu})?(gmyY~}bJknPFdzu3! z9wQuDn{@8Z#pdep01U`)yJZb?hITdApM@AIQD$fN%QiS|{SO_lPBui6L&6!-60w2(0D5QgD zkX1Qh#R^IsXZ1sIn;hPPViyJ4OUn~WOglE`D~g$WCf{X~0B=j6QfyZ@?BD!mv*iE6 zz7<^QgpoF7RAcwNrY73s*18J%)MbKpB@?j&*0n!pFM&}zpLjxJbgCj-cf&|siJ9LY zy(76qUjl)jQ<3Ds zA&TM(gW0PC$icpOx%P(*HC0N?95_Z|I^O!kv}9>02#m*MY}XoV(M1FmnedvqU=gyC zEn+1H11K3<%}p~TuEMf@(%8^0!Fq$Pv3~J*QHT)SBZOAjmF`K<{O!In-uSB5WYV_q zrLhWB2?pY&iF6I)27-`+J;hA9`Ml5uxcPlXB25m!r{Ay(Dglr&LhL85{RH-5WmV5V z@-9#_clC}EB62G%FPk;3V^HAsezs!$4pO&mOp+W;w{TkHD~3nnfw8Xc?qOVFBS2P5 z+^RU6)10&Ln^1WP1q0&uEG1dLGn##ETA*uLZ&P9|87t3lIh?Q4>_4@}=hfXuDvoL< zd=vq|`7MA+MdReKM-&NlG|%%lmAY9%wMJTiIhYIUtf6iyAwkj#$3NBCHDLp|e~0Ut z*M_;viPMKyAqq^``&OUB6Fmk)m6!y@Nod-@FM{T}FY-+s7Y+Bxq0`U~|oYV_?QxI;hM zd;j2nU?n*IQiic$x!a6ak&1@4Drrs2J*`-B02y0rGm?+lZmgl?NVgU;?U_*-EKsZ~3aK83?Q8x;Yx9pY;)}O|bD#S6FB7LU`*>|2 zKP4Vjn3(hU?{b^ci0~6i8`EZc2Sv}ZqopD%Idv$8Ce`RDqg3iN+Q^-!{Gf&W(i-cs zJ}5+n66>TxVtbH6uthS+Mo(rynCwbNLPA^M1$mHG8YF;lJ&S+ThZ(ZKGe~#6v$&8yn78J@ib9B9a`O)^}HI zw>w(*9)Y)y&s#jFdxrtC&yrpxuLI1VU)4gxQa=Hr?~A9LNGhUV-$OfxU_X0DB9})N zJ&x_KE=$N!?svgGF!4?bH5Czn9QXzeLWa0@^kFrfHMi^$9L2q}Hx7Wlvm>4@Sr4aG zj8PQgJlUWuB`$PdlguR8Ry3+YGiv2`0<82PC}xhqfTD90MM?RL;KQlgnPCQ#tG}@l^x!RzXc|Cc`Js-{xpQ+M&{D%f?o& zt|eJXLYLoUK(}!V*?JxPBB@%UI$>0iDK=`y*@U$sWDZCR&HEeE!_#kkw~{U6y|gzt zrusTEOE64`2ys1$8pqRPhAr_<|5l#x058!vZKH-jyBJ`LrznK`YF1Vz7N!8~S`y&3 zczZ`_&n;S8qTE+B)*qXiVPRF_)6FLsGj^z$xSX`Yke_|+F~LS z-rPpQY~sZSu%i|xkA4eT-n|90LvOh#985pTN_jabzFfxh`viO?ZIlBqe*#T8zE8Iv zYl&WON}G%~zdN>~{|6>QNNM7OuZ}FK0mW>&e2TntzgX#7E->4I;c-F#_3G93^rm!+ z$am!J$Sl6T5ZU#rB=q;P-0>c1r)}m_>s#roWySDl=7^$0r)fMnk5q)r!(GwLa#S*k zLZ`gwk+7Bxk;YzAGTY^r^pD_~alobZQ>;o-p!>1y8i7pWD0JGFH$sYEY__m=aK;Es z5tPj06;C#9697pT1iPloFOlcZ0-3_8=30kBKu{+*)D5`lm@73Cl21 z1_!!M4LOk-bEXz|e^)m;uhncWPl4HbF4%L;0F9ZXK@(IVJ?8X;+Da&yv>9crv!*mo z4}l6omHJMoLvQ2w(^-v-#E=qy()-{T#S0NhyXOLj60q4Fe-o~V?+@#Useo>;#3@4< z;HT{6qIXV~HxP5me)<=&_>;KG`e4vgxOpf7H#+($-|kRh;_!-TVbHAE5k`&VCt(t$ zT~@+ebAvh5(E*O()8IwosnYSFrARj*niAZFGH)zTHAKsq6wSic3z8~Fr`X#p1?ZZ@ z=8P3I03DihJDrXTZ-z!14lk#YpV319hCUgHUiH04G02Agf9N{URCwLaYqJNFBsblr zCuy~5DqvD8>)!|Qy1Y-auxAEbR*vOTd8$23zpQ}amYr9geX30V)4i0NRc?2M4_szKBGh=p@)xyR0(GGxTzEk z{Vv27v?V96Qtc#L1jaGiBt%m%GVh?RD6=}(kbh;&#$MYYtUhbT_%c#D2wo;brgtZZ zRfrM)4L_qM;ZHjlTLKXJlX*XRn=T2PY=ip#a?#B{6RF{F?di!fiUn=8+S$NLnu;q1 zjf>}XAc>aKh^#rDYe9S<8?zo&S%9S(GqJWovKjdlC4Tkaq_#9PH_FY9%(d@VtZV{> z)80E+CMCRjn$$8>?$5#k6a6Xb-NQ9M|k5O|~35x#OEQ zF@R7T_{3fMCAHc?s%dv$k^#8C1RcLYm;m7pwCYg)1Skm2PJI0dn$1fw9l8gRc2#zC z6%d)WX?vaDx!qieE%r0l?}8UxmwS0`(@8v+Jgr?U&DGL%RT|Sy&^U)&im3*J;kZQqrID zS%N>U9jT6;F00zMwikqt1X+cmj3U>y-e1HShaeaaShow(OXHTpu^}1OYs*}5YkzrW zlR!nFj}EikAl!#j+yK64jxwo~sX3CFK(cQLg|dkpugDM&O*xkcQp{6 zKK3V;;9`o&fi318`lg+T(J*!l%bIPTl3jv(&Cg-WciyW5}{(m)5RwP|Hvou-s zWMj0P9r}!|DXFk!R?S)XK-vXHGz2VRTsee)Wz~XYhRdW%%ZYg^+~o|WG+SZAhm<&m zt;O@ug1J~o=a*NZO~nUImKDF{+#OHDpzTbR?_px+4+3)6BNqC@8|*T5!Ay34L!hmX zid@ah;ftt_iMwq$7zo%`eAM`=ETjxeL8&YG>#CghU}-F_XZ)a}1#udivh4WydtB0) zK5*C76j>hAnID-SMA5~>V;!q7;uuowXz{}Q1(cHRCwp5ihF$F|HS=ih;zU6u1}H-g z?>|pZO#u1CmWVw1W%4+g3Ow2QMaM&NMPwZ3FL3p!ts3b3+$a{Xh8@X=G`9&xYGKj` z`A&?`(~7QnPlN1N!9z1q%tLLcKA&fQ@|-8!pR~T!D?2OFv1M3qLaL zYN@mLf;HQ4h3J|XT&Q6)1a&nfA#zKok<+=9H6T@b3p+w*=za-G|Keu~QQhkMECc4o zhB=vgI+;%>XM!<>!rUn`k;+)9Ax;73?fwEONCh#cS(S8xBf3@4`7q3YO1KM(UEdAC zo)_j(&X`C`_g|Sl4oC-#6EN>sMk;Q;U0ku&AR5d?9z50h3b5FsbzJ5p*od62Diq(( zbO5AENAbdbV($-H}(PHE42j)CQ&lT0uCTTk7%mH+#0 z8P$SVuc)Ud@p}LW`st1RYJLgRGa?%cA2GhjYzNbD5Pt-VkX3l0|4qHs|DU0HmkPR}_%wTuELq|D=-TAm(6>OCYvOD+fDc zNr5H!G1Q@k8cbpyrwD7XO-}14`y6dz-Ef^W4#^t9eo1T@(n25!>oX9yUXxt~U09tU zIpgsbi6g*&Xz%5gId)j|$EN^IORv~vmq8OXTK&WgaA<7IUe?xYD7CZ%8PD!AYYZwt4jzk!4y8Yc zl2?WpA*2hGG!Z9t_9lg(F@-1yZEyE z?Y#k3jqi3l0q;a#cTN{K_Q3dObRv=E*Y;8I|4?m}P2tVCEsh$eMmGfL)5$G!O4)}~ zmMXCNv*mkCadeT|MgMaKIDHNBIL%6h3E7!j1wZ*{#z?Zz7In; z6R8{*mdBvf)CS;rNrnI#TGc$UDm2xqXqT(UWXZwmkU_xC1j{%n#VOQh z)d&M`o_VpjFC2{|EgZYn?Sk5Te?Z+d{=-$qFUj*t6KgaZTy9{NFmacb#7Vq^(l*W}gllH*$86KB*LGxo|LG6j}`oDsu>u3LlZZEH| zBiaK0wSe!F)(F7pdro9$W2al6<$r1wakc!^aRL6`U|>b7&1+#wyk8-&z?t+ewu$uV zA^3O>a>J4xt}$;avCwzmDC2-O&)dk`+G;syZj-?KPRI9nv$}WZEI*lzE}(IA#%rOp zM^3AzQNsdH(iC5>TG6=wD~PcS599FtM7P0`proX086cA_qkGmVfmL(?#ThR}43((R zAB-ZaPH7T>F;;^9Cn(mXc#(i&CahO;)j(NfJEp5#-nBX-{UP} z<$(^St;ROp$7NSQHq)V+riaT~>opn7o49}cqkY%DDbFlqSHILaV8_;y2flO(_K$9n zm{g4DGmtG#X-9qt5|8$|?MCnU;>{WqEqaB)kz5w>C2Kk--r6nID6t+hs|oIeuDX&G zI>d?7MFF7%IK}iZQ=R0jY8kT)Tf||RQx&F@a+;E-q(;(f8^PEAN=qt?VF^aZhZ%ae zXdh(8u_M!&qP{Rp=(dAwL=i{Oz_WHDBvUM^24tidOdzgbw>TbGyUq@T+XM0~x z2uGdXtY`QHAIWaXGGmATCs7PCb@^=f(oN3jV>TBi&a&u{u`mG z1!cl6di89O|U>HOQ&E%bGfx+DE29 zHimrm+LFDmRKbQ-{8y;@kbnv(Rl-%evm1rvi z6VfD6%XMr-E^)}^1jabFsX3)X`?5+vHAoJUz*u+Gpd&meun&EI3?OLgb zk5o$_8gmXriaMu*Fom2QW_ZXH%AwR-If2KJx*+XhCL>iMkM2Psw_zy=TP+UA*JyYO z;z%aFt&5@#1j6$lSKEVqByv{No1_D_D%zhmi}Dtc9>{?Afsw`sg9`sUwajOo|HR3u zNn4FZo%r__=MS=oN@`S84p7xO@l{@K^ZQrE)IAibe8!n~Ov~4VM za2`QL7gNvZsTM2?`!o^wyXj06Sb1#u^j*isRjSO$on|K##q%2vC{ciZuM z(O!)AV6d*(GltK4N|^^D>anagcU)|n4QaN#KR=1!W0>(MXQ)CJN{4wo&2dgQl~tm( z4evyDvk?!b>A2Nn&j_$0pozLXLi(!^6P_HNCs)PLqzhkVD7muk>6Ok+{`PPnVv|NA zx55ebdiY80Pwz*R*!Lrq?}HlfO#2>u)4TYqGj75=mr`cfmf<%I@p3Y$yeaWeoSGb6 z#OFCn@AYX|mEQc2cpZOe?dsOruu@&4Rh{#KQHid7)1R6%Bw+wjPVDftA4ZFCy4@0f zZB_It3g!ag!K2%3{I3uSjV!C~qaM^Wjz{horNdzyv#)Gf{5gy9q-hN=Y>JjVHlIg9 zo~P_S8LFwmihz|7dVc@ z5AG8>Q*$t;N-H3G&YrxnsTO_aNTsNp`e87uQe*m)l1-FwOL#d1&S$ZE2?63~6Hjde zaRPSQ7;^Swv?PCezVb$dE&@V`4Z>5iiLVFNcfmv+T@yXd0v^X zJXS!>rDW$$$jDXr|A@NGeH>AH zMyXo8NJaOpId;)S8hxjveWWxTI5@`TEVAorV>hzj}aN5=t z=>rL|ty^o3k0g3XB-=>Va;^Vryj~WDb+K%cyj(ZsrI0z5wd8T;ve%v*734gHUMB+< zi2+kHCy3A-Tdy9G1C08yXh>Ra9%5M_q)jg2Lw+MN>lINoDhlS8g59`&wKKpt(U96y z4iTOk@6K3HqNl|kAUc;$7i~w@UU?2tpCCv!1qH!$(PX-4@4R&ckKODzmrzC| zE}(U-ismx!8RIgC0_~TVcftoAuv-Sk4jI_SR+n%O@e! zAN=1Mwsc0d^cW>Om^L!98>}3Gv9{9bLS}w3vGJ z&sJxxr8v*j5Xe~Xdd|4RU-xKH31 z;V={boD%8Gi^TU9y$0Os&oEE?KLDseSHJakJ-_jO`;#ZTz>~{W<1#MeZI&e0=zq!z zq1U4SnQcWJI+~o#X^h$Cj2KHCglgf~QAcjCaMZU<9v$;?rTLy0^3w7A_8!N-`(NgV zKmIT9V0%jS#Y4>B`T@TFQ$NLrCvfZtUi<(6AOJ~3K~&%^(Pd1|ESHImfU2EqU zlOgY7r6K2xxFyCcQLTw_N|Cnfi}q`-MlVTz4$siU*q#Ad##GmmMgce@%exAxA8yxzTeNM ze*MR|({o%!kHxziB2u7h%FbT3P0t0m= zJPsLpWp_*)7)5i&>e_=-&s;^kZp@-ZNMf)?xHV~r*S2|jbBFm>L$X4QkkqogQt{=z z9Zu^BDP*eSj;k>heTwIDg5uEJCv<_r9Tr?mGD*?&74DyNfo1SZc9cxMPJ z&ybXQ|Cq`3h+>I3kq5zBj~y#XvLB_|6|y0%D&lEFSTv|fz(7t8=Y+~9oXI1|DnSZl zNu;b~qo^p}MMws0CPhKAB7@=N^?imMsa^=ww+A?=iFaz#h5IirCXZj%*OssTzJu_a z|Lb?8PyeY)zJKIpT*lj`l2kdP6%;kkLL10&Aa8Hu)M%qUhp|UA5MTT{{O|fWpZt!G z(DhTUz5hA#x8LF){FnbDPk;QU;>SMlKK;aZeJ3Z!k9qOOe~`UL54mYtnp;=+!gqa) zFF$IJ{BRD}AR-#ulqkIDecvDx7Z$fUoN+bFu9@(h;+k z>1V0CddLYCM@j}`2fXjcs?r0V$|=TF)Uz)jlIgqa7}o)wG;ErCtSG-y;%9wubERlU@2* z!;PwidWYEE=J4<)_l_PC7;q;~xjLIsHxY3ebu|-|SSLaY^R0>E7Y9D&BTvo^vo`Z^ zHV}?8+h1_hzfKw2iXla+F5sF9m8uv_iiK26!GDb=aLVE5Z-QjD-+M$5rK%E*6^!`N z6Qv{iKngNmZWQ-TD@qo@wJS6WesJXAY2THoZ)6sC4Mzu#cCPI0LHG5O@bu{V{_!jA z?fQqRzg2zX^5^+h;AbvZj?1`=w^6&*$hkFD9(@~!*)&vnz{f-LuyHDNqb>*Ht$psl z?|=Qozw?uP?9P{YE+4_}E{DJE{ruuo@~v-vA5ZRnn(cr1qkPA`FVL)x$k`HKo$x^t zzwmW4UW+@tG^y~~(Zz~g&CKJnsOiWkLnk8$Wpo2c3Qo@3jqADIS=W%YHI=GIFo>Lh z45-$Ky3&3%!G#26f8uUG=EKc(pc`Q(tT!y~lo*?PgVN=sA=YJWKc z+6BBd#M%+OBUs6v+&S{fpiPgd62?@7<1KP{h_tx~=ebh#Wwk}2D9PxXV$qmb-k-9# zyT|m!S1~?Nd5;l4p1&eZmk=aZG__K>MDRm_A8FxJ8j4(?sS{a*s_tp`k1%(ykRJ-^ zV2REGT(eoKD|h9jrRi$@sqFa7(|^lV<1#MeZI&e0a&O5%FC*g8BsvSK*+f$I#%kp& z>~_nYANdJh{y%<#Z;B7>!Q@0@eKp=Zc*2d}`f+}7Ym1}r|6ll~*YB{(OIGQ`E$5P> z*~!h9-r${k!o3De4fKJmkw^2D>Ed8bGYNiB>M?Q1i2MwrTpkyz$Fu6_+<%Jd97q#K zG4Hj>a?$wIv{gjS?Fumw%}q&cW;G%rYm$0I0>&LBv!Vv; z;};aVtW5hzvlwWHM4lRY41ax}P$zf;rn2;l1uYS7Z*l)Ih*eYvvN|;Tv(F)wB~2=Z zNk!*PXo;sVoCvztV=G6t72$Nk@=1l?9g{*lbwG=8#%7Hi+4nFa2`h#s$13oP2 zPc74Zi!qU`LQD{IB=;cElN*n-fk3`z(!YPaPO1zb-G(1^J?G`fK zKGu~J0&^Zm&u&vV9&BPC<+>M>S^5tY&pLu|FTUSv7Q6a!90P$syxxMyw%K7M<0^SwpC8NQ9Hj za9~I`!8HR~O-P+%@vvt0&K1TuTwQ!SQ?yvqGaOZ%zIuz)N0#@uP+6c^IsAne2v6r+ z`OY^&;58QC~z5<@it46I;z^(v8`F!o4Fo>bZxt(5|pYd@?`TT zM{w_yE6ZxQnyM&|E2Ue|$uVyJIc~q>7EeF9lIPW_oqr0mQ0cZaY8r>h6MjB*ESDW@ zDMMrESP=){q-o0}x+hR9$V+8oi- zLw|Exy*n?z=XdH)9S-4|Z?UN(4#ym8BtQ2u^|c$s_gx8}0Mt!rhEoo!8`w!j_`nN1 z{>i_~%lRJLK!_1%1-!!;TNGkO#UCUH(2?%3mTHM(Jy|>? zjr3($n?zb+@L?0kcNQD32GBz zZplH(LyJ@gbfpWI6r7*LiF5Sb9G$jcjo2YtKi$gB@Bd!zXw4%LtX(3c(S4($L79E$Z{o1{HL_%2 z6|OQv$^C-KPyQobu4b69fT3gQg@Y57?VN2-6Srcqep+N@OUkUwt=%m)W`A}Yv&i*r zb*SV~qqVqjsDi2~GcFn=M^px|YpqvD&Naq#WtORH5RNGor1XeXXLOT{8~rGc(c}xw z0|OEfS&d$j>lW;WJl;kXd9ji#c2z}6j||onF$XiYQdKXuYA+_fvBDfTREZ%_Xd=c4 zQHlzWea32Qvl>P9%tv&Y5`SSR_)^~uG+iJT8C1;a3b!}qQHYqWmT=m!I}BXe-QuJ& z^xG|uA3f#TkeKI$uN+-tiLED3Yx1N9n-^UiPE1KmVlZt_$d>*|O*kx0lm<&q!s#pf zEZ@9N4n47hG*rZ;1I=idK$_4$c!8tO!q$6V2Mt(bvDH9W8di^P@PEeNe;57ZpW}s( zzlLiPZUkQ`7L|-@CiaHPM!e5VpF8HE-6t<0KB(A!abUWia5s18ZtYC@d=n)3RT)WK zCdtdVjJHXWT-zymBdk~pVn|ls2!EP5m@EYo{Zn6>ZQn2|O9MGf=c(r$eHSg|CW%e7InH=;(WeheuqW zHVoHxSoW6L)ZqI->!F)E`o%!CKcf!`=M^{ggtj8JHPPEZpI4C_4J41PB4}V(c)CYZ z?0e>jTGC+99DgwN5n<5~SB~6!&;bo?v5@u=KV9Od0bdEe5pMj3SBOi?{LLLmN}ONi z$q1RxiN29@+h>Tz#)AS^hvC%TSJtx&l?i@U^)GMXAk}%gjvAs62GF=#| zbu0XG`M8YBczYzt^_(wmcnuEP*U8j<<*ej5x52pB%zv)3=MC)-{7pXkr7tjXu+)Iv zzQRu9h$n9}r*81e*{##vtmyoZ=O_E@x}|g|!*eg{>G1(4@s!rL7&pbS&8#&l@i4}b zQ12;}lmus4(a2^ccA=2M6fWJE6iPKb@)gyh*&`AWS(J9@XmuszQk9(L)|#w_l+LSF z%~es{F@FJLO0rGKkkVG+^2_-9T63meBm-neNmiw;xmo|BifS5(ikh^5k|+k4=kAB& zPN`l{&4KgGyLR`;sd!p60*m$hQA_ePBd26j6ZT?7UgQWhCqlNYQf4P4T+*WP3Ye6! zM{{;scpL}hjbq-^_Z;5d=g?$)YZ0UPoT*|YtAEm`gslzg9J%%km8Gvta>Ug!VX;H* zTIyzwILJK=52vI!FN{&6%-;JZq>k?Joy6rHxethR7z-MAsCJK-JpYi{JD%X%MCvM* zue_6LmT9k^V5W~S=`P*f5Ax&_A7OIsh?uETj>q;uzh+6 z9{K<~@rYIHbQ)Ip=~Fr~i)u>lE$#eRR(~$&p_#B{BO!rH292PD(n=z^M6wxc&czNk zIg-M~gt`@>D(6|bl9o0!@H?26Co^aGUdS79r!PTC@Lzwv3 z9xY$Hn@?Vuvh%WI^3H~E>{xzAIe+;@%hPXLvU45k?9sz5hBsfr+J@%Z68y>N{LxU) z9&qK?eVNJg2lTxm9)fM>X!V3}e4BW*&ty9AuHXK1tX`jU`04lX=qKNYOz%_QUSMs5 zh8bA(O}4IGJDGafKY5sbB7gcXzO3IE zo%}45qO} zs8Ys=5Kwg5ad#DInP755(^pgvGA1uc;efPQl6}SU-OqDiKr~YsL9In7I6R^&ZRxSNOgWkxP?5G;g-uoXBJ22Z%i7R38r5CaOm@D6Ohi1Fz?>_qJ zmq`aVuF25`S#HMw(xt?}VKX-oef0y!pf5&f#ZX;@Z{EQqAJI*QO}G3OxPf z?F)ZZe(#ug?67_)udlZbPp<4X{$b_xfqU-W|8D+x{uAkaz`wNqo!h|g9N^FWnt!i9 z^346uUM_W)@ivI7TohLh<71~^b-Q(A0cNaJGA7{~T;qvppnuXJry=^q;bOR5PgU(z zU3pK)%fe~&!f6(25yUD{gb=_xLu8^Z4@riW-IFD|w&Sq~(HnBz5?sTM?dfwOB&9Ym zWThIjpHk*>QO;**n+bB=FI;`U2;&^mt3s=u}w^k!m$icM&(K z=tVK#c#DHi-Qyh(o^W-4mpj#z#~4zKn69H*^|bH3%70Ro9V3L+qd8NH;u=eC9ZN54 zRVHcFe#LOK#jp@e-Q%VX)t2tj7V&+@m<-l7?7eGF96;+tSQ%zF?qGewO)`~%VP&wh zLqr03@C-*=bVoacxr1Sg<2$e7_nzYRR!m-eos(BSz}EZTV7hgJHN%-ou9(Q_s~0QD zcK;L(T7S$CXl|TR-K>Ir>E?WS=Zlu5SN9j>-|qk8^I7rPxqb7WhsENXp1J?Y%k}dz z-UjjQ&)k3Wt4)wFYNE16V~!3W08)pEuhDLSXTid%A}&m?UDKme;rg(mw@_D(rhz`e zH75+FEX@-yERxWA%W_pA$upU3GtY^XJ62h#oPR@R6C2!4wV5T%dx#$fh3G{Blj_k!srGmloyQ5OWMdK$y%Fmwr7YnIaP>> z1qfs_IW=f%ih7SJ+ODepjDCWc3kF%EHC9p-@rYW@sOBsQl+oQ|?c%ZNg=OTT_hcRu zC4Z^dn)%U?I;-L-^GlT?z_xBXuTi#V)hFl4QjK85U`a^M^eGd&f$c8P3=x+Vv5LEP zgR9@R$E!c{MPB^u9ll{1Sg0Y*Cg}C6^zVO(H}@K*(~(?}l`1DHYsrmAr!}GRnA)a# zsf&*OaGQP(t_rx;kvgTjKO?LT@J6x5BY#+2J)lyNjYFmhUq=K%RM3@Wcyfch+`-R| zsrF8=yC;NhARX?pcyNX7#SxRO#MXQ6a{TJcEZ@A&?u#c>O(@$z&}3LW+`sUbHM=>7 zg9bfZVx3~I*6LrladI-5Ox&{i=tmy8zxwA%w?4QrFIE*ug1BF6P|y3Gxqm4(dw&^k zhj@0(@QYuQcIyUO)FDJI^DuIf#n7nH5-hCYo z(Zsn%)fTg5Nh1NHMMsB`l7KuTWo6^BlIRpwF`C6FTC9W1=n_Unp-rLoO(mbTj?fxt zB)KHW^`J6osl1(0aw?t~X5?Mw4Ww+Lhj8vTB4uLg#tho0#1Im82pG+0x_`2g10IEM zeIK7XSP>sD@d^B1i{9KKd&9&Uyj`Pl4b+~&SaR*Dn~Gd}s9bJ^(+Aog?a`fPru&MY z8d6aD2Rn2RD`w9rxQI$Drr46OO#m`kGbvT2{M`!dfB02~2iJ-7DgDtk($RIoktYYo z{K+2oPv6PT%SUXz=ML5G6MyD+ULbta9ojk;p^0cY{NfhjbZg_mDfSwo}hn0knj7onoI5in^kcH{_*Ad zc^Pkm_~vKsf8uMgQ6uLGCL8EPh$5sk)WdRt6Ct{m%C}U;Q;DJ4o_}&;r>m>6?Y16R zCU*@x!}9R;1`gA#!!4;nMQI1n1nvtAEbIeFiWQC|@CV58l(2JjW zt(If8%x|rpJA6b1r-~OTZYg6|Kt?IQb(`95?3)>nuQ3J@!DR?BGlT^Ffa?Qph`6Yj zZb=#fGSA#<_qg@)%j~@WWv0*VbHyt++CXbdZ5Bd?n9-=^ihs#Fa$A$8HL0!hw&C!U z{$OBrtf+x%R+MnUiD7xS1~O_^3^EX@TsMuXaDy@q2*(dpswraUXsI@X=@X z^T{tWd;8Pld#?B682AO?&;By|@U=QGU+sQ;wdbW8e`j+W|Kc;R`A_&tbBRCrtEnUn zqf$$T27ee-u_|d&HHd*7P4q(`s8U_oN5AKL`Sfw>-&9K#4mm=KYO3l;V~}%~3BZ_1 zUwez}Zj*lSZ}5Te1-iIoz*0~5us!j1I?kuLtoE%6F)7or5>*PA=vilsbqX;X1pdA- zDYPz0K~^J2(45T;Wjd+ z4L~!dvxtz$Ot`4bqH09T)X*s6vZ`oCRm-u9Mf>1PhFmrh`2f{{ zq0@39gOUe_n*_ut)<%$sH4!iHiGTl_`Ql&t7SgJ^@VAi%p?^H%{twT1{D1xHy#J5= zJ;XS=2Q%DzA2OV_^vjmVpLlj2dBErYr+<&oJvsl9>&nu-@(yr+is`rh{Jr7$=(VT! zx{rTo2mMQbkiV?YwfR54|JQ+k^)0?Z{qtw>)$Ia~rq6 zVNAa7+<5H4xZVHrYn&uMIR5N_9|g8HKF5DGzVFv>y#B+$Uwe!F{~6%^-~7x(b9J)-03ZNKL_t*J&jbI_cnaaKkN^5{fByV4kALBpIIiCZ{AUzO&$HYA!1(Xq7|-dC{WCl-U+sQ;wddtq$9?{% zji3CdPLiB!gu1acV->RVL0v-?JAY!=(+!HPD{M1m^7UVjHxnAEn6TKG4(f$=A#2}> z&=x00dWPAAG<3!=u&SmxCCl}8M%OQM%21giBG53}V~wcW^cLvW4doaqw2rbLBX~wa zL8r>bm`_d7ZN(I>qC$)la_*@7-$(o#4Cx!`yN{ywK59E^lhHgmtGH9ZH z*&(VNf8pkZ*UsO(O%ByBv7tRqKK<{Vh8O?1=Sl>QppMB5OZAnK2nY*fXowLu`=i6)Vwa;=ZX_u*Tplm`ovNnCP&^<9(mU{@w$J1EfSwe(9fZ>KiUmZ#+zWRk-o% z_tS0-Qh#Pymt6U+6ZB6#NVWMmgR>QJ*)h3(=5Pku=Jd`#$LX*8IQ>(GXMg5(Os^em z9%1yQZM$52;;PuqG5iT1_{oLfwhMfJw=upH_+{Xs?sp~dgTUJs{uhtq8Z2Z1ba2^^ z1AlYw{~h3;0Dqm&j_udi%zb|Y_%h(kLbm$OZh!v&8SoDe9pMgdq>pxweRmf)E&`J- zXnjwYW!&ktA9ZNxb%%u?0Y<Nz{9WLG?E=Hwfzx-Xr+vpl zVDi9ElNyem>FRMmJ@?$Jz>fjndCco_9P>Dib@}1$cBjDo_D^ys`P71>BxeW_rBT6Q zXn&%jV(1qZ>ZX@DEA*Gfz1CCijc9-Iw|UJc9+j*ZYGHcVZr9D7P@&)h*&t^Wp>R+& z6I{8>$}1k?S{o^fVf0mnHQ*;3a;s{LD{^%vsfA~SG7XWwF7zT)VoY})zWq&mKCYoB<+IyO{v+rWq`(wO+Jtp^H+BCIb zQnn~@-FMG+q7EA<=a1B5Hk9^`j7D7FA5Fqw4OICI?$41Ly<$s}5og zN(hm{D^8W@W0FD0l+@#rGI#}oM6%rAgT<=FX`l%q!L%LTy5B~k-COI^;l>z6^M6QC z&vfdUZZ{O$4Knq(6M(N62PmYZU-rnvQWgbGJwn@t*sFQoIR;m@6-YCMCDO;&Wu z)Rs&wtPCT*~p2rH7-v)-4IRZlM*51zij zegE?vbof+<_GEMO+FXFYwimARo89fG@mzPX_<;pmM!L`L`5=I;N~h0XY=mVwW7 z9{*tB{@()rm$_{IA9aCbe!P33{`TK5{QmEE2c_9~Z7# zwVALRc9^ommfxcp3e#fBjh(B=ie+bcS&OxS%6cC&2pZ4!LL3ajGT<`lG-HzpfFM}R zeqD_t;KR5NrKWmdow7&*LIW}ZnWkH6m*@gmO!BF5h)BkT7yVOQ@qcc7`%1R2{in2( z(_|XDw*HbB`iS_h<%6W{sxE1_5|Txh&MW`K!?u@*2~mx&l~_f@<2CBECQPPiP}-z0 zkfh{hq7D(%D|t|iSJX!|D#2;j>M@hiQ6fs_B(-%I+gWj>W{DW3BQ_d}AQYKJqhdqEqL@r@xllUGS}y~Hd>T2q)I-k>xM3Ui zoH8mL^)RC@g+^NXVx|rG)|7BfscJz^Hxx?-QyaD~WlVx0k`dGr4MgLKHV|DPO5`Iy z_F~%IgPKIXHsZ}c{SGo4aam3r--j!NrH3~Ted5Ekpsiu+@qhEw-SiB)iy;x!nR|0Zo2S)W3}BYt4o?aQ9buP^R*FUc-LgYuP;3Q9Y?@g^W(o*sIR

O}cFLvl$>4H?03_Zd06X#5;3AV4jHd>=D=cI^bu!Om1U=tg*51 z*sDj7L*~gNEr0O0kH}!=$E7>GrcZYsZv-BhyZ`81fSir@FWlb+9-q7amw~ejbNstU z%<+jMVB+Ikm*bkpajwgqviJ8K*N557L2t~%njzxr8n31fQM9Kau_iBG<)xuPj8jV> zG}Baf%YwYD!GC+|B1h^8+D=^C53%+pE_DQ! zd28jA@f;XTsT_!$FT$=C$WgVOuz4p%r)63M34kSrb&!a$6~>IwXoxXqclX7dIscnn zz492*zm2+nkY4WwKAuUuMG2jqmgRtinwX=it`irS^_rTFSlTi#fRN}i`;k+G*ul!i zNFwemLVr%GWU4qhATC8YLQp{xJJuz<7Qavb=?FTrc95=aYGkf|6Ya=W25W>Cn0SwE z8k~=*Z6wbqjgnhKj6MZhqcp07elIavbB8z!MMfOe44fg9C0-r2ED0_nxD3fkPTEQ9 zLol1P%{s2OxT1zEfHCOC61KXIG3kAcjl&f!B7cgS1P@;Mt(PDA6f|2y{?*_G)Ja6K z{fcJu0ov*lJpYcvwI1>eqx*Y(s{!8FbpWhZ*HC#e7wGzx71&G=+tTW zS#B3Uznf9>S1sKC*`p@@0{)-h^aARc5AoSm;5k26zpLhg`avS3 z_&Pdrd$F-EJVyYCT51qU64}Q?^RSSJHn2F)EKh|c%l}aQjfB`^G#fS z_6I=VfzSU5&fNF&v4CnhBxGeu|ebX^UMuAM}+m=j|NshwZtaArA6E%()V_J56{ zP4{7&lcEzM`S@k~S+wVqH%T+t_#C7})U8&A)<)T$Do@R5GzkY0f= z3YxsfiNZ9F)^E^O5kEEH0(PkaQGde55_)5Rgd7tc+LWDdr@?4MG%&uj{%L*UZEs3> z>N;U^n!V@yY<}c$Eok-OXDIrLHJ1JZ8&DV~mxhP0(Vw=}gD0-GyP2Yg=<$Wg-pQYw zSgiBCXyN|OT{g*g9x?N~UE`>Y<*4y@KFe9;f3UFWPLF_x=b^Y)&wW258GoJa-h(gr z6rcN#??Mjw&E%x^U;O-pZ965#pW@u%W=%(IM!uxy!nW% zb8?)SigC%0_Kx?8^+IB)E2F?43fazJ(CH>FLTB0==Yfzi$BAIcl zzu}dHx4pUkfLrENHDb7TiGQ7@p?CQ@=SMD_ah9-GVfJdGz%(Q_XR9-YW(qA%5426^ z&)0fjiJ1s2-RnsioJfs`+Ycux!xU64AsDQwv37&u(!Zm=^(9oJ@8;r1|AeL9fZVO& zG7p*){3@a$*==b;C{wVsh?ydiq1q?)BF=PUGY+!zMKN}r&*D3VYJZAbg2b4#Y-a|| z-5=h`qG{iIQfD?<2U%(8p2L{5zNUM=5EZLkrB4jF92b-jmC6UIsv&Dzj0c=WLYi^2 zH%6l3Rftt2R7&0`{XxmLvmk=YGknM}s?b6O%Z$x`Xs-rls?Ag}hwb_&YI zV1_l~2E>VFdU2JdbAMY>gw{LNY=daIo|MY;80xs^1EaT%SY6? z7RTMLu?Ud?Tqd<7{PNs$yrHX2z4NmaB)?+r`*{(A*WTgh4}Z;l|A%umyfX_mvHywG zzVc*uKv3W&sju%&5ZmisK%MFp3-{l*P)qzw=dpOW`NYEgeBnJi3VvPxG}q$UV_uiz zn8$If%iR!^$By#@?dFQij*dyH;G<$gYT}PkDMS+~zftKs)Hgy;l@R&gPygWuSpC>zvh1f!wkNV%HEK+h(MV(^6v1k$z1g6c z5XoYqZdi?U1eiO*PI8c?%`B_LgSvy1Up%S>LYv4qW=x0{;*h1)=WxT{<@sm7jkfwq zTDw7>b-JylMSb4Estw7|Ikl3cHjq5kL#0M3<}y2fkbl`8sT3521Gv@Ch)Jm?>2yZO zNkMK-#`SO}+4Vj8S&N8B2di@6{LyLOhNwyIkD26OYCDM{80#=Zd>d)Jr>bjQ}E3QJWDfgUu2Y>z$)t7#-Nj=z8ew z>`>fz0Ds+Wu)P*3JWPf9$_laCKzc1!d}^%GfU%J{pOtsxZMS|LS++F0j-3ymW&F$; z>TBzW-6h77wJ-QE=fC1vs0wcW(K#@YjgOtD-Z)&_TX}dpee%U(S0iS9%GO8E9e&K4 zZo=v*@74#4WiSWe2aBK0?|1>(#NEc-t|7on7Jq*B7rM>-7f1}u+4%2{fOwBpuN=UaAfKJBhN@r3C;d!17k zZ*k(e=QH>5D`x4|j!YZJ-gYK~23rXPuM{G*G1blLSu2FMQ z8C}<&p|hZ-9oYs{Ek3MrsW=L2cxEvEN7Lgcn!sgnLS>jL6z z9m7Ik<`DF&gcym&V64N6Ax6dfmbz-lCk-N(mXc7GpF zK71cfJaRuzzWgjtpI%}E#rQyOI(a-Jh!sqp5%P>$EMg+713zhKwyqOzDRC#m<`FZf ziQ;LuE!9q>Z6iTK0+WMa#bd0;4yr?+*Ggk+|QGUbu0uOfIm3mc@|lg7Z+aRgB&J@Ope$*-*iOgJ%10=9mhP{ zPXaaef&Y5p_Y-axBG|kO6#1vME}!WqYjSbeb07lxS zwbd|OUk}5zQ+odF*|6;LW`E!nCs28%FC7bWZgV%C?bG6hKSL~&GlyypTBJJUtj4Mt zy05VsyNu5e;{Y&NyNPwr(^TKY?&ZJ5-rh^t*?1{!yVgZs57BkdZb;-8wdr~~2Z5?f zj8~n{9L*dQ%u(4SGq|5{Kx zM8zQ1k%>h#5`t&awAg7wIc+IvAyYzW(P1B!2H4Cp>^pjevQ!2Jrlo8e@)$8OU}8uz zc&f=!b5KzAaz>(fiMldi8cn!$6Wyv|Cr{?t{RXv4v+bB%bu?AL(-OsFOoK#4g0l3; zl|%352fpVU`REV6ntx`)5+_ScpS!^5$qPLHi(kbje*EkB(06|`PyXXql9hqM$qMQ1 zFnMnIw*QFd-u{Jr?0P9Xe`8SaaK zK?imI+`=>bGm=ysN5EOL@sCM%SD(r?-uOviy-OT!VQ^hGd4JsFt2kgvUgSVpZJwwx zuK@Igcli1Ah}V0r+t9zc+f-k67l`dkkAVCZv$#b|v5zg>KaP1UuG2pvHO@asYU#QE z1>t;MdgcN$+vrJcW$s|N@7(fTPUkkA<{QWW0asuW|cY$rJ7KT z$C*BTvGu#R*bc$Oein+!1ay}Qht3{BbVf)+kdp$ClBzrA&5M8p) zlBA@g(IFD%0@{q(I%BoEJHW$-;8vlNU7ytnlhRI%ogjHfxG@V*Yn;ziJ917?rP!CC zOF{!i6+3g!?5-0!VS=Qk5Yx3qaK<7XnWt(!dF|;nfr)zZvY=H9Riqdci3HS{l&OPQ zdm3X2nSaF_MWdi%h#@_<7T#d8S>;$eKk+s!OT%?YFF{{qOyDe*b%qsArzu;tPN5!(5AkEPq*zoGu3Z%w&fz?jU!77kk2;>L>3d z^~f!bAMK#MS^LI|xwCV}ovyK{@Ulq$_~8y3oRuHm+QG9=A7hYw%N-!E`OIWKOM1;6 z=KKDIYkhlnV9N7cq2J)$=Fna<XIVl>?A3RE7S`o6oyT#l3r8Gg=3Ae>zq`$6tIK?v!WlCI-_&ZS6y2hUMw=VK ztB5mB3?dRjo|io-i;^COGaC(Oo^YIc!g2B=mis=Iaq97mGmmB5_jt~!j}*M<$()my z`t3`eHr0cVZP2^YpjDxhNlD&1<40pA+kZPIN_3;HS5uuNAp#?`l7fUW2|_$L+dS#@3XP-`3zUbTzKdw84Q1eBELvx zuc0AN(5Pzq`O$`~Q>RS0{?H-A-npm!O4%$q_8?g$gHBoJ%!9*X8)`8fDHIG{#_ud(9vm=cfzcQ+ZA@Ne0w%Xa zplKqe_85&s^+@zs18!JAk&)#YxfSx#5=UbOn`4H%HL^Xz`G7YLuY&EkysZ`5+~Io} zwHjS&A+xa^6ZSS~Hw3L6b}3-{o_|~yy{1P9ow&8 z&?h}RF{OU`B~UA3YkDubgpLo3CO`Pi@25Dib>!a{YrDMSzj`+`24jU{&62O&SDsmY zHqYD-cN@Yd8^6(==#Qez{~_=Rs^mjMX&QXVsSKbAtJC1q$5V!XY&O@`0-eukBVDoS9Akh~xKDGc)9@n~j zrt>(iby*z0dY9jSg3mx%%5}Oik(u^r7Y(9!t!fA{n-sWMwT)GsF2(!Sx3af=y>2}F zVK#zAb~8+;-lB4l-z9^J4u59L3N5I$dFTOlAALV(r@fe!P@AP_r#sYssvAw_wJ}VC z;uFs8r|NcB-qj6*^wyMOep1=^(Tx-YWs zk$q$1xlB^$q`@8|Rz0^2-@+HBWD3t4VO4v@o+CRwIHKgM@$yvFmT_ zn7q-TEf6H>)EJM>lw>v4cS9F&XUKsl#N1#c!p4}^IQ+>TcDcZeJbu^H>{OV##(76G z6|``ub7@|UM}w!DHh)a6?_jn(;=nJpjDm*~1Jo8cRYGiNL~(;Y;nWi5`CeGLJ~ply z@AUnWd_<(hDa?9qd_D}pbYTv_dd<(xBg2$`h%}x z>yKYdyRky7^CZCJJlXPu-kBYW7dNcE!cexJri$$Ll=8tnbboqSMBqhly~cxo`R94} zcfOVJvlq~4$k(?y{rZcXdF=#smYpjG_h7^N1D5P%=i8;H*T3~EKm2Qd{JUq~@uc4T zFQddSMp^IP1u301$=$CuDc!7`hDEq+xhN0yubgptG#_+2Vp)qSA(v)HGG^TTK)^4 zrFHpC=5d_svN*oGLqxu%oBMDfyZ`X9z^!wZOPPVj<$txBoMmg|MqNozt71*9W;vdE z{g-p=i@umTQ*zVbXY$=FW_mLi(h>2rRm02iQC zC3s~ic5tBz#aw;PFy9U#ofX!+J(g0S>JT$YP9SPiH=s0($8Uh~moZp=KP!VvG}TKe zdLLx6`+r)-FSF7|2~t z=#*&1SST~v+%h$W0n+x+=z&|rP~*o2TMM?V5S!!cg6L~RJYpfwD{6@Zk+kMMMHqOx|KalAH?O;iCzChOZFYv>pu3C z=HL9KZ@TfN=G99d2>*{n|7rX`eu(6n@ac>bFW@o1#|slB_;iM!BY_+))85YToRu7g zpX7Rvb&&9##@$}W$-Df%<#tt<&&E6;5r4D(>JdMGgu_knyL|E{_?rNzKv%!Of79Lm z|5}$%cOJ*RE{m__$GBbE?|5pG9M~|;oIq-T;4PsJRn$;gM{9F6LM2A2uRTw@w?SqU zOiKuzeX%pd7|EXnx>8h;5UyF#GB$NMm`AclPZy4JKnAjTYMyXSHpiKowD561;t$b+_w zIVjenlt%VhWU24*KpBTl_}m}*Fzei5ME8FM0<;r1?_^m!R}tC2UYM(A9?U~hz!ZZu ziOptJnbw{`)#6%184PWVcnvh838rdO9<99V`V;867di4y_Q#b7G#M zy^JbzEQ_s|@kR+@i>O=p-P5?;3^5hBmRR?gK68Tl+)ZpA@J%0zfOAcfevc)NK$L$# zP{>R`C88l|=UQnHpxP}7J1az4p}%#5l?Nx3eM52oCVQ76ZQbL>`(De*N8UyL@RWR* z`d%|p0v5S2g5C&U^=S5Ts0@-v?9ljn_2g4qrG2cu>3Z@3^OE+4-gjgx>%DPr+%|rA z;r{RQ!W3-ojX%ZV<~QxGb-)9v&Cq|>dwaWRG-V%?7xdcb+s)^>{LBA>!84DWegxm< zG3$iKU2bU1!DefW5XE4#K#)pCFMUYc?QOO~9*W#z1#F(v!@|y#(kF<~2XNg+oe;-V zqZ2*M@QiS%Z!&i3Ii}0x=AAzbGV5cMh;uiw`DL2%SL1yJ($I#pT)+BeF1&yI?W~{u z4X$miaq-b_Npj-BAkttYrd~-+4vHczs*V_k9dq`ekMclB;HVx+WZsN9F?nql1a;wr zI?v=yEZvX*iORV4tW6ti z3x&zp^$jt2n$10iSGG7Sh7o_38iSwKTrd!a1$I!<`@H+PoMi+|>T}I9v@8fcOIu_} zmWB1G!Gzk6Lfc-U-8@6K2U4_9cr-++D?{4J6?`ueVvkJ0ViMAxDN)+l1JwkG-< z2((_Im|*%FH2x&+!ZWy2W27y~jWB$0lg$rS_>nTYct4l_-Ag!g>L!1b8AZ>L6KS-d z?bVpoJ^XV$>a83%7P6%ZccNsvc5Wk_U;dz8zuw5-vv16=wd+RC{{PvS3= z`YRtJIb6J))cWzw3y=SadvDpF%^*1+Xtaa)8l+Jb9lDHZ8_1-gHHKrqwRpxzAR+ZH(=uuC<%?eSX~^-UpPQB1B_D;O+3 z7%(ZE*HeRUBBL9pkftOnBW@|;Bd8g}Zb80$6LQbq6XzMMUM9~Ys}JAg>dF>eS)&cg z#s^~mxKue*QB^yCtfZR=8*l9b;(y!SKH2y=ZtugqH~x$S$^Az3E{kXYV~c68HzaFo zv>7oDLMTegor;suu(EPrYk^|4M{uciWW^*$j7$vHI4TcIHp9o9ypgP|+Nhz3Lm3cu zZJEFFu14Nxn1IV6U#Z{B>6EvE+kI9Gp@oqgT!h168v4D(rc*Ge*vE={!JI6)x;j9|Eoa1_ zYOqa6kiN0R!qF}j)cuksFIbnBGRN*zb$!!^-4Ss&$J__mYE2kq z=ZQ6ZtJJ$C+wXldSy@ti^E0?ICnMqpp5BW`*grPtv>}coahj2xP~6%IZvD(;xjz^U zZ|qcmNaejz?m@2i#_ggIcN#y>?Lz4H#-D$cAgPBN)}unM5Ue3cqyZ#&8x+-Y5M^t` zTBwMHrSU>BuGn)*Yaq{ssY%^zr5e0iCZQp}J}P&1tFA5_v~E3oGVpn+J7SWKM0T>#YnkNjo}mfdy7Ump;w?3SfGC#44KxRo$uZ+0F%H;!W{un^8d|iqh|5@d_$nKJ_z-`(>xfOH z-CSk&iH8~QJj+r!AyhruyhrcCg#L*U(`!oD%wbaCmJRl#MXsFFvftaO$e;NiUTe-j z^-J6?0&;KsIUIkR)c)}`B>A_MZeN%H*GR1mGgkAx@nqr!f zR|I2`ti(+#Xt#=;39%^hb~k?$j4EXYdr|2rG%-+ngK0fFj;z%omh0y>?IT~*USUL> zY%&>^IGcyHwnlo;ww3`<$3*lnB%ed4Novih;`C_!(=Gr>=!iOp15wwKf?^TPK_*yx z1LZ!3`ALktmEDV9%5eD|yz;gG8&5s{)r|MPnz0VB?l~w%gzyN#RAhg7jS-7r2r>CU ztF(wr(T-JmAg*qXs4vcN*A8x#h{&NpDF<2oK8(5_0B0^A2gUt)c{xk!D{WQDj4)%( zni*0cg71zQK_m%2h~QN)Y7WiSbo#v^L}Km9sv2UFnzuH?8B1Xd<#NfiX&@6~d6p|{ zeJ#=B40=SCVm;Z@`UZaQ0Zdbp4JXM4de6{q ztkAYMh!U|KSJt-`HP@DI zz0Ce7FTc%Y_ulw(IW7Y~(%tTj&sn%t_LW(PkUL>aGSwwUyh0AHM!YqSBFm}U%;)_w zs_n5>M~Dm@XJu9lWmVp45VxQ(y_T?>c@X}kW#Zo`*$Kf6n$n*lKnDT*8P z`x&iYBKT8G_TI`9k3B-xdoMwLhq``{qW?=QE%%9G18E*ft{*b!o;fwKYfLs=GxKNF z?66eb(L@}Xuc02?=GnWmHWKaHIXp+yHs>-$GlP~DdNY4g&YZQ@KBW zK{XjV2ThrL)p+u{#x^as55`(Daafz6S?Oieb->-4vc6s6+X>663}1ep=bfSTo^AI>(kb%|{oG^@Ix3C>2E zP+-Jijm3W!4rf}tI*bM9JT?fE550)qiHm4&g6Ua|6?$h|`Y*c5^!jQ1)T6B-hME>n z2!>>WB8bZwp5I~PeXCFz;<&(`udz!Jm1R{<`DiZgMB|=d&cEf}xHs;N&w032RZ?ct zSu9yV>k@UVEX6&kjPcShL%+RgcZyytPF)CRQG9>3o$c9LgI)9GuE*RO2>~})TWhmd zJ;?Y?ui?fQzK;6g2UyRAb~0izbo5uxRLhe|tfpITD=$JhSki1V!M6e1)!Q;7cu~{{ z(Mo5(wXbe-Tkx5aY15Dd$8CZxV@yFyOvGBB0wyOxYkbxH+yx{n2>LR-Kgr74GZ3Gp zntXpFIyz60Pr)SiYK+x>i@vH&-H60}pAOZ~LA;Kr7F8i?N9t*#L+Az6d!w2SAXJ8h_9P3_V(>~xQnzT=iE zyKPGtZ)19?IitxlVgzeblg!e3LAz#gJC5}y*K*A6X4Bu&E0ZR_!r2=qDOUTm!g|H+BYos-gXtM!o?+Y+?-eyIY6QffCgLT~G6gTh)?o7%pIKbyDQr#HT%mn%2l9WG z9LsWUx$uT-R2Ls%=UIznU^BsyVXecM0*4_QrFeNu`?8koN)P|h40EEQce5JOwTi(d@k+amxjvhTujmPXfe}&!AF4btn zYRr5t8B^~yiB<9vU%822+j z)i48n1+a>SD2j86%TyZ^gQ6kH&;?BrM$$o|`g6HSwnv_$e9*G09haB*i0HfsK;BAtiD0mIgd6T@|u`M;#BdQ zJz}+qEH^1As)G;-YOuy*oF|GQL_sv5-lm>OFGyuj(=a&y3{9OuVJGpuI?Wjn<^_dI*gU4o0xkX?G7`}=)%cD5M@#cGCWUCbnC zwLM}b)Qr{AQ@{#!tNVYlLNl?0SulXZ^~gCAkJ`E4C6yh-Au>s%8?ugI)1p*Fngori z!!QMHF=U7loXNl{B7qqDU3~Uv%(@w*%uF@wg7EB_U8l^mD10!>;@F21_x`@DZ#kp%Nv5!x6rCe@nH zSDvyC`nYcX~#MAL568f;Pm(=x8e0vqxnS(7AJ-&hcmxHs;Nd*gFCNbW1T%<5kXnyT$mV>Lz)6Pd)MSE_(UCDTqkKyO#pG2E`WUA^smwwsQtN4+Ab1fAB{Ok)v;R@)m~z_A|3N|zBS`0 zqS1_;lU{#bN8FL5h+~cH!*r(09v8E~S*EQ%GSiRm$;0H%SZ2DZEs+5j5xyCcHJ9yno~8OGA5CqwQJ5kCop;4zcPavK;+ zLlBPt!BRPSL7%h?qtayt1ZbE$>*?{`W8uiADuziS=l4|mC+*$;&EWK)j-S=(audd^k zn?xsTa;y=eHAEF88U`mOjC&BHrQOT1=W5JKK=bwS_|(cXyH8{z{&h}rCb-uxd2ies z|2}^N$vMx#nI%(fa=d2P$uu;X;EWh`t<}?gf8yJ|9W!ufX>bzBcQ%QSKf(4#KY?UR zR3H2hOHck$7IMN!9K&g=bqwO8jmr`#>IQ5NV-& zq%>?SEi)c26O9m@Ma7WU0dW=P!YRh$$fJ^Fj?l~Ty^J<9^o)j~ij`=p)@XAp`0*C) z&Ux&bMV4x09MQ@#y|m2q;SqKPq{@*B%z7lMNym&tLPJui9mO!XaFg=HgxIv?7nj(1 z>_N&4*Pvddy}3-WyoWUwPAluLf0}>Y_ufx7Fdz=!wGo;6HAYZ|Hd#=UWG{3!=XGi$AgsZX&27;T%W&0~m$qU<4g z845O?FB|G5Lpixj_}~Y)_2Da0?CdeTeo6bgTePo#1CtP0&6iqfVL07ho2-BK#wNB+ zuE4ZnRcm$;L_9@rKr}h0vX&Y@slGA}9anA_)f zIHT;u;pW{}l0NH!X6vl>=;wcQTG0cWtNA2w9)gWg$vYA9edupaFnuO!uc`!}nkp~` zL9nyu4uL2_8k=mPf{(vKleis3=0jIAPMunXLBN-1cPa zmRbs0Ti|TTpm69grztGUPUA}JoXd(D3EMlg#dX@9^GIuu)qu$ZY#V>->qDxMP@ME+ zb%rzGr#UzWMc235y`QmlU|UoSWPPMi`sW?(Eto$0D#Cb3=AOb`n2_5XR|ZbK;Rbg6 z7;QhP4vUK&KRET;YAck>E$&oBbGc90wZz7dEw$K{A$sCWlU*3>%0|v~AJ)DHx!xQ1 z#^-pDT+B`^5N*}-nV)}niE0a=EMtF)-`(Ajz0FOn9{nSFzxo@d|K6KLkvC?y7c;&8 z^hlR3__g7Le29qYYrpZ6HkQ877GIQj-w#&00j-c{noN7e6ON(ADxp=na2|x-Mv{7opW@oZG}@*QtN~DnSdx#w1s6cTz^$ z>4J)M*=Q^}n=BHvOWD1p!eL3e#q@X9Cz-Oz&eg-t)PdNqAzG4y;tY0;`;rP>|D(=9 zypsAOqbh|tXe)_mf}cgGXVQMNzm0;JgPu}r4w#uSD2f-D)9 zAKIqfSVijzga&CndC}rc$>3~@`?4n)RXresJa(cK7BPQddmd9j@!%e#=hx9)A?`Wy zLdn-7x_Uau`r0^9roTU=#d~ku8~4WND%`4bB$JLP(puBtoDQ@$qh_kPGwMyRWv8b4 zx4%cJ6ZZbXSHz7s>GbrYA2Yp=J*Bxtm$I|@ZM5x6$n+vvu}P3+3Y%o;QU=;tUa1gcuge%yuVc#3>|7AG zcbMy3pX99UdH}bI*|#1$5F+n_m!#}5cJh2WcR_zUTRVYv*0b6DJeXO)O&PUU#dZf0 zA0X=l)=`Ltee-7%!G}nUk=;qn>2U?#qef6;aXzr@GUCaTjBk!P^O46Hj&_LShM|vS zy&l#2662SiW&7kZF>@G=I07;#sD~Lrbg z6*d<#`pAhX(QOlLAWU++1u43>R}F3`6ffEX@x;xXcyoX~H^nUnSXv3be{$TAbC?u& zZ`>RA#^-F1OavD{%Un7!Y^nQgFF2&CF?oMOv<1FcVtI3i>C?aC>blk8%U%)BonMvl zSG*zaz4_IXjq6*iTzG)x&8MYzwJOiu+O3PKVRyNftwM$6QoVH`Jq9r+YsYMdmMV<# zZH;vqL=LQ)1m8~OO7Cn)-FXRv?% z6O4D>LfyQDC=;?`LZK-${QuZ{w;0>f?Y`?bzRR5JR`-4D-rarN=hnxU*vEE^53!;o zGI5X+6eNNy5Ro7t#0yF!@`!}s38XwA!9xIvphyu!GEq<eNJB8QVprJyQ*ugJ=a=Wb9`fr|M>mQ!npFW z8NDQ%T1^^tF3C0L$4^g;+uv|I@s5~{3fC=B$6Z-j?OZ#?otK@p)i(KEdl&3lsKHLoFAYJl+=(eCKS#w4LB2g%&7Xh9-BO>f;g?>ol%pP`-u2L6Fa6S zPjHh7%Lg+`nh{^R5O=7(6BorE9QQC!AKQ0L&`B59b7T)FJH>P7kol337WN zv<7oWw!0-WrH(Gv+=zE>;wC=ZNzz6hs#X8MvX#0PokJSZWRm97D))~i(0p*toz2;_ zi$#~;`d%;pXz<)W?Ay;BG5wjJVE+0ElVAKJJpST=%inzJW^aEq9G`!E?DwB-CgotZ zS-o_d%a7hBh%nhZ>-)V`VtqU=N)vdupf zV8BeMf}f13VovM+q*3RKB6tUsJFL(DU5b5&$>BesnfyAEzf8$L0Wz9|%7B?eb%KsF zNn`+vs8)k#dycOPA{BokAYzC^3aELzR8e)9NoVwfI?a|6LDozMg=(-f^(iIo)QY( zCgF;5nKG^)Xu6&-WU_Y*V)XAn=H#u1+?#mb`d8oJz1w%O%MO44_>z59oEMsTgInz5 zn}!&hy8oF4@e_15K^BeONlE8t>DV=G>Zj$|YLgzlYvtZOW_g02Y}gzPFT6S%lY!FC8`g$4`Imp2q+3 ztv^>9{$swz*YwZZf&2$N^Iv)Yewn=b!A%?XCT`+0ktCIDzN#Co&eX&}t>huLIyqcG zUOC;I$7PD;A{#N}O}7_w({7||lj=wBpl9!}{@$CEd;fpfm)Bm?*_Xa}mVWN%ct0EM zJ5SZU|8bfv4mdjfkoLWQ9-f80ylLk&tEWx_teNOxxtfDD<1S;cdWz4C%FHX54ZYIw zSS=$kQ5Y);8!0zlYQh9X&EupBdvxq{ItMPyv3!?2e2L}buQK2N+sxV)DJONU-o|#T zMIeswbpwBGFv2M4$s=WlotCERMFWn2i1S5Iyqy+|I4EvuEC&r@))n%q(9c?EBNVM_ z)T|>sp}r4;j{RP9Xf~XKgfR=%zcWyfK9iS9zt<2K4VFvg(V3AcL=VL|v^awEln7D^ zB1*;+T_A|ZYr<>|Da&f;EpK|7KGS3&Yn4OI?%jW5d7(VM*s%Zh6Mpi&GulbZ>E4{j zdo$L1bC$D~=`^xv8=OE4M(-PjW=6NSKo8G3US;hA1ONaa07*naRQI}glI{p`NOu-* zcWL>}2dR1KXE;bR!VI3>Rq{G<@ZKKnJ9G3WmaHlGY*-37B7UMc=W46nK}o*yirRuD zBWHieOHDwV>f0ZBbeLYf{8{_=EOl?< zCT`;Q_e`?3?rNJYh_D7GZq`o``t?Ahcgug$PgCx@=gkyRmh=ok3QU;X0d3V=fJdxti#9OZ+;TC$Bg<{u_TZ z!zrgv{|z>me-`oulO|%eT^Fg$Q;}tz80r+$N!`&KnXwgSpyLWi;Ca8!=M}ECYSZ0U zZ3wHYzg4b*1a{ON8T~YO0N1(_Qn*t7ZX2*BqqUe9hONP~KoKL#^X?;C8c=QJ*2R(v zSQpfJnwVv6*f1m(eWp>Rb3#@lv=M*3wa3}ZM?9Ie#3s@{U9xz(=GF5Kx7V@0f6QeJ zFcc&>R2AnP+IVssStHza=pp^aW5r}fC(b>>bVw`qS*dVKJp z0q>_c6- zaw#WCg|cbH4&@U+zhadJwnoL(F3YX~$gNg_IF`$wAlbLsyY)Yj z);~q?U%-b0ME1F&;WdYha~s({PR%eKzeco?Iq(iG zY7Bj0J`_TqiN(mlR{_s#N;zBc>d7HzCkyoWfU_^c$6dj__n22cIOX*=@}U|M9L|sQ zpWx9p(nXK+q9>G%M;d?3NO*sa3}*~a?h(nq&w_D&(KkXsj+?6Z9HKCZbS?md7M2(49~PylE#$*g_G zQ<&Voz|WT0lR3IJWZ2-^f}Awi;{L#VdhVY!zjOa@`1|r-mtcP{$^8*5tn1lLa=eL~ z_>3h<%xWcOFaeTlXosK+D{i&!%VdQMgV)KBygywsghpdo#_p&TA11mt-)EUNu(^=l zm)X)ZP3DV1-Tv~{+a1f_`NyvR-ScwmOGtmCDB7PGo}+FM<=?4`-G0F+WK zw~AdUL<`?<*YJNT#%`^RId-RX9gTXQv#+Wk0of`+Eu!UwZu4(ZR$pQN`2S{q@e!NL zKV2(E6p^}nX*wNSuiM0%M)~75VP6SpuJcj9zs=Y6?~57HIeH3vBOE|S)vV+Cu4w%a zm8-t<)e}~0s-iF*EwidHXjHwrT2YFCI@>8>4JcX}vRi+0k@;$)rCC{anf)%|hcc>% zJhTyayhq;^CQW4S13Dz68?YEzfBr6yPFL{wlDn_p;i1$u6(@$wTGF&3#ejLo9qHE5 zhG0o3cw1tdhxAXrLOf~-`vW%3l)liNPMKXAeoxRYRu7X>S-c{lCQDaWTC|T=&MCnb-}n+mAOwz zmRHqgO-VQeRl@N*MH3gQxG&B(D?a(MK2Q8(w};!kpU`X5ocC$w=k5_PH) z)V6-49i{65(u>r!pETH!oDmWptt3 zfe5ZY_gK2BZuz%{I2*l4M$=`fvLcmQd3qD_D3a2w$f$sF!J{I$TsZ3zudI4v%3$^R zeDt{B@o&7sBPYzn(Rc?j)a08ob~0!A#F&2r<{U+22Vjr0hSCN!I*y|Z?XbyilYOs~ zyd*5&C7rFvYsd6JV5;=1LU%T$doXA5#=4$~#@U{!jEOOzeL$K42@1Kvg`vJR=J25~ zefbIb%{}Da8TnvEbLcp@|EM0G!W_w<1m`rYs@*cca-x$bI^Yi zZbfAO2tB&}bl7|F=6cg!eDQx=z4DKK-oDGSPSA(u=FW2yH}M%tk}6Vf(&Nvv08zEjF1yWgR__ahN~S(3p*^Fl{w4HAE%xEDpUC%NUMLFoc;hYoYrd80;-cbKXe`2^$9Hp zW96#X+N#w;>#B17l*(7OEmymi=yj~PQe0u5$}uPB=$p0LcEbqUrzp&O7~kXUQl=o|<%2ytAx zwdfELd>J7KclJ30d{%tpu{NNSNSQXINkbnzM?unJ(B2Kb%V_CYeEg6!e@tG#gx3fS ztYRUrBmD<^EWWhHH{)SsJS%^Qt8SQBok`-9ylxP0NYQ${kOKV-${ylCEMu36cqfsE z3HXHg4XX9HqAH}Ml%fzkf+sj39%gi!DH+O{M<(EBhC6I2r}x(V%hQLeNB;eL58XX3 z%9AHAadhjU>Hfvdo#!TQ;xiK47TzuasnwMUH^J$wTa?fZZo%>C2cCa#{Js6X^%HsN zkNrG%zHpbt)9=x~^Ct7N3y#A?gS2@zbd=3gK7R6m^>FS_v$?r7T{yd>Iev-BomY|4 z@5j83`RK&<^Ag`Z9r;N`i* zxvK`pWrqG*V%#1QGFO0r3SS$uy}Qv2-Z0a6gmJ3{LTK`<>8FXZSbQe}TNq4h8ch$$2Aa3WX? z=(HuZ4LWOCJI7uS7L**cwBj@mQ~$vk`SKyT^SCr6M2EFKL*LUsUQoJ-4jvmkHn?4q z6suLG^#L0kCS_dD*?tb5=D25i?+L;l`Rogn^CN;!IA3)#{y>C-y@k3E-yk)%(`)Aiz&r>(I+2;ZkWT{PvC$1+v2|QYkOyx3b-M}(C21q z)7a3c(pX_r#0QD7v37`0pOE_v-N6yl)>&@Ug*GtrEh~Q?Inl^uxvtz;FRY{{%q+t9 zQvzP2>3t$$QWKY4uhK?qD`Kq(C1KX07I8ddZ~w1j%ZAnZUt@iK%;DifN*Xb9BUNWx z8CD;}5vFc3`dDt;!hU4rY}>D&Mqb&rTerz^>kLu=@mKBEZPI5nUk+O}s#OSiZJS=h z^^Ihm?c;wbMmw@!gH;8viDBd8)3x`oF0}~7UA@!JRmE+SOStTF?$$k%RmXG~IG+X7 zSLnH?t`j9yQgl^aY#?z|yL_MLvo9o_LP_@gHkSL=z4-Q1##fSReq}p+E;&@GuZNb4b3A=!W-rVKP@c2@FA}4 zp&SuzU$9;(lnxgKCxYOK4tyhMs*UHI2c)z(C-_B&d+8b7`-kL9LoYpUl5u;H(%d0U z4lgca^G;rcOXjlqm3Pejg3(MjH+P51xIjtm#5a+GY91XW7O|s!(SZ%U~)uWR3 z9T$_u#XL?3!?|CUb(^g1#EK@V>c4gr?F6p#OJ!^setM$3?%8;Si4($(x- z4(Gd?>nL>H>p1^ioPU#&KhJt~j~D__aISx=Hd!NO2g%ib%PK?w^E*{3){d(n30IjvVgXR=Tm-d+QavT=V5&!))92ERx+e?ZA6sc)KL9m5JIZ-zf8AJDKc zxjy!I1t0-o1y} zj3GhRf{Tj^$bMm+!^W0w)woPXj;r6bWZAV}chzDhX>|a@=naN!sA&&6)Pz@hfn3=z zmdmI-Rf<;SsT;*QHtv3C-PwOZof4(GQo0CP3VtwB7l}>FCKZl4BMyaCPK4knQ&$fm z*)Z?07->}SD3*Kh4rwEbNQ2Ev(f(P!g@9%$bY5(&VAIqP6^QPr`6F2dhm`QF^jEy1}6I_zy`+-f@XPGY6 zKE|SAK{4{>g>S4()}^?zdFq#CBNtiFB$Q3h#qxq@Y00LAcs$dI><>*Ug;_uj7X-~L zyEV&_$z^G$n|gSr(8aMYMSUbC{3rqXgSQ^(? zyigiX-v*{_!{7q9JWNS05(CA2GGv8?57D)dpDxfl=QRlikEI#I*@W0^(2d78P`VaN z9%+9v#5!d)xAlW;M)GY#&K}=xXy$9Ccb23lbGnVAxqk^kkeJY+8gi=?B$kR;NDl3N zeI-pnJj8=FDOL?tUVEB-#qEJiZ(oM}!*8VrA73o~i}0uVxAN!c`Qk^f*lc}s^SOze z_{_9huQf4sw0P`>JJ3OfcuRjnUmzvh43^!*6}@$<9A?8Luo((2ca)G= z2P68-y5F#W0sXDLKE8g7{NX8WwlLEs4`Gi*v(Dxwc99)HdWwQqXh%=OmTI~zV@7-t zDsVknx@)agze_+>nVPAhnya143JJ)lZBR3u{}9!d&GKJI^Y3AN87D{Mx?o?aH&%be zHr4q<$9Ha9$-cPq@h=Pev@cUktFduELKQrWiLvfYZs89iSIJYiRi#~hrdF(O^%4hrjp>r=(I2M1#KVpxRIItC1K!imF5 zuJd4lG?uZN6z>(HQ5sK~H00TYE=GTjJnTtnq8%(m@PqZbLBa>npThbUnhn~7#0E_* z!?PBDw8GW_O2Bn()di2DEp<{4EkUaS76?Ic!5{^Ohleb`cMo!+lgL5)+e}}-V7Qp# zeH9AZ_|{r6OdZ;VIvg1N$|olsLkpH$hKq)9SaAD>-#N6x{lF?F`O^sCZ^ zwe+60VDknynKNgP*=9kroX}lvC|~(e=C9tTTt4Q~2DfCAqeT4Sp1IJnm!+yT$JK|# zmD;n&RU-T}mFV>f@;U;pR$zZ3{rI!WR~1?BRyR!gkyd(vkKZFS-vlO<{1vb{B7$@M zm>6UAq%@D&WZaRAAj9}~Y_DbH(R^Yxg-`kZ#Y(b@c&fCdH$%*7Vw76ti(Ivx<=Uaa zwk*G0OBO5_O4ds4agH*k3-8=0VW|*-u}ZG{m%Hk>qVSve-6<=oJXhenac5cTA2kd$ zqo4uGtfkDS(q2OU63z5+6&tI zYLH#rXit_gEp=`=cu2lt7LdB7qX9q3w8y7lifTvcAgw)GAg?33iC7@f;v znUd4}aZsN0( zB)L|3$q0Y^4(-Hd{fb-$QrD5J?PGFXe)F3#z523U{(+ww+LGCu9qHlZcyly4xNM|J z?XU^`=5+e>{9))f=b~+J$A9_{bNSywfDaFF?hGw2k;^Mp)MJI5k9TuS zk$&W{I-J{#)ya!6^~3PK%3!w@kk~fykCo);l(LO%hKcMBJRZ=j zXekV7tP*|Qc@%Lwg{+MpqVmEuT+Jw}&2Wm|8WDzESgr=bswd=vpEq=qFdhyZC4&@) zoUVU341vv^L*BoCn|F`*c=)WNeY$3@0)rwYR~SMYDYFT=Y48RosGp!Y#C*$MEng^2 zxme-W58yIUHXb6lNd@w!WyA1vhW0hl`=)~DOBc~T)HR!2HNuuE7j+($HdTvSBBWsmd$mt6%6peTO@6$SD5rlCFP03&z8a5f6P4iYlCYvJJ5 zN9S$x^%|X@8y2{!K#s2gK>GQgpQ=wU|yZo$e}D)j8ltTPy*aj9=U9 zc#*R6X)V`J3V4iC3L-*30tR;TK_`Eh^El_L*lLUL8~L{O+~-)6Eea(S8YgtlQ+(ZN zEaI!A#kK%+uKg)jvren7B*nXWaLL9+mkC`W3%i(+m+*rhv%{h3^!*Q^I%6UoTfn;Q4iU;uB(z&ENu$fxxE}I+b@qLd z`==>fw;L~cm-xnKlDa0btDFtOwK<|~-C8mEvrqP1NhBPW&gJxPW_HXa=K`u;9nGFi_meU~U{*3j-hP{I+ zkDNg^e012PVTgeahoMh5Pq@(N=%q-zj&V{X7>BKZH%j&-Sc& z=@#j5K?$CwWXgX(|83sav%HVV{6$TQ%78aCT`C_WuD$z#y zh5eu^@>K}x9#Q?1~0#8sitX#DKlxb9+x4T@LDrLOe^ zRV*b=HywG|;no?~DX~$4C>kO4Mu?tXp$vif`I71RV>aD=Zh!7I*3)OK9)3V`dyg_Z zU=j`2Y_Na1kQWWPZ8>qN%vliErS*%`A4y-{m((qutlxWf`yI2VU&byx)7e0n7P=<| zyPP5G7B?u;fCYmAm=kC0?C>@v21Ap8&*Nr}#}X73EPK3z`SCz{^$DlnnKOL&DkdM% z9-koxz8ZDo&{8NXBgKICrTVh0BN8A7r8_X(Nn-kV4_!ik_4Hx@022>ML_t(;=vSV} zfydvU(0};(#=@dvHB zL&O(F^29niU#vvm2Qk)t(D3F5q`&&VGWiGp5R(W$`c?M6_Ol%R_>VHbb%(in-1#L> zf8!f${+GXh&B3=mV(p@)MbMxFT}oI8`0R1(6qdv6G30q79rU|nFGa=`8^dQ_2;_qVdmnrV=pmubzDr7-SC$2; zHHlUzL8!@byaqKHXOj*yx04miP_>uO5bScfRT*pS{O$@r=Bz2N7xOnM&7F>~f0ro^&xIZzj05;0gFN z8|ROqq6lLZ_E@eAUQv%{!sMt!ngKU`47SEPu+8XI=^%BHv>8`Ryn}v*Tr6PYN$MD8 z;BIwH+5qd8v}_1%V0PHh%9;k)ra)0^ARP29w1q}^&)s?ux3 z8XQ*VR22w*MSc;hJ9qDWi2v*VyY&C$H;F&`hF#o!iTU0>K5bZk^nm5R_+8w4AJUu+ zv^kS3Go*xQA*&%KG|o}*gi_h1UTbR;gT}5LN~q+Q9g7a6X9 ze#gR?z`#ntyU`g@jiLWpEd2-~|Aa98n>5px5%&dzIcfpZd2R2JYb#gs2orFKq!E}t zySlFDxChr)Q-1JFa@=`zQ1pt%^P+(1dRumzes`^D8@)AEF;`ow!D=1rW&A9i+nH?p zaqTAWKm=F3STEN|KUO_3RtDXWndI7kz8#z+8L-e4in%qFDudpwhXf9Z8*A3or0 zc*=Qu$Sb}9=PHaKXT%9QZRy&E-U-K|`IzMATN*&(5>yr2&8S`RSUQMw7NZxJR-L>>8md>@+) zl#Q=uV@(-(u!aqRGPKp;xg>O`9w+f&O+O!C6R>pyrAPfhiIcRR-+Alt-2b|*!*}@0 z4i|2c-TxcM>J=(R-tgR00amTyGCp{^=ew*sUs*xX;6)ove$>}IaJ>oGHb%#?h!@3@=W zdd9y)ghr||wv0f5GG^axXTc1jcs!Y4%0`uSmpEScG($pCuAhN#G1ja;e!}$i&tV0) zJSU#qLH76PQA$71`cPMIO2+wd54MSvNy894O|Wu{<={=u!&`ivpPYADGabdBV)~GB z@d|q6i9S^`FN5K7N?t>Md%Qwisr!&pYFibl;J-eCDy}ia>zFtL(qw!rOixZ3E*Ipp zyOib(yZZ=LDmBO)G-)(msi->_3WS351&YH@6Mm79Ck?jp=t__Wve2J_xDzIFhx=|pRD-PJ-lIc8U~mDCr*RR*(>QmPEF27@s#E0$t3qE` z1FfG+|k*^&@cGoFaR~cND+6FUHF`f-o;p%fGqE=WzQ?Yp;e}tT05Drfm zRvW^jhgg`=w);%-lFP-Ewf8l#my9_f#fCDQ(#42G(GymGsbO^yK5qY!T>em>b4SN= zxFo0d$QOySsoet!6)K;WEyJZJcWwj#L^Ri9on^mUD>W5LA8Ok?J4&i9n7&EO?wk|a zj14Vl!TQQALbNJjWn3W%8TCpjj=X8GO@JZdf+HLxoD=9AwyH{JTNt)?f7#qS{B|?6 z@y=!ZQ}2I&<3_l16F2c0Ns_x=JsI1ZqoUGAAhwNjFJH%T7?t%|%y8DRUZ46^w{%@I zFJX3TI0%j9{>(k?pUNW{c;?)wkPH+bacx8LEzUPcY`{e(;;z;u1hRp1JML;tG-jXJ zJ>2?43RgGy#1$j})KYDaT1|LnZfv}b1$6vc*u zFN(&08O}%at#{a;O<2$N7~X!LTeFr+H^Hqh+1x(hvI*d!xC%{hlNOz}lxZXdv0D>$ z(_k)4UVj=N-YKM&@0#UiLx1r;<=K*aSuL>Qw17ccP8cp6mNFDKE;rRzuF3B{cckjW zELHIqE>%c`1Lq<(?9(2644TMmD4n61I+r$o2ZN_#& zwk7*)TcB=7^x55ncWqD4zkL1U(<;i{ywMB8wSnZ;y<@D1p6A4F*LUoN>orAfB3C;aiDDOVt z_FHc;JGn)-QBL0aJ@%HDbkEkb`wMat>P*r>@}3+#v@Ns^LlBN8`1v&W*xn+3>yG2u z>1*k>Ss&BWN9ghtT?bSf!ZhQ2PwAE6bc*HeuB1a1@=)r(g^{EM)qgnCQ;tRO?(EDkV)R_9f zY%(jM+0(stj&m(B&X~3fZ9{8+GbextZ1W*D02=E|QW7~O`qO>#GLCb@`YLmVGDNI|nz*_K z%YqhaqEw>{brDKhBc6N&q}skcZz&tC)nsp!PGAr$*Ta|Mr9G(TUO(C5=ahI1DvQ{^`{bi&SG-uF^=7gpJO9Q#<@VQE8nqqZn zT^X0xYx_5D|872MIdwf@TuYj^O|=;zjjI|{D$lj0q`Txi!-a3+{o9oM5{-Y0=>H+Y z1zPT6+Sm5&j-cazxw01;`e+*_|}`e`r2K-b9~6V-~E6uoL=C6+Xe}tE_e1L_y4TE!8G5WTy`SqJj}JfU9$A(<*JM@N$6w;qVNZoT024E+%Xq?USB0m?bP3k_0EEezpZjf+T^#BgNx_ zAp2_+!FrE>^&asV7r^B^SU&02`^|Lt=@5xGN%AIc;`e)!v{B$O-W%mG;CyuQ*EXT6LrkgNLeX^;>_42#hJ3qN}n=j zh0C$h$zz)}I_M1INX{wS9hK)tanF5!O<^jAN%i=DHMs(wZ(paCJCxxoh`o-MA4Yt_ z1*PzFgl5Wg@z0pdzmD^D6H(A{-D5=NNyaRqxPp3b>T^nP5S{sg+YE)(CNar{Aa$?S`$*J;l?{3SHhW)qh4&slVDFvxxbq`#@Zk7> z2j70c8^84?UwF2{6|hxe_Vj|8WQHbSvneGw#IQrnq2bK=S2u(I+8ZZ)nKy>{-1n|c z57EtrwDD*L=c{p7=_CE+q=LswgOmuFikfqOSCz4)Fw$~HHm=r0D^by5hUPu6hJ1OO z^@G=0zV&&!_fODG1rY2KaPpMoDMLlnX%1*{G>e393n?4JD&i301jHS%(z}-9^r@_4 z;bty*6F2euJ^bwdZ|~f8<0is5{`+ReWACB6Ny9>_RvaW+#6jXAxCDtu;R$#o9)nAN zZh#6<5r--?32n%3vRSX~nfbVw*`y%2??pbzlI06awk&_A{{gF8Sl2n3!-;k$#sbgoGH1+e5;Cn3$!I)%c>l8@{>A%Z)~hb1~&v2F*{iHs?4lFL?ILmll%g!>8xs?@JGj?b^gUytn*+&=dXAMI zYk}zLT0@Y)_+HOw-e48dWe3(G4pQ%~%L+jt+EVkBak-RAte?^>{=|%~P|Y&&-HUyo zrQS;zW6M{V?YL4hsEb4bDJqRI^yf2H=ZPQ#vxPFMYPRQ&<@qV=_Q{EVKe_Dmk1ZN+ z`I5JB8?SOFS$qf`J5R!5jU?GjC}Z9Qv63yDj9{%0jbO1bc(kvuy(e}a+j-1TLtA0D zF8`LA1k(W1fQ|xg8qra}kIT6qnEQmPQlmJ@aGP=I9Q|%#rZ;HZfA9B7Cnf26)LDvv zoJ?hd5UxeYjd_8pB@AzW5W|ENg*Z%zu_NiWTyvy2fW~}Z$y%X1&`wE4rJ;Lyq^cRo zAum%v{twmqA3I7>MRmt()Eiw^k?XGZT5DH}=h6-&6bkSvv2*bNQL>IshP|Si=*#o~ z`NwlygSfJWZyvvz;&F)x(F)FF^zmv4_^X~#7q9|qJ=WG(-Lk%aIAuMV!Y7|GJbIg7 zzWavRPmh^=^fBGc^XuX@F4kM>75TNFi#1+X36(DVSLPnU+W2RW6?Aa69g|bL6`?C7q00000NkvXXu0mjf93(_* delta 29154 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 XHaajc0FVC000000NkvXXu0mjfhZXvd delta 3881 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 Date: Wed, 26 May 2010 11:44:38 -0600 Subject: [PATCH 228/324] Accomodate 505 users in the welcome wizard --- src/calibre/gui2/wizard/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/calibre/gui2/wizard/__init__.py b/src/calibre/gui2/wizard/__init__.py index 449d917ea1..1fc57e8f4e 100644 --- a/src/calibre/gui2/wizard/__init__.py +++ b/src/calibre/gui2/wizard/__init__.py @@ -81,7 +81,7 @@ class KindleDX(Kindle): class Sony505(Device): output_profile = 'sony' - name = 'SONY Reader 6" and Touch Editions' + name = 'All other SONY devices' output_format = 'EPUB' manufacturer = 'SONY' id = 'prs505' From d2bbd4f6ad537842f7336a9762611c26f0d08a47 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Wed, 26 May 2010 11:44:52 -0600 Subject: [PATCH 229/324] ... --- src/calibre/library/server/opds.py | 51 ++++++++++++++++++++++-------- 1 file changed, 38 insertions(+), 13 deletions(-) diff --git a/src/calibre/library/server/opds.py b/src/calibre/library/server/opds.py index 149f12644c..b40c146363 100644 --- a/src/calibre/library/server/opds.py +++ b/src/calibre/library/server/opds.py @@ -12,6 +12,7 @@ from itertools import repeat from lxml import etree, html from lxml.builder import ElementMaker import cherrypy +import routes from calibre.constants import __appname__ from calibre.ebooks.metadata import fmt_sidx @@ -25,6 +26,11 @@ BASE_HREFS = { STANZA_FORMATS = frozenset(['epub', 'pdb']) +def url_for(name, version, **kwargs): + if not name.endswith('_'): + name += '_' + return routes.url_for(name+str(version), **kwargs) + # Vocabulary for building OPDS feeds {{{ E = ElementMaker(namespace='http://www.w3.org/2005/Atom', nsmap={ @@ -42,7 +48,7 @@ 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, rel='subsection', +NAVLINK = partial(E.link, type='application/atom+xml;type=feed;profile=opds-catalog') def SEARCH_LINK(base_href, *args, **kwargs): @@ -59,7 +65,7 @@ def AUTHOR(name, uri=None): SUBTITLE = E.subtitle -def NAVCATALOG_ENTRY(base_href, updated, title, description, query): +def NAVCATALOG_ENTRY(base_href, updated, title, description, query, version=0): href = base_href+'/navcatalog/'+binascii.hexlify(query) id_ = 'calibre-navcatalog:'+str(hashlib.sha1(href).hexdigest()) return E.entry( @@ -148,7 +154,7 @@ class Feed(object): # {{{ title=__appname__ + ' ' + _('Library'), up_link=None, first_link=None, last_link=None, next_link=None, previous_link=None): - self.base_href = BASE_HREFS[version] + self.base_href = url_for('opds', version) self.root = \ FEED( @@ -188,7 +194,8 @@ class TopLevel(Feed): # {{{ ): Feed.__init__(self, id_, updated, version, subtitle=subtitle) - subc = partial(NAVCATALOG_ENTRY, self.base_href, updated) + subc = partial(NAVCATALOG_ENTRY, self.base_href, updated, + version=version) subcatalogs = [subc(_('By ')+title, _('Books sorted by ') + desc, q) for title, desc, q in categories] @@ -206,7 +213,7 @@ class NavFeed(Feed): kwargs['previous_link'] = \ page_url+'?offset=%d'%offsets.previous_offset if offsets.next_offset > -1: - kwargs['next_offset'] = \ + kwargs['next_link'] = \ page_url+'?offset=%d'%offsets.next_offset Feed.__init__(self, id_, updated, version, **kwargs) @@ -243,13 +250,13 @@ class OPDSOffsets(object): class OPDSServer(object): def add_routes(self, connect): - for base in ('stanza', 'opds'): - version = 0 if base == 'stanza' else 1 + for version in (0, 1): base_href = BASE_HREFS[version] - connect(base, base_href, self.opds, version=version) - connect('opdsnavcatalog_'+base, base_href+'/navcatalog/{which}', + ver = str(version) + connect('opds_'+ver, base_href, self.opds, version=version) + connect('opdsnavcatalog_'+ver, base_href+'/navcatalog/{which}', self.opds_navcatalog, version=version) - connect('opdssearch_'+base, base_href+'/search/{query}', + connect('opdssearch_'+ver, base_href+'/search/{query}', self.opds_search, version=version) def get_opds_allowed_ids_for_version(self, version): @@ -283,18 +290,36 @@ class OPDSServer(object): except: raise cherrypy.HTTPError(404, 'Search: %r not understood'%query) return self.get_opds_acquisition_feed(ids, offset, '/search/'+query, - BASE_HREFS[version], 'calibre-search:'+query, + url_for('opds', version), 'calibre-search:'+query, version=version) - def opds_navcatalog(self, which=None, version=0): + def get_opds_all_books(self, which, page_url, up_url, version=0, offset=0): + try: + offset = int(offset) + version = int(version) + except: + raise cherrypy.HTTPError(404, 'Not found') + if which not in ('title', 'newest') or version not in BASE_HREFS: + raise cherrypy.HTTPError(404, 'Not found') + sort = 'timestamp' if which == 'newest' else 'title' + ascending = which == 'title' + ids = self.get_opds_allowed_ids_for_version(version) + return self.get_opds_acquisition_feed(ids, offset, page_url, up_url, + id_='calibre-all:'+sort, sort_by=sort, ascending=ascending, + version=version) + + def opds_navcatalog(self, which=None, version=0, offset=0): version = int(version) if not which or version not in BASE_HREFS: raise cherrypy.HTTPError(404, 'Not found') + page_url = url_for('opdsnavcatalog', version, which=which) + up_url = url_for('opds', version) which = binascii.unhexlify(which) type_ = which[0] which = which[1:] if type_ == 'O': - return self.get_opds_all_books(which) + return self.get_opds_all_books(which, page_url, up_url, + version=version, offset=offset) elif type_ == 'N': return self.get_opds_navcatalog(which) raise cherrypy.HTTPError(404, 'Not found') From 311d56f16d4d875b8bf4a9190c1124921928e068 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Wed, 26 May 2010 11:47:01 -0600 Subject: [PATCH 230/324] use the banner instead of the logo in the content server --- resources/content_server/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/content_server/index.html b/resources/content_server/index.html index 3dcc093c13..f9f0aff491 100644 --- a/resources/content_server/index.html +++ b/resources/content_server/index.html @@ -11,7 +11,7 @@