From 7fb8fa9323445dcd5614fef1d1c36642956c088a Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Tue, 18 May 2010 09:58:09 -0600 Subject: [PATCH 1/8] Indicate current setting in column header context menu --- src/calibre/gui2/library/views.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/calibre/gui2/library/views.py b/src/calibre/gui2/library/views.py index d3c7be433e..ad0e82110c 100644 --- a/src/calibre/gui2/library/views.py +++ b/src/calibre/gui2/library/views.py @@ -99,19 +99,28 @@ class BooksView(QTableView): # {{{ column=col)) m = self.column_header_context_menu.addMenu( _('Sort on %s') % name) - m.addAction(_('Ascending'), + a = m.addAction(_('Ascending'), partial(self.column_header_context_handler, action='ascending', column=col)) - m.addAction(_('Descending'), + d = m.addAction(_('Descending'), partial(self.column_header_context_handler, action='descending', column=col)) + if self._model.sorted_on[0] == col: + ac = a if self._model.sorted_on[1] == Qt.AscendingOrder else d + ac.setCheckable(True) + ac.setChecked(True) m = self.column_header_context_menu.addMenu( _('Change text alignment for %s') % name) + al = self._model.alignment_map.get(col, 'left') for x, t in (('left', _('Left')), ('right', _('Right')), ('center', _('Center'))): - m.addAction(t, + a = m.addAction(t, partial(self.column_header_context_handler, action='align_'+x, column=col)) + if al == x: + a.setCheckable(True) + a.setChecked(True) + hidden_cols = [self.column_map[i] for i in From 5fa9a5b935197d42f9a6d9a66dbf0c7139ee2f1f Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Tue, 18 May 2010 10:43:44 -0600 Subject: [PATCH 2/8] Re-organize Send to device menu --- src/calibre/gui2/device.py | 88 ++++++++++++++++++++++---------------- 1 file changed, 52 insertions(+), 36 deletions(-) diff --git a/src/calibre/gui2/device.py b/src/calibre/gui2/device.py index 1bc35b6a2b..f31a8d1cdb 100644 --- a/src/calibre/gui2/device.py +++ b/src/calibre/gui2/device.py @@ -327,15 +327,17 @@ class DeviceManager(Thread): class DeviceAction(QAction): + a_s = pyqtSignal(object) + def __init__(self, dest, delete, specific, icon_path, text, parent=None): - if delete: - text += ' ' + _('and delete from library') QAction.__init__(self, QIcon(icon_path), text, parent) self.dest = dest self.delete = delete self.specific = specific - self.connect(self, SIGNAL('triggered(bool)'), - lambda x : self.emit(SIGNAL('a_s(QAction)'), self)) + self.triggered.connect(self.emit_triggered) + + def emit_triggered(self, *args): + self.a_s.emit(self) def __repr__(self): return self.__class__.__name__ + ':%s:%s:%s'%(self.dest, self.delete, @@ -356,6 +358,7 @@ class DeviceMenu(QMenu): self.set_default_menu = self.addMenu(_('Set default send to device' ' action')) + self.addSeparator() opts = email_config().parse() default_account = None if opts.accounts: @@ -379,51 +382,65 @@ class DeviceMenu(QMenu): self.connect(action2, SIGNAL('a_s(QAction)'), self.action_triggered) - _actions = [ + basic_actions = [ ('main:', False, False, I('reader.svg'), _('Send to main memory')), ('carda:0', False, False, I('sd.svg'), _('Send to storage card A')), ('cardb:0', False, False, I('sd.svg'), _('Send to storage card B')), - '-----', + ] + + delete_actions = [ ('main:', True, False, I('reader.svg'), - _('Send to main memory')), + _('Main Memory')), ('carda:0', True, False, I('sd.svg'), - _('Send to storage card A')), + _('Storage Card A')), ('cardb:0', True, False, I('sd.svg'), - _('Send to storage card B')), - '-----', + _('Storage Card B')), + ] + + specific_actions = [ ('main:', False, True, I('reader.svg'), - _('Send specific format to main memory')), + _('Main Memory')), ('carda:0', False, True, I('sd.svg'), - _('Send specific format to storage card A')), + _('Storage Card A')), ('cardb:0', False, True, I('sd.svg'), - _('Send specific format to storage card B')), + _('Storage Card B')), + ] + - ] if default_account is not None: - _actions.insert(2, default_account) - _actions.insert(6, list(default_account)) - _actions[6][1] = True - for round in (0, 1): - for dest, delete, specific, icon, text in _actions: - if dest == '-': - (self.set_default_menu if round else self).addSeparator() - continue - action = DeviceAction(dest, delete, specific, icon, text, self) - self._memory.append(action) - if round == 1: - action.setCheckable(True) - action.setText(action.text()) - self.group.addAction(action) - self.set_default_menu.addAction(action) - else: - self.connect(action, SIGNAL('a_s(QAction)'), - self.action_triggered) - self.actions.append(action) - self.addAction(action) + for x in (basic_actions, delete_actions): + ac = list(default_account) + if x is delete_actions: + ac[1] = True + x.insert(1, tuple(ac)) + for menu in (self, self.set_default_menu): + for actions, desc in ( + (basic_actions, ''), + (delete_actions, _('Send and delete from library')), + (specific_actions, _('Send specific format')) + ): + mdest = menu + if actions is not basic_actions: + mdest = menu.addMenu(desc) + self._memory.append(mdest) + + for dest, delete, specific, icon, text in actions: + action = DeviceAction(dest, delete, specific, icon, text, self) + self._memory.append(action) + if menu is self.set_default_menu: + action.setCheckable(True) + action.setText(action.text()) + self.group.addAction(action) + else: + action.a_s.connect(self.action_triggered) + self.actions.append(action) + mdest.addAction(action) + if actions is not specific_actions: + menu.addSeparator() da = config['default_send_to_device_action'] done = False @@ -437,8 +454,7 @@ class DeviceMenu(QMenu): action.setChecked(True) config['default_send_to_device_action'] = repr(action) - self.connect(self.group, SIGNAL('triggered(QAction*)'), - self.change_default_action) + self.group.triggered.connect(self.change_default_action) if opts.accounts: self.addSeparator() self.addMenu(self.email_to_menu) From 7c65e0e63a2b18025dace99fa7ed84627eafc7c1 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Tue, 18 May 2010 11:06:47 -0600 Subject: [PATCH 3/8] 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 @@ + + + + + + + image/svg+xml + + Folder + + + Lapo Calamandrei + + + 2006-06-26 + + + + + folder + directory + storage + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/calibre/devices/folder_device/driver.py b/src/calibre/devices/folder_device/driver.py index 792de9ee0a..00f2db282d 100644 --- a/src/calibre/devices/folder_device/driver.py +++ b/src/calibre/devices/folder_device/driver.py @@ -42,7 +42,7 @@ class FOLDER_DEVICE(USBMS): SUPPORTS_SUB_DIRS = True #: Icon for this device - icon = I('sd.svg') + icon = I('devices/folder.svg') METADATA_CACHE = '.metadata.calibre' _main_prefix = '' diff --git a/src/calibre/gui2/device.py b/src/calibre/gui2/device.py index f31a8d1cdb..37f1d9e513 100644 --- a/src/calibre/gui2/device.py +++ b/src/calibre/gui2/device.py @@ -356,9 +356,9 @@ class DeviceMenu(QMenu): self.actions = [] self._memory = [] - self.set_default_menu = self.addMenu(_('Set default send to device' - ' action')) - self.addSeparator() + self.set_default_menu = QMenu(_('Set default send to device action')) + self.set_default_menu.setIcon(QIcon(I('config.svg'))) + opts = email_config().parse() default_account = None if opts.accounts: @@ -470,6 +470,8 @@ class DeviceMenu(QMenu): mitem.triggered.connect(lambda x : self.disconnect_from_folder.emit()) self.disconnect_from_folder_action = mitem + self.addSeparator() + self.addMenu(self.set_default_menu) self.addSeparator() annot = self.addAction(_('Fetch annotations (experimental)')) annot.setEnabled(False) From 7491a8d20ebf7fa0fb6c9d65a50301124ff9cf68 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Tue, 18 May 2010 11:13:27 -0600 Subject: [PATCH 4/8] Fix text alignment in device views and only save valid states --- src/calibre/gui2/library/models.py | 6 ++++++ src/calibre/gui2/library/views.py | 4 +++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/calibre/gui2/library/models.py b/src/calibre/gui2/library/models.py index 802e23e90c..bd8fb20741 100644 --- a/src/calibre/gui2/library/models.py +++ b/src/calibre/gui2/library/models.py @@ -1011,6 +1011,12 @@ class DeviceBooksModel(BooksModel): # {{{ elif role == Qt.DecorationRole and cname == 'inlibrary': if self.db[self.map[row]].in_library: return QVariant(self.bool_yes_icon) + elif role == Qt.TextAlignmentRole: + cname = self.column_map[index.column()] + ans = Qt.AlignVCenter | ALIGNMENT_MAP[self.alignment_map.get(cname, + 'left')] + return QVariant(ans) + return NONE diff --git a/src/calibre/gui2/library/views.py b/src/calibre/gui2/library/views.py index ad0e82110c..e5c6ffd5f7 100644 --- a/src/calibre/gui2/library/views.py +++ b/src/calibre/gui2/library/views.py @@ -44,6 +44,7 @@ class BooksView(QTableView): # {{{ self.selectionModel().currentRowChanged.connect(self._model.current_changed) # {{{ Column Header setup + self.was_restored = False self.column_header = self.horizontalHeader() self.column_header.setMovable(True) self.column_header.sectionMoved.connect(self.save_state) @@ -198,7 +199,7 @@ class BooksView(QTableView): # {{{ def save_state(self): # Only save if we have been initialized (set_database called) - if len(self.column_map) > 0: + if len(self.column_map) > 0 and self.was_restored: state = self.get_state() name = unicode(self.objectName()) if name: @@ -287,6 +288,7 @@ class BooksView(QTableView): # {{{ old_state['sort_history'] = tweaks['sort_columns_at_startup'] self.apply_state(old_state) + self.was_restored = True # }}} From 49d70e0f9ee0c0d4a9e7100cf4bc05e1849aaa2a Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Tue, 18 May 2010 15:04:20 -0600 Subject: [PATCH 5/8] JSONConfig: Fix encoding of top level str objects. Add support for encoding bytearray objects. Remove pointless TableView class and have jobs view remember its column layout --- src/calibre/gui2/__init__.py | 30 +------------------ src/calibre/gui2/dialogs/jobs.ui | 13 ++------- src/calibre/gui2/jobs.py | 50 +++++++++++++++++++++++++++++--- src/calibre/gui2/sidebar.py | 4 --- src/calibre/gui2/widgets.py | 41 ++------------------------ src/calibre/utils/config.py | 26 +++++++++++++++-- 6 files changed, 76 insertions(+), 88 deletions(-) diff --git a/src/calibre/gui2/__init__.py b/src/calibre/gui2/__init__.py index 9cb68ea01a..0cf565c928 100644 --- a/src/calibre/gui2/__init__.py +++ b/src/calibre/gui2/__init__.py @@ -8,7 +8,7 @@ from PyQt4.QtCore import QVariant, QFileInfo, QObject, SIGNAL, QBuffer, Qt, QSiz QByteArray, QTranslator, QCoreApplication, QThread, \ QEvent, QTimer, pyqtSignal, QDate from PyQt4.QtGui import QFileDialog, QMessageBox, QPixmap, QFileIconProvider, \ - QIcon, QTableView, QApplication, QDialog, QPushButton + QIcon, QApplication, QDialog, QPushButton ORG_NAME = 'KovidsBrain' APP_UID = 'libprs500' @@ -294,34 +294,6 @@ class GetMetadata(QObject): mi = MetaInformation('', [_('Unknown')]) self.emit(SIGNAL('metadata(PyQt_PyObject, PyQt_PyObject)'), id, mi) -class TableView(QTableView): - - def __init__(self, parent): - QTableView.__init__(self, parent) - self.read_settings() - - def read_settings(self): - self.cw = dynamic[self.__class__.__name__+'column width map'] - - def write_settings(self): - m = dynamic[self.__class__.__name__+'column width map'] - if m is None: - m = {} - cmap = getattr(self.model(), 'column_map', None) - if cmap is not None: - for i,c in enumerate(cmap): - m[c] = self.columnWidth(i) - dynamic[self.__class__.__name__+'column width map'] = m - self.cw = m - - def restore_column_widths(self): - if self.cw and len(self.cw): - for i,c in enumerate(self.model().column_map): - if c in self.cw: - self.setColumnWidth(i, self.cw[c]) - return True - return False - class FileIconProvider(QFileIconProvider): ICONS = { diff --git a/src/calibre/gui2/dialogs/jobs.ui b/src/calibre/gui2/dialogs/jobs.ui index e2e345ca28..1fb23269e6 100644 --- a/src/calibre/gui2/dialogs/jobs.ui +++ b/src/calibre/gui2/dialogs/jobs.ui @@ -14,12 +14,12 @@ Active Jobs - + :/images/jobs.svg:/images/jobs.svg - + Qt::NoContextMenu @@ -66,15 +66,8 @@ - - - JobsView - QTableView -
widgets.h
-
-
- + diff --git a/src/calibre/gui2/jobs.py b/src/calibre/gui2/jobs.py index a801e0db28..437e65632c 100644 --- a/src/calibre/gui2/jobs.py +++ b/src/calibre/gui2/jobs.py @@ -15,10 +15,11 @@ from PyQt4.Qt import QAbstractTableModel, QVariant, QModelIndex, Qt, \ from calibre.utils.ipc.server import Server from calibre.utils.ipc.job import ParallelJob -from calibre.gui2 import Dispatcher, error_dialog, NONE, config +from calibre.gui2 import Dispatcher, error_dialog, NONE, config, gprefs from calibre.gui2.device import DeviceJob from calibre.gui2.dialogs.jobs_ui import Ui_JobsDialog from calibre import __appname__ +from calibre.gui2.dialogs.job_view_ui import Ui_Dialog class JobManager(QAbstractTableModel): @@ -243,7 +244,32 @@ class ProgressBarDelegate(QAbstractItemDelegate): opts.text = QString(_('Unavailable') if percent == 0 else '%d%%'%percent) QApplication.style().drawControl(QStyle.CE_ProgressBar, opts, painter) +class DetailView(QDialog, Ui_Dialog): + + def __init__(self, parent, job): + QDialog.__init__(self, parent) + self.setupUi(self) + self.setWindowTitle(job.description) + self.job = job + self.next_pos = 0 + self.update() + self.timer = QTimer(self) + self.timer.timeout.connect(self.update) + self.timer.start(1000) + + + def update(self): + f = self.job.log_file + f.seek(self.next_pos) + more = f.read() + self.next_pos = f.tell() + if more: + self.log.appendPlainText(more.decode('utf-8', 'replace')) + + + class JobsDialog(QDialog, Ui_JobsDialog): + def __init__(self, window, model): QDialog.__init__(self, window) Ui_JobsDialog.__init__(self) @@ -252,8 +278,6 @@ class JobsDialog(QDialog, Ui_JobsDialog): self.model = model self.setWindowModality(Qt.NonModal) self.setWindowTitle(__appname__ + _(' - Jobs')) - self.connect(self.jobs_view.model(), SIGNAL('modelReset()'), - self.jobs_view.resizeColumnsToContents) self.connect(self.kill_button, SIGNAL('clicked()'), self.kill_job) self.connect(self.details_button, SIGNAL('clicked()'), @@ -264,7 +288,21 @@ class JobsDialog(QDialog, Ui_JobsDialog): self.jobs_view.model().kill_job) self.pb_delegate = ProgressBarDelegate(self) self.jobs_view.setItemDelegateForColumn(2, self.pb_delegate) + self.jobs_view.doubleClicked.connect(self.show_job_details) + self.jobs_view.horizontalHeader().setMovable(True) + state = gprefs.get('jobs view column layout', None) + if state is not None: + try: + self.jobs_view.horizontalHeader().restoreState(bytes(state)) + except: + pass + def show_job_details(self, index): + row = index.row() + job = self.jobs_view.model().row_to_job(row) + d = DetailView(self, job) + d.exec_() + d.timer.stop() def kill_job(self): for index in self.jobs_view.selectedIndexes(): @@ -281,5 +319,9 @@ class JobsDialog(QDialog, Ui_JobsDialog): self.model.kill_all_jobs() def closeEvent(self, e): - self.jobs_view.write_settings() + try: + state = bytearray(self.jobs_view.horizontalHeader().saveState()) + gprefs['jobs view column layout'] = state + except: + pass e.accept() diff --git a/src/calibre/gui2/sidebar.py b/src/calibre/gui2/sidebar.py index d6b58f165d..bd305912a0 100644 --- a/src/calibre/gui2/sidebar.py +++ b/src/calibre/gui2/sidebar.py @@ -33,16 +33,12 @@ class JobsButton(QFrame): def initialize(self, jobs_dialog): self.jobs_dialog = jobs_dialog - self.jobs_dialog.jobs_view.restore_column_widths() def mouseReleaseEvent(self, event): if self.jobs_dialog.isVisible(): - self.jobs_dialog.jobs_view.write_settings() self.jobs_dialog.hide() else: - self.jobs_dialog.jobs_view.read_settings() self.jobs_dialog.show() - self.jobs_dialog.jobs_view.restore_column_widths() @property def is_running(self): diff --git a/src/calibre/gui2/widgets.py b/src/calibre/gui2/widgets.py index 4b61677b12..8083cd4ba0 100644 --- a/src/calibre/gui2/widgets.py +++ b/src/calibre/gui2/widgets.py @@ -7,15 +7,15 @@ import re, os, traceback from PyQt4.Qt import QListView, QIcon, QFont, QLabel, QListWidget, \ QListWidgetItem, QTextCharFormat, QApplication, \ QSyntaxHighlighter, QCursor, QColor, QWidget, \ - QPixmap, QPalette, QTimer, QDialog, QSplitterHandle, \ + QPixmap, QPalette, QSplitterHandle, \ QAbstractListModel, QVariant, Qt, SIGNAL, pyqtSignal, \ QRegExp, QSettings, QSize, QModelIndex, QSplitter, \ QAbstractButton, QPainter, QLineEdit, QComboBox, \ QMenu, QStringListModel, QCompleter, QStringList -from calibre.gui2 import human_readable, NONE, TableView, \ +from calibre.gui2 import human_readable, NONE, \ error_dialog, pixmap_to_data, dynamic -from calibre.gui2.dialogs.job_view_ui import Ui_Dialog + from calibre.gui2.filename_pattern_ui import Ui_Form from calibre import fit_image from calibre.utils.fonts import fontconfig @@ -399,41 +399,6 @@ class EjectButton(QAbstractButton): painter.drawPixmap(0, 0, image) -class DetailView(QDialog, Ui_Dialog): - - def __init__(self, parent, job): - QDialog.__init__(self, parent) - self.setupUi(self) - self.setWindowTitle(job.description) - self.job = job - self.next_pos = 0 - self.update() - self.timer = QTimer(self) - self.connect(self.timer, SIGNAL('timeout()'), self.update) - self.timer.start(1000) - - - def update(self): - f = self.job.log_file - f.seek(self.next_pos) - more = f.read() - self.next_pos = f.tell() - if more: - self.log.appendPlainText(more.decode('utf-8', 'replace')) - - -class JobsView(TableView): - - def __init__(self, parent): - TableView.__init__(self, parent) - self.connect(self, SIGNAL('doubleClicked(QModelIndex)'), self.show_details) - - def show_details(self, index): - row = index.row() - job = self.model().row_to_job(row) - d = DetailView(self, job) - d.exec_() - d.timer.stop() class FontFamilyModel(QAbstractListModel): diff --git a/src/calibre/utils/config.py b/src/calibre/utils/config.py index cb17085071..559721c193 100644 --- a/src/calibre/utils/config.py +++ b/src/calibre/utils/config.py @@ -6,7 +6,7 @@ __docformat__ = 'restructuredtext en' ''' Manage application-wide preferences. ''' -import os, re, cPickle, textwrap, traceback, plistlib, json +import os, re, cPickle, textwrap, traceback, plistlib, json, base64 from copy import deepcopy from functools import partial from optparse import OptionParser as _OptionParser @@ -636,11 +636,31 @@ class JSONConfig(XMLConfig): EXTENSION = '.json' + def to_json(self, obj): + if isinstance(obj, bytearray): + return {'__class__': 'bytearray', + '__value__': base64.standard_b64encode(bytes(obj))} + raise TypeError(repr(obj) + ' is not JSON serializable') + + def from_json(self, obj): + if '__class__' in obj: + if obj['__class__'] == 'bytearray': + return bytearray(base64.standard_b64decode(obj['__value__'])) + return obj + def raw_to_object(self, raw): - return json.loads(raw.decode('utf-8')) + return json.loads(raw.decode('utf-8'), object_hook=self.from_json) def to_raw(self): - return json.dumps(self, indent=2) + return json.dumps(self, indent=2, default=self.to_json) + + def __getitem__(self, key): + return dict.__getitem__(self, key) + + def __setitem__(self, key, val): + dict.__setitem__(self, key, val) + self.commit() + def _prefs(): From 90459ef792f20c90b90823502791cc51e64d4846 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Tue, 18 May 2010 16:56:29 -0600 Subject: [PATCH 6/8] Fix column customization via preferences --- src/calibre/gui2/dialogs/config/__init__.py | 125 +++++++++++------- .../dialogs/config/create_custom_column.py | 2 +- src/calibre/gui2/ui.py | 5 +- 3 files changed, 77 insertions(+), 55 deletions(-) diff --git a/src/calibre/gui2/dialogs/config/__init__.py b/src/calibre/gui2/dialogs/config/__init__.py index ff50ff7718..f92c52e204 100644 --- a/src/calibre/gui2/dialogs/config/__init__.py +++ b/src/calibre/gui2/dialogs/config/__init__.py @@ -1,6 +1,7 @@ __license__ = 'GPL v3' __copyright__ = '2008, Kovid Goyal ' -import os, re, time, textwrap, copy + +import os, re, time, textwrap, copy, sys from PyQt4.Qt import QDialog, QListWidgetItem, QIcon, \ QDesktopServices, QVBoxLayout, QLabel, QPlainTextEdit, \ @@ -10,7 +11,7 @@ from PyQt4.Qt import QDialog, QListWidgetItem, QIcon, \ QDialogButtonBox, QTabWidget, QBrush, QLineEdit, \ QProgressDialog -from calibre.constants import iswindows, isosx, preferred_encoding +from calibre.constants import iswindows, isosx from calibre.gui2.dialogs.config.config_ui import Ui_Dialog from calibre.gui2.dialogs.config.create_custom_column import CreateCustomColumn from calibre.gui2 import choose_dir, error_dialog, config, \ @@ -330,7 +331,7 @@ class ConfigDialog(ResizableDialog, Ui_Dialog): def category_current_changed(self, n, p): self.stackedWidget.setCurrentIndex(n.row()) - def __init__(self, parent, model, server=None): + def __init__(self, parent, library_view, server=None): ResizableDialog.__init__(self, parent) self.ICON_SIZES = {0:QSize(48, 48), 1:QSize(32,32), 2:QSize(24,24)} self._category_model = CategoryModel() @@ -338,8 +339,9 @@ class ConfigDialog(ResizableDialog, Ui_Dialog): self.category_view.currentChanged = self.category_current_changed self.category_view.setModel(self._category_model) self.parent = parent - self.model = model - self.db = model.db + self.library_view = library_view + self.model = library_view.model() + self.db = self.model.db self.server = server path = prefs['library_path'] self.location.setText(path if path else '') @@ -364,26 +366,27 @@ class ConfigDialog(ResizableDialog, Ui_Dialog): self.new_version_notification.setChecked(config['new_version_notification']) # Set up columns - # Make copies of maps so that internal changes aren't put into the real maps - self.colmap = config['column_map'][:] + colmap = list(self.model.column_map) + state = self.library_view.get_state() + hidden_cols = state['hidden_columns'] + positions = state['column_positions'] + colmap.sort(cmp=lambda x,y: cmp(positions[x], positions[y])) self.custcols = copy.deepcopy(self.db.custom_column_label_map) - cm = [c.decode(preferred_encoding, 'replace') for c in self.colmap] - ac = [c.decode(preferred_encoding, 'replace') for c in ALL_COLUMNS] - for col in cm + \ - [i for i in ac if i not in cm] + \ - [i for i in self.custcols if i not in cm]: - if col in ALL_COLUMNS: - item = QListWidgetItem(model.orig_headers[col], self.columns) - else: - item = QListWidgetItem(self.custcols[col]['name'], self.columns) + for col in colmap: + item = QListWidgetItem(self.model.headers[col], self.columns) item.setData(Qt.UserRole, QVariant(col)) - item.setFlags(Qt.ItemIsEnabled|Qt.ItemIsUserCheckable|Qt.ItemIsSelectable) - item.setCheckState(Qt.Checked if col in self.colmap else Qt.Unchecked) - self.connect(self.column_up, SIGNAL('clicked()'), self.up_column) - self.connect(self.column_down, SIGNAL('clicked()'), self.down_column) - self.connect(self.del_custcol_button, SIGNAL('clicked()'), self.del_custcol) - self.connect(self.add_custcol_button, SIGNAL('clicked()'), self.add_custcol) - self.connect(self.edit_custcol_button, SIGNAL('clicked()'), self.edit_custcol) + flags = Qt.ItemIsEnabled|Qt.ItemIsSelectable + if col != 'ondevice': + flags |= Qt.ItemIsUserCheckable + item.setFlags(flags) + if col != 'ondevice': + item.setCheckState(Qt.Unchecked if col in hidden_cols else + Qt.Checked) + self.column_up.clicked.connect(self.up_column) + self.column_down.clicked.connect(self.down_column) + self.del_custcol_button.clicked.connect(self.del_custcol) + self.add_custcol_button.clicked.connect(self.add_custcol) + self.edit_custcol_button.clicked.connect(self.edit_custcol) icons = config['toolbar_icon_size'] self.toolbar_button_size.setCurrentIndex(0 if icons == self.ICON_SIZES[0] else 1 if icons == self.ICON_SIZES[1] else 2) @@ -647,6 +650,7 @@ class ConfigDialog(ResizableDialog, Ui_Dialog): self.input_order.insertItem(idx+1, self.input_order.takeItem(idx)) self.input_order.setCurrentRow(idx+1) + # Column settings {{{ def up_column(self): idx = self.columns.currentRow() if idx > 0: @@ -683,6 +687,53 @@ class ConfigDialog(ResizableDialog, Ui_Dialog): def edit_custcol(self): CreateCustomColumn(self, True, self.model.orig_headers, ALL_COLUMNS) + def apply_custom_column_changes(self): + config_cols = [unicode(self.columns.item(i).data(Qt.UserRole).toString())\ + for i in range(self.columns.count())] + if not config_cols: + config_cols = ['title'] + removed_cols = set(self.model.column_map) - set(config_cols) + hidden_cols = set([unicode(self.columns.item(i).data(Qt.UserRole).toString())\ + for i in range(self.columns.count()) \ + if self.columns.item(i).checkState()==Qt.Unchecked]) + hidden_cols = hidden_cols.union(removed_cols) # Hide removed cols + hidden_cols = list(hidden_cols.intersection(set(self.model.column_map))) + if 'ondevice' in hidden_cols: + hidden_cols.remove('ondevice') + def col_pos(x, y): + xidx = config_cols.index(x) if x in config_cols else sys.maxint + yidx = config_cols.index(y) if y in config_cols else sys.maxint + return cmp(xidx, yidx) + positions = {} + for i, col in enumerate((sorted(self.model.column_map, cmp=col_pos))): + positions[col] = i + state = {'hidden_columns': hidden_cols, 'column_positions':positions} + self.library_view.apply_state(state) + self.library_view.save_state() + + must_restart = False + for c in self.custcols: + if self.custcols[c]['num'] is None: + self.db.create_custom_column( + label=c, + name=self.custcols[c]['name'], + datatype=self.custcols[c]['datatype'], + is_multiple=self.custcols[c]['is_multiple'], + display = self.custcols[c]['display']) + must_restart = True + elif '*deleteme' in self.custcols[c]: + self.db.delete_custom_column(label=c) + must_restart = True + elif '*edited' in self.custcols[c]: + cc = self.custcols[c] + self.db.set_custom_column_metadata(cc['num'], name=cc['name'], + label=cc['label'], + display = self.custcols[c]['display']) + if '*must_restart' in self.custcols[c]: + must_restart = True + return must_restart + # }}} + def view_server_logs(self): from calibre.library.server import log_access_file, log_error_file d = QDialog(self) @@ -776,33 +827,7 @@ class ConfigDialog(ResizableDialog, Ui_Dialog): input_cols = [unicode(self.input_order.item(i).data(Qt.UserRole).toString()) for i in range(self.input_order.count())] prefs['input_format_order'] = input_cols - ####### Now deal with changes to columns - cols = [unicode(self.columns.item(i).data(Qt.UserRole).toString())\ - for i in range(self.columns.count()) \ - if self.columns.item(i).checkState()==Qt.Checked] - if not cols: - cols = ['title'] - config['column_map'] = cols - must_restart = False - for c in self.custcols: - if self.custcols[c]['num'] is None: - self.db.create_custom_column( - label=c, - name=self.custcols[c]['name'], - datatype=self.custcols[c]['datatype'], - is_multiple=self.custcols[c]['is_multiple'], - display = self.custcols[c]['display']) - must_restart = True - elif '*deleteme' in self.custcols[c]: - self.db.delete_custom_column(label=c) - must_restart = True - elif '*edited' in self.custcols[c]: - cc = self.custcols[c] - self.db.set_custom_column_metadata(cc['num'], name=cc['name'], - label=cc['label'], - display = self.custcols[c]['display']) - if '*must_restart' in self.custcols[c]: - must_restart = True + must_restart = self.apply_custom_column_changes() config['toolbar_icon_size'] = self.ICON_SIZES[self.toolbar_button_size.currentIndex()] config['show_text_in_toolbar'] = bool(self.show_toolbar_text.isChecked()) diff --git a/src/calibre/gui2/dialogs/config/create_custom_column.py b/src/calibre/gui2/dialogs/config/create_custom_column.py index 98aa3c99e0..5b470123a4 100644 --- a/src/calibre/gui2/dialogs/config/create_custom_column.py +++ b/src/calibre/gui2/dialogs/config/create_custom_column.py @@ -123,7 +123,7 @@ class CreateCustomColumn(QDialog, Ui_QCreateCustomColumn): if ':' in col or ' ' in col or col.lower() != col: return self.simple_error('', _('The lookup name must be lower case and cannot contain ":"s or spaces')) - date_format = None + date_format = {} if col_type == 'datetime': if self.date_format_box.text(): date_format = {'date_format':unicode(self.date_format_box.text())} diff --git a/src/calibre/gui2/ui.py b/src/calibre/gui2/ui.py index c8f1ae5ded..3f67e4184c 100644 --- a/src/calibre/gui2/ui.py +++ b/src/calibre/gui2/ui.py @@ -2237,7 +2237,7 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI): _('Cannot configure before calibre is restarted.')) d.exec_() return - d = ConfigDialog(self, self.library_view.model(), + d = ConfigDialog(self, self.library_view, server=self.content_server) d.exec_() @@ -2255,9 +2255,6 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI): self.save_menu.actions()[3].setText( _('Save only %s format to disk in a single directory')% prefs['output_format'].upper()) - self.library_view.model().read_config() - self.library_view.model().refresh() - self.library_view.model().research() self.tags_view.set_new_model() # in case columns changed self.tags_view.recount() self.create_device_menu() From b1287f0a51e866cc51e043601103e2fc94e5abdb Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Tue, 18 May 2010 18:03:16 -0600 Subject: [PATCH 7/8] American Prospect, FactCheck, PolitiFact by Michael Heinz --- resources/recipes/aprospect.recipe | 26 ++++++++++++++++++ resources/recipes/factcheck.recipe | 19 +++++++++++++ resources/recipes/politifact.recipe | 30 +++++++++++++++++++++ src/calibre/devices/manager.py | 14 +++++----- src/calibre/ebooks/rtf2xml/paragraph_def.py | 2 -- src/calibre/ebooks/rtf2xml/styles.py | 2 -- 6 files changed, 82 insertions(+), 11 deletions(-) create mode 100755 resources/recipes/aprospect.recipe create mode 100644 resources/recipes/factcheck.recipe create mode 100644 resources/recipes/politifact.recipe diff --git a/resources/recipes/aprospect.recipe b/resources/recipes/aprospect.recipe new file mode 100755 index 0000000000..ce230c624a --- /dev/null +++ b/resources/recipes/aprospect.recipe @@ -0,0 +1,26 @@ +import re +from calibre.web.feeds.news import BasicNewsRecipe + +class AmericanProspect(BasicNewsRecipe): + title = u'American Prospect' + __author__ = u'Michael Heinz' + oldest_article = 30 + language = 'en' + max_articles_per_feed = 100 + recursions = 0 + no_stylesheets = True + remove_javascript = True + + preprocess_regexps = [ + (re.compile(r'', re.DOTALL|re.IGNORECASE), lambda match: '
'), + (re.compile(r'
.*', re.DOTALL|re.IGNORECASE), lambda match: ''), + (re.compile('\r'),lambda match: ''), + (re.compile(r'', re.DOTALL|re.IGNORECASE), lambda match: ''), + (re.compile(r'', re.DOTALL|re.IGNORECASE), lambda match: ''), + (re.compile(r'', re.DOTALL|re.IGNORECASE), lambda match: ''), + (re.compile(r'', re.DOTALL|re.IGNORECASE), lambda match: ''), + (re.compile(r'', re.DOTALL|re.IGNORECASE), lambda match: ''), + ] + + feeds = [(u'Articles', u'feed://www.prospect.org/articles_rss.jsp')] + diff --git a/resources/recipes/factcheck.recipe b/resources/recipes/factcheck.recipe new file mode 100644 index 0000000000..b25b9c245b --- /dev/null +++ b/resources/recipes/factcheck.recipe @@ -0,0 +1,19 @@ +from calibre.web.feeds.news import BasicNewsRecipe + +class FactCheckOrg(BasicNewsRecipe): + title = u'Factcheck' + __author__ = u'Michael Heinz' + language = 'en' + oldest_article = 7 + max_articles_per_feed = 100 + recursion = 0 + + publication_type = 'magazine' + masthead_url = 'http://factcheck.org/wp-content/themes/Streamline/images/headernew.jpg' + cover_url = 'http://factcheck.org/wp-content/themes/Streamline/images/headernew.jpg' + + remove_tags = [ dict({'id':['footer','footerabout','sidebar']}) ] + + + feeds = [(u'Factcheck', u'feed://www.factcheck.org/feed/')] + diff --git a/resources/recipes/politifact.recipe b/resources/recipes/politifact.recipe new file mode 100644 index 0000000000..d06f3a51e0 --- /dev/null +++ b/resources/recipes/politifact.recipe @@ -0,0 +1,30 @@ +from calibre.wb.feeds.news import BasicNewsRecipe + +class PolitiFactCom(BasicNewsRecipe): + title = u'Politifact' + __author__ = u'Michael Heinz' + oldest_article = 21 + max_articles_per_feed = 100 + recursion = 0 + language = 'en' + + no_stylesheets = True + + publication_type = 'magazine' + masthead_url = 'http://static.politifact.com.s3.amazonaws.com/images/politifactdotcom-flag-fff_01.png' + cover_url = 'http://static.politifact.com.s3.amazonaws.com/images/politifactdotcom-flag-fff_01.png' + + remove_tags = [ + dict(name='div', attrs={'class':'pfstoryarchive'}), + dict(name='div', attrs={'class':'pfhead'}), + dict(name='div', attrs={'class':'boxmid'}), + ] + + keep_only_tags = [dict(name='div', attrs={'class':'pfcontentleft'})] + feeds = [ + (u'Articles', u'http://www.politifact.com/feeds/articles/truth-o-meter/'), + (u'Obamameter', u'http://politifact.com/feeds/updates/'), + (u'Statements', u'http://www.politifact.com/feeds/statements/truth-o-meter/') + ] + + diff --git a/src/calibre/devices/manager.py b/src/calibre/devices/manager.py index bc493d9f1a..b00e944d05 100644 --- a/src/calibre/devices/manager.py +++ b/src/calibre/devices/manager.py @@ -10,7 +10,7 @@ import threading, Queue class DeviceManager(object): - + def __init__(self): self.devices = [] self.device_jobs = Queue(0) @@ -21,19 +21,19 @@ class Job(object): def __init__(self, func, args): self.completed = False self.exception = None - + class Worker(threading.Thread): - - def __init__(self, jobs): + + def __init__(self, jobs): self.jobs = jobs self.results = [] threading.Thread.__init__(self) self.setDaemon(True) - + def run(self): '''Thread loops taking jobs from the queue as they become available''' while True: - job = self.jobs.get(True, None) + self.jobs.get(True, None) # Do job - self.jobs.task_done() \ No newline at end of file + self.jobs.task_done() diff --git a/src/calibre/ebooks/rtf2xml/paragraph_def.py b/src/calibre/ebooks/rtf2xml/paragraph_def.py index 6635024273..aa13f9e4e2 100755 --- a/src/calibre/ebooks/rtf2xml/paragraph_def.py +++ b/src/calibre/ebooks/rtf2xml/paragraph_def.py @@ -354,7 +354,6 @@ if another paragraph_def is found, the state changes to collect_tokens. def __tab_stop_func(self, line): """ """ - type = 'tabs-%s' % self.__tab_type self.__att_val_dict['tabs'] += '%s:' % self.__tab_type self.__att_val_dict['tabs'] += '%s;' % line[20:-1] self.__tab_type = 'left' @@ -373,7 +372,6 @@ if another paragraph_def is found, the state changes to collect_tokens. """ leader = self.__tab_type_dict.get(self.__token_info) if leader != None: - type = 'tabs-%s' % self.__tab_type self.__att_val_dict['tabs'] += '%s^' % leader else: if self.__run_level > 3: diff --git a/src/calibre/ebooks/rtf2xml/styles.py b/src/calibre/ebooks/rtf2xml/styles.py index c551b7ad3c..815a64e4f4 100755 --- a/src/calibre/ebooks/rtf2xml/styles.py +++ b/src/calibre/ebooks/rtf2xml/styles.py @@ -318,7 +318,6 @@ class Styles: Try to add the number to dictionary entry tabs-left, or tabs-right, etc. If the dictionary entry doesn't exist, create one. """ - type = 'tabs-%s' % self.__tab_type try: if self.__leader_found: self.__styles_dict['par'][self.__styles_num]['tabs']\ @@ -362,7 +361,6 @@ class Styles: leader = self.__tab_type_dict.get(self.__token_info) if leader != None: leader += '^' - type = 'tabs-%s' % self.__tab_type try: self.__styles_dict['par'][self.__styles_num]['tabs'] += ':%s;' % leader except KeyError: From 17dde7b2065def6b8e1557000ae0a4003ef156f5 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Wed, 19 May 2010 00:09:17 -0600 Subject: [PATCH 8/8] Partial implementation of the new SONY drivers. Updating sony cache not yet supported. --- src/calibre/devices/prs505/books.py | 370 ----------------------- src/calibre/devices/prs505/driver.py | 47 +-- src/calibre/devices/prs505/sony_cache.py | 249 +++++++++++++++ 3 files changed, 277 insertions(+), 389 deletions(-) delete mode 100644 src/calibre/devices/prs505/books.py create mode 100644 src/calibre/devices/prs505/sony_cache.py diff --git a/src/calibre/devices/prs505/books.py b/src/calibre/devices/prs505/books.py deleted file mode 100644 index 61f3e3c363..0000000000 --- a/src/calibre/devices/prs505/books.py +++ /dev/null @@ -1,370 +0,0 @@ -__license__ = 'GPL v3' -__copyright__ = '2008, Kovid Goyal ' -''' -''' -import re, time, functools -from uuid import uuid4 as _uuid -import xml.dom.minidom as dom -from base64 import b64encode as encode - - -from calibre.devices.usbms.books import BookList as _BookList -from calibre.devices import strftime as _strftime -from calibre.devices.prs505 import MEDIA_XML, CACHE_XML -from calibre.devices.errors import PathError - -strftime = functools.partial(_strftime, zone=time.gmtime) - -MIME_MAP = { - "lrf" : "application/x-sony-bbeb", - 'lrx' : 'application/x-sony-bbeb', - "rtf" : "application/rtf", - "pdf" : "application/pdf", - "txt" : "text/plain" , - 'epub': 'application/epub+zip', - } - -def uuid(): - return str(_uuid()).replace('-', '', 1).upper() - -def sortable_title(title): - return re.sub('^\s*A\s+|^\s*The\s+|^\s*An\s+', '', title).rstrip() - -class BookList(_BookList): - - def __init__(self, oncard, prefix, settings): - _BookList.__init__(self, oncard, prefix, settings) - if prefix is None: - return - self.sony_id_cache = {} - self.books_lpath_cache = {} - opts = settings() - self.collections = opts.extra_customization.split(',') if opts.extra_customization else [] - db = CACHE_XML if oncard else MEDIA_XML - with open(prefix + db, 'rb') as xml_file: - xml_file.seek(0) - self.document = dom.parse(xml_file) - self.root_element = self.document.documentElement - self.mountpath = prefix - records = self.root_element.getElementsByTagName('records') - - if records: - self.prefix = 'xs1:' - self.root_element = records[0] - else: - self.prefix = '' - for child in self.root_element.childNodes: - if child.nodeType == child.ELEMENT_NODE and child.hasAttribute("id"): - self.sony_id_cache[child.getAttribute('id')] = child.getAttribute('path') - # set the key to none. Will be filled in later when booklist is built - self.books_lpath_cache[child.getAttribute('path')] = None - self.tag_order = {} - - paths = self.purge_corrupted_files() - for path in paths: - try: - self.del_file(path, end_session=False) - except PathError: # Incase this is a refetch without a sync in between - continue - - - def max_id(self): - max = 0 - for child in self.root_element.childNodes: - if child.nodeType == child.ELEMENT_NODE and child.hasAttribute("id"): - nid = int(child.getAttribute('id')) - if nid > max: - max = nid - return max - - def is_id_valid(self, id): - '''Return True iff there is an element with C{id==id}.''' - id = str(id) - for child in self.root_element.childNodes: - if child.nodeType == child.ELEMENT_NODE and child.hasAttribute("id"): - if child.getAttribute('id') == id: - return True - return False - - def supports_tags(self): - return True - - def add_book(self, book, replace_metadata): - # Add a node into the DOM tree, representing a book. Also add to booklist - if book in self: - # replacing metadata for book - self.delete_node(book.lpath) - else: - self.append(book) - if not replace_metadata: - if self.books_lpath_cache.has_key(book.lpath): - self.books_lpath_cache[book.lpath] = book - return - # Book not in metadata. Add it. Note that we don't need to worry about - # extra books in the Sony metadata. The reader deletes them for us when - # we disconnect. That said, if it becomes important one day, we can do - # it by scanning the books_lpath_cache for None entries and removing the - # corresponding nodes. - self.books_lpath_cache[book.lpath] = book - cid = self.max_id()+1 - node = self.document.createElement(self.prefix + "text") - self.sony_id_cache[cid] = book.lpath - mime = MIME_MAP.get(book.lpath.rpartition('.')[-1].lower(), MIME_MAP['epub']) - try: - sourceid = str(self[0].sourceid) if len(self) else '1' - except: - sourceid = '1' - attrs = { - "title" : book.title, - 'titleSorter' : sortable_title(book.title), - "author" : book.format_authors() if book.format_authors() else _('Unknown'), - "page":"0", "part":"0", "scale":"0", \ - "sourceid":sourceid, "id":str(cid), "date":"", \ - "mime":mime, "path":book.lpath, "size":str(book.size) - } - for attr in attrs.keys(): - node.setAttributeNode(self.document.createAttribute(attr)) - node.setAttribute(attr, attrs[attr]) - try: - w, h, data = book.thumbnail - except: - w, h, data = None, None, None - - if data: - th = self.document.createElement(self.prefix + "thumbnail") - th.setAttribute("width", str(w)) - th.setAttribute("height", str(h)) - jpeg = self.document.createElement(self.prefix + "jpeg") - jpeg.appendChild(self.document.createTextNode(encode(data))) - th.appendChild(jpeg) - node.appendChild(th) - self.root_element.appendChild(node) - - tags = [] - for item in self.collections: - item = item.strip() - mitem = getattr(book, item, None) - titems = [] - if mitem: - if isinstance(mitem, list): - titems = mitem - else: - titems = [mitem] - if item == 'tags' and titems: - litems = [] - for i in titems: - if not i.strip().startswith('[') and not i.strip().endswith(']'): - litems.append(i) - titems = litems - tags.extend(titems) - if tags: - tags = list(set(tags)) - if hasattr(book, 'tag_order'): - self.tag_order.update(book.tag_order) - self.set_playlists(cid, tags) - return True # metadata cache has changed. Must sync at end - - def _delete_node(self, node): - nid = node.getAttribute('id') - self.remove_from_playlists(nid) - node.parentNode.removeChild(node) - node.unlink() - - def delete_node(self, lpath): - ''' - Remove DOM node corresponding to book with lpath. - Also remove book from any collections it is part of. - ''' - for child in self.root_element.childNodes: - if child.nodeType == child.ELEMENT_NODE and child.hasAttribute("id"): - if child.getAttribute('path') == lpath: - self._delete_node(child) - break - - def remove_book(self, book): - ''' - Remove DOM node corresponding to book with C{path == path}. - Also remove book from any collections it is part of, and remove - from the booklist - ''' - self.remove(book) - self.delete_node(book.lpath) - - def playlists(self): - ans = [] - for c in self.root_element.childNodes: - if hasattr(c, 'tagName') and c.tagName.endswith('playlist'): - ans.append(c) - return ans - - def playlist_items(self): - plitems = [] - for pl in self.playlists(): - for c in pl.childNodes: - if hasattr(c, 'tagName') and c.tagName.endswith('item') and \ - hasattr(c, 'getAttribute'): - try: - c.getAttribute('id') - except: # Unlinked node - continue - plitems.append(c) - return plitems - - def purge_corrupted_files(self): - if not self.root_element: - return [] - corrupted = self.root_element.getElementsByTagName(self.prefix+'corrupted') - paths = [] - for c in corrupted: - paths.append(c.getAttribute('path')) - c.parentNode.removeChild(c) - c.unlink() - return paths - - def purge_empty_playlists(self): - ''' Remove all playlists that have no children. Also removes any invalid playlist items.''' - for pli in self.playlist_items(): - try: - if not self.is_id_valid(pli.getAttribute('id')): - pli.parentNode.removeChild(pli) - pli.unlink() - except: - continue - for pl in self.playlists(): - empty = True - for c in pl.childNodes: - if hasattr(c, 'tagName') and c.tagName.endswith('item'): - empty = False - break - if empty: - pl.parentNode.removeChild(pl) - pl.unlink() - - def playlist_by_title(self, title): - for pl in self.playlists(): - if pl.getAttribute('title').lower() == title.lower(): - return pl - - def add_playlist(self, title): - cid = self.max_id()+1 - pl = self.document.createElement(self.prefix+'playlist') - pl.setAttribute('id', str(cid)) - pl.setAttribute('title', title) - pl.setAttribute('uuid', uuid()) - self.root_element.insertBefore(pl, self.root_element.childNodes[-1]) - return pl - - def remove_from_playlists(self, id): - for pli in self.playlist_items(): - if pli.getAttribute('id') == str(id): - pli.parentNode.removeChild(pli) - pli.unlink() - - def set_tags(self, book, tags): - tags = [t for t in tags if t] - book.tags = tags - self.set_playlists(book.id, tags) - - def set_playlists(self, id, collections): - self.remove_from_playlists(id) - for collection in set(collections): - coll = self.playlist_by_title(collection) - if not coll: - coll = self.add_playlist(collection) - item = self.document.createElement(self.prefix+'item') - item.setAttribute('id', str(id)) - coll.appendChild(item) - - def next_id(self): - return self.document.documentElement.getAttribute('nextID') - - def set_next_id(self, id): - self.document.documentElement.setAttribute('nextID', str(id)) - - def write(self, stream): - """ Write XML representation of DOM tree to C{stream} """ - src = self.document.toxml('utf-8') + '\n' - stream.write(src.replace("'", ''')) - - def reorder_playlists(self): - for title in self.tag_order.keys(): - pl = self.playlist_by_title(title) - if not pl: - continue - # make a list of the ids - sony_ids = [id.getAttribute('id') \ - for id in pl.childNodes if hasattr(id, 'getAttribute')] - # convert IDs in playlist to a list of lpaths - sony_paths = [self.sony_id_cache[id] for id in sony_ids] - # create list of books containing lpaths - books = [self.books_lpath_cache.get(p, None) for p in sony_paths] - # create dict of db_id -> sony_id - imap = {} - for book, sony_id in zip(books, sony_ids): - if book is not None: - db_id = book.application_id - if db_id is None: - db_id = book.db_id - if db_id is not None: - imap[book.application_id] = sony_id - # filter the list, removing books not on device but on playlist - books = [i for i in books if i is not None] - # filter the order specification to the books we have - ordered_ids = [db_id for db_id in self.tag_order[title] if db_id in imap] - - # rewrite the playlist in the correct order - if len(ordered_ids) < len(pl.childNodes): - continue - children = [i for i in pl.childNodes if hasattr(i, 'getAttribute')] - for child in children: - pl.removeChild(child) - child.unlink() - for id in ordered_ids: - item = self.document.createElement(self.prefix+'item') - item.setAttribute('id', str(imap[id])) - pl.appendChild(item) - -def fix_ids(main, carda, cardb): - ''' - Adjust ids the XML databases. - ''' - if hasattr(main, 'purge_empty_playlists'): - main.purge_empty_playlists() - if hasattr(carda, 'purge_empty_playlists'): - carda.purge_empty_playlists() - if hasattr(cardb, 'purge_empty_playlists'): - cardb.purge_empty_playlists() - - def regen_ids(db): - if not hasattr(db, 'root_element'): - return - id_map = {} - db.purge_empty_playlists() - cid = 0 if db == main else 1 - for child in db.root_element.childNodes: - if child.nodeType == child.ELEMENT_NODE and child.hasAttribute('id'): - id_map[child.getAttribute('id')] = str(cid) - child.setAttribute("sourceid", - '0' if getattr(child, 'tagName', '').endswith('playlist') else '1') - child.setAttribute('id', str(cid)) - cid += 1 - - for item in db.playlist_items(): - oid = item.getAttribute('id') - try: - item.setAttribute('id', id_map[oid]) - except KeyError: - item.parentNode.removeChild(item) - item.unlink() - db.reorder_playlists() - db.sony_id_cache = {} - for child in db.root_element.childNodes: - if child.nodeType == child.ELEMENT_NODE and child.hasAttribute("id"): - db.sony_id_cache[child.getAttribute('id')] = child.getAttribute('path') - - - regen_ids(main) - regen_ids(carda) - regen_ids(cardb) - - main.set_next_id(str(main.max_id()+1)) diff --git a/src/calibre/devices/prs505/driver.py b/src/calibre/devices/prs505/driver.py index 9926e5f61c..0bf2a1de82 100644 --- a/src/calibre/devices/prs505/driver.py +++ b/src/calibre/devices/prs505/driver.py @@ -13,9 +13,9 @@ import os import re from calibre.devices.usbms.driver import USBMS -from calibre.devices.prs505.books import BookList as PRS_BookList, fix_ids from calibre.devices.prs505 import MEDIA_XML from calibre.devices.prs505 import CACHE_XML +from calibre.devices.prs505.sony_cache import XMLCache from calibre import __appname__ class PRS505(USBMS): @@ -27,8 +27,6 @@ class PRS505(USBMS): supported_platforms = ['windows', 'osx', 'linux'] path_sep = '/' - booklist_class = PRS_BookList # See usbms.driver for some explanation of this - FORMATS = ['epub', 'lrf', 'lrx', 'rtf', 'pdf', 'txt'] VENDOR_ID = [0x054c] #: SONY Vendor Id @@ -72,23 +70,34 @@ class PRS505(USBMS): fname = base + suffix + '.' + fname.rpartition('.')[-1] return fname - def sync_booklists(self, booklists, end_session=True): - fix_ids(*booklists) - if not os.path.exists(self._main_prefix): - os.makedirs(self._main_prefix) - with open(self._main_prefix + MEDIA_XML, 'wb') as f: - booklists[0].write(f) + def initialize_XML_cache(self): + paths = {} + for prefix, path, source_id in [ + ('main', MEDIA_XML, 0), + ('card_a', CACHE_XML, 1), + ('card_b', CACHE_XML, 2) + ]: + prefix = getattr(self, '_%s_prefix'%prefix) + if prefix is not None and os.path.exists(prefix): + paths[source_id] = os.path.join(prefix, *(path.split('/'))) + d = os.path.dirname(paths[source_id]) + if not os.path.exists(d): + os.makedirs(d) + return XMLCache(paths) - def write_card_prefix(prefix, listid): - if prefix is not None and hasattr(booklists[listid], 'write'): - tgt = os.path.join(prefix, *(CACHE_XML.split('/'))) - base = os.path.dirname(tgt) - if not os.path.exists(base): - os.makedirs(base) - with open(tgt, 'wb') as f: - booklists[listid].write(f) - write_card_prefix(self._card_a_prefix, 1) - write_card_prefix(self._card_b_prefix, 2) + def books(self, oncard=None, end_session=True): + bl = USBMS.books(self, oncard=oncard, end_session=end_session) + c = self.initialize_XML_cache() + c.update_booklist(bl, {'carda':1, 'cardb':2}.get(oncard, 0)) + return bl + + def sync_booklists(self, booklists, end_session=True): + c = self.initialize_XML_cache() + blists = {} + for i in c.paths: + blists[i] = booklists[i] + c.update(blists) + c.write() USBMS.sync_booklists(self, booklists, end_session) diff --git a/src/calibre/devices/prs505/sony_cache.py b/src/calibre/devices/prs505/sony_cache.py new file mode 100644 index 0000000000..f365bba3ab --- /dev/null +++ b/src/calibre/devices/prs505/sony_cache.py @@ -0,0 +1,249 @@ +#!/usr/bin/env python +# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai + +__license__ = 'GPL v3' +__copyright__ = '2010, Kovid Goyal ' +__docformat__ = 'restructuredtext en' + +import os +from pprint import pprint +from base64 import b64decode + +from lxml import etree + +from calibre import prints +from calibre.devices.errors import DeviceError +from calibre.constants import DEBUG +from calibre.ebooks.chardet import xml_to_unicode +from calibre.ebooks.metadata import string_to_authors + +EMPTY_CARD_CACHE = '''\ + + + +''' + +class XMLCache(object): + + def __init__(self, paths): + if DEBUG: + pprint(paths) + self.paths = paths + parser = etree.XMLParser(recover=True) + self.roots = {} + for source_id, path in paths.items(): + if source_id == 0: + if not os.path.exists(path): + raise DeviceError('The SONY XML cache media.xml does not exist. Try' + ' disconnecting and reconnecting your reader.') + with open(path, 'rb') as f: + raw = f.read() + else: + raw = EMPTY_CARD_CACHE + if os.access(path, os.R_OK): + with open(path, 'rb') as f: + raw = f.read() + self.roots[source_id] = etree.fromstring(xml_to_unicode( + raw, strip_encoding_pats=True, assume_utf8=True, + verbose=DEBUG)[0], + parser=parser) + + recs = self.roots[0].xpath('//*[local-name()="records"]') + if not recs: + raise DeviceError('The SONY XML database is corrupted (no )') + self.record_roots = {} + self.record_roots.update(self.roots) + self.record_roots[0] = recs[0] + + self.detect_namespaces() + + + # Playlist management {{{ + def purge_broken_playlist_items(self, root): + for item in root.xpath( + '//*[local-name()="playlist"]/*[local-name()="item"]'): + id_ = item.get('id', None) + if id_ is None or not root.xpath( + '//*[local-name()!="item" and @id="%s"]'%id_): + if DEBUG: + prints('Purging broken playlist item:', + etree.tostring(item, with_tail=False)) + item.getparent().remove(item) + + + def prune_empty_playlists(self): + for i, root in self.record_roots.items(): + self.purge_broken_playlist_items(root) + for playlist in root.xpath('//*[local-name()="playlist"]'): + if len(playlist) == 0: + if DEBUG: + prints('Removing playlist:', playlist.get('id', None)) + playlist.getparent().remove(playlist) + + # }}} + + def fix_ids(self): # {{{ + + def ensure_numeric_ids(root): + idmap = {} + for x in root.xpath('//*[@id]'): + id_ = x.get('id') + try: + id_ = int(id_) + except: + x.set('id', '-1') + idmap[id_] = '-1' + + if DEBUG and idmap: + prints('Found non numeric ids:') + prints(list(idmap.keys())) + return idmap + + def remap_playlist_references(root, idmap): + for playlist in root.xpath('//*[local-name()="playlist"]'): + for item in playlist.xpath( + 'descendant::*[@id and local-name()="item"]'): + id_ = item.get('id') + if id_ in idmap: + item.set('id', idmap[id_]) + if DEBUG: + prints('Remapping id %s to %s'%(id_, idmap[id_])) + + def ensure_media_xml_base_ids(root): + for num, tag in enumerate(('library', 'watchSpecial')): + for x in root.xpath('//*[local-name()="%s"]'%tag): + x.set('id', str(num)) + + def rebase_ids(root, base, sourceid, pl_sourceid): + 'Rebase all ids and also make them consecutive' + for item in root.xpath('//*[@sourceid]'): + sid = pl_sourceid if item.tag.endswith('playlist') else sourceid + item.set('sourceid', str(sid)) + items = root.xpath('//*[@id]') + items.sort(cmp=lambda x,y:cmp(int(x.get('id')), int(y.get('id')))) + idmap = {} + for i, item in enumerate(items): + old = int(item.get('id')) + new = base + i + if old != new: + item.set('id', str(new)) + idmap[old] = str(new) + return idmap + + self.prune_empty_playlists() + + for i in sorted(self.roots.keys()): + root = self.roots[i] + if i == 0: + ensure_media_xml_base_ids(root) + + idmap = ensure_numeric_ids(root) + remap_playlist_references(root, idmap) + if i == 0: + sourceid, playlist_sid = 1, 0 + base = 0 + else: + previous = i-1 + if previous not in self.roots: + previous = 0 + max_id = self.max_id(self.roots[previous]) + sourceid = playlist_sid = max_id + 1 + base = max_id + 2 + idmap = rebase_ids(root, base, sourceid, playlist_sid) + remap_playlist_references(root, idmap) + + last_bl = max(self.roots.keys()) + max_id = self.max_id(self.roots[last_bl]) + self.roots[0].set('nextID', str(max_id+1)) + # }}} + + def update_booklist(self, bl, bl_index): # {{{ + if bl_index not in self.record_roots: + return + root = self.record_roots[bl_index] + for book in bl: + record = self.book_by_lpath(book.lpath, root) + if record is not None: + title = record.get('title', None) + if title is not None and title != book.title: + if DEBUG: + prints('Renaming title', book.title, 'to', title) + book.title = title + authors = record.get('author', None) + if authors is not None: + authors = string_to_authors(authors) + if authors != book.authors: + if DEBUG: + prints('Renaming authors', book.authors, 'to', + authors) + book.authors = authors + for thumbnail in record.xpath( + 'descendant::*[local-name()="thumbnail"]'): + for img in thumbnail.xpath( + 'descendant::*[local-name()="jpeg"]|' + 'descendant::*[local-name()="png"]'): + if img.text: + raw = b64decode(img.text.strip()) + ext = img.tag.split('}')[-1] + book.cover_data = [ext, raw] + break + break + # }}} + + def update(self, booklists): + pass + + def write(self): + return + for i, path in self.paths.items(): + raw = etree.tostring(self.roots[i], encoding='utf-8', + xml_declaration=True) + with open(path, 'wb') as f: + f.write(raw) + + def book_by_lpath(self, lpath, root): + matches = root.xpath(u'//*[local-name()="text" and @path="%s"]'%lpath) + if matches: + return matches[0] + + + def max_id(self, root): + ans = -1 + for x in root.xpath('//*[@id]'): + id_ = x.get('id') + try: + num = int(id_) + if num > ans: + ans = num + except: + continue + return ans + + def detect_namespaces(self): + self.nsmaps = {} + for i, root in self.roots.items(): + self.nsmaps[i] = root.nsmap + + self.namespaces = {} + for i in self.roots: + for c in ('library', 'text', 'image', 'playlist', 'thumbnail', + 'watchSpecial'): + matches = self.record_roots[i].xpath('//*[local-name()="%s"]'%c) + if matches: + e = matches[0] + self.namespaces[i] = e.nsmap[e.prefix] + break + if i not in self.namespaces: + ns = self.nsmaps[i].get(None, None) + for prefix in self.nsmaps[i]: + if prefix is not None: + ns = self.nsmaps[i][prefix] + break + self.namespaces[i] = ns + + if DEBUG: + prints('Found nsmaps:') + pprint(self.nsmaps) + prints('Found namespaces:') + pprint(self.namespaces) +