From b6c0716cff07a99c518032e028d71724cbb5f818 Mon Sep 17 00:00:00 2001 From: Charles Haley Date: Wed, 10 Jul 2013 17:44:00 +0200 Subject: [PATCH] Add: 1) send metadata to device while connected 2) ability to manually match books on device with books in library --- src/calibre/customize/builtins.py | 7 +- src/calibre/gui2/__init__.py | 2 +- src/calibre/gui2/actions/match_books.py | 37 +++++ src/calibre/gui2/device.py | 10 +- src/calibre/gui2/dialogs/match_books.py | 197 ++++++++++++++++++++++++ src/calibre/gui2/dialogs/match_books.ui | 138 +++++++++++++++++ src/calibre/gui2/layout.py | 4 + src/calibre/gui2/library/models.py | 6 + src/calibre/gui2/ui.py | 1 + 9 files changed, 398 insertions(+), 4 deletions(-) create mode 100644 src/calibre/gui2/actions/match_books.py create mode 100644 src/calibre/gui2/dialogs/match_books.py create mode 100644 src/calibre/gui2/dialogs/match_books.ui diff --git a/src/calibre/customize/builtins.py b/src/calibre/customize/builtins.py index e6073a3bd4..8fce645c6e 100644 --- a/src/calibre/customize/builtins.py +++ b/src/calibre/customize/builtins.py @@ -886,6 +886,11 @@ class ActionEditCollections(InterfaceActionBase): actual_plugin = 'calibre.gui2.actions.edit_collections:EditCollectionsAction' description = _('Edit the collections in which books are placed on your device') +class ActionMatchBooks(InterfaceActionBase): + name = 'Match Books' + actual_plugin = 'calibre.gui2.actions.match_books:MatchBookAction' + description = _('Match book on the devices to books in the library') + class ActionCopyToLibrary(InterfaceActionBase): name = 'Copy To Library' actual_plugin = 'calibre.gui2.actions.copy_to_library:CopyToLibraryAction' @@ -936,7 +941,7 @@ plugins += [ActionAdd, ActionFetchAnnotations, ActionGenerateCatalog, ActionFetchNews, ActionSaveToDisk, ActionQuickview, ActionPolish, ActionShowBookDetails,ActionRestart, ActionOpenFolder, ActionConnectShare, ActionSendToDevice, ActionHelp, ActionPreferences, ActionSimilarBooks, - ActionAddToLibrary, ActionEditCollections, ActionChooseLibrary, + ActionAddToLibrary, ActionEditCollections, ActionMatchBooks, ActionChooseLibrary, ActionCopyToLibrary, ActionTweakEpub, ActionNextMatch, ActionStore, ActionPluginUpdater, ActionPickRandom, ActionEditToC] diff --git a/src/calibre/gui2/__init__.py b/src/calibre/gui2/__init__.py index a552ad8594..2fd2135956 100644 --- a/src/calibre/gui2/__init__.py +++ b/src/calibre/gui2/__init__.py @@ -73,7 +73,7 @@ defs['action-layout-context-menu'] = ( defs['action-layout-context-menu-device'] = ( 'View', 'Save To Disk', None, 'Remove Books', None, - 'Add To Library', 'Edit Collections', + 'Add To Library', 'Edit Collections', 'Match Books' ) defs['action-layout-context-menu-cover-browser'] = ( diff --git a/src/calibre/gui2/actions/match_books.py b/src/calibre/gui2/actions/match_books.py new file mode 100644 index 0000000000..28b6afeba9 --- /dev/null +++ b/src/calibre/gui2/actions/match_books.py @@ -0,0 +1,37 @@ +#!/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.gui2 import error_dialog +from calibre.gui2.actions import InterfaceAction +from calibre.gui2.dialogs.match_books import MatchBooks + +class MatchBookAction(InterfaceAction): + + name = 'Match Books' + action_spec = (_('Match book to library'), 'book.png', + _('Match this book to a book in the library'), + ()) + dont_add_to = frozenset(['menubar', 'toolbar', 'context-menu', 'toolbar-child']) + action_type = 'current' + + def genesis(self): + self.qaction.triggered.connect(self.match_books_in_library) + + def location_selected(self, loc): + enabled = loc != 'library' + self.qaction.setEnabled(enabled) + + def match_books_in_library(self, *args): + view = self.gui.current_view() + rows = view.selectionModel().selectedRows() + if not rows or len(rows) != 1: + d = error_dialog(self.gui, _('Match books'), _('You must select one book')) + d.exec_() + return + + id_ = view.model().indices(rows)[0] + MatchBooks(self.gui, view, id_).exec_() diff --git a/src/calibre/gui2/device.py b/src/calibre/gui2/device.py index 15dc1f0c0a..d3225e66e7 100644 --- a/src/calibre/gui2/device.py +++ b/src/calibre/gui2/device.py @@ -1604,6 +1604,10 @@ class DeviceMixin(object): # {{{ except: pass + def update_metadata_on_device(self): + self.set_books_in_library(self.booklists(), reset=True, force_send=True) + self.refresh_ondevice() + def book_on_device(self, id, reset=False): ''' Return an indication of whether the given book represented by its db id @@ -1652,7 +1656,8 @@ class DeviceMixin(object): # {{{ loc[4] |= self.book_db_uuid_path_map[id] return loc - def set_books_in_library(self, booklists, reset=False, add_as_step_to_job=None): + def set_books_in_library(self, booklists, reset=False, add_as_step_to_job=None, + force_send=False): ''' Set the ondevice indications in the device database. This method should be called before book_on_device is called, because @@ -1675,7 +1680,8 @@ class DeviceMixin(object): # {{{ x = x.lower() if x else '' return string_pat.sub('', x) - update_metadata = device_prefs['manage_device_metadata'] == 'on_connect' + update_metadata = ( + device_prefs['manage_device_metadata'] == 'on_connect' or force_send) get_covers = False desired_thumbnail_height = 0 diff --git a/src/calibre/gui2/dialogs/match_books.py b/src/calibre/gui2/dialogs/match_books.py new file mode 100644 index 0000000000..847d1cbc70 --- /dev/null +++ b/src/calibre/gui2/dialogs/match_books.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python +__license__ = 'GPL v3' +__copyright__ = '2008, Kovid Goyal kovid@kovidgoyal.net' +__docformat__ = 'restructuredtext en' + + +from PyQt4.Qt import (Qt, QDialog, QAbstractItemView, QTableWidgetItem, + QByteArray) + +from calibre.gui2 import gprefs, error_dialog +from calibre.gui2.dialogs.match_books_ui import Ui_MatchBooks +from calibre.utils.icu import sort_key + +class TableItem(QTableWidgetItem): + ''' + A QTableWidgetItem that sorts on a separate string and uses ICU rules + ''' + + def __init__(self, val, sort, idx=0): + self.sort = sort + self.sort_idx = idx + QTableWidgetItem.__init__(self, val) + self.setFlags(Qt.ItemIsEnabled|Qt.ItemIsSelectable) + + def __ge__(self, other): + l = sort_key(self.sort) + r = sort_key(other.sort) + if l > r: + return 1 + if l == r: + return self.sort_idx >= other.sort_idx + return 0 + + def __lt__(self, other): + l = sort_key(self.sort) + r = sort_key(other.sort) + if l < r: + return 1 + if l == r: + return self.sort_idx < other.sort_idx + return 0 + +class MatchBooks(QDialog, Ui_MatchBooks): + + def __init__(self, gui, view, id_): + QDialog.__init__(self, gui, flags=Qt.Window) + Ui_MatchBooks.__init__(self) + self.setupUi(self) + self.isClosed = False + + self.books_table_column_widths = None + try: + self.books_table_column_widths = \ + gprefs.get('match_books_dialog_books_table_widths', None) + geom = gprefs.get('match_books_dialog_geometry', bytearray('')) + self.restoreGeometry(QByteArray(geom)) + except: + pass + + self.search_text.initialize('match_books_dialog') + + # Remove the help button from the window title bar + icon = self.windowIcon() + self.setWindowFlags(self.windowFlags()&(~Qt.WindowContextHelpButtonHint)) + self.setWindowIcon(icon) + + self.device_db = view.model().db + self.library_db = gui.library_view.model().db + self.view = view + self.gui = gui + self.current_device_book_id = id_ + self.current_library_book_id = None + + # Set up the books table columns + self.books_table.setSelectionBehavior(QAbstractItemView.SelectRows) + self.books_table.setSelectionMode(QAbstractItemView.SingleSelection) + self.books_table.setColumnCount(3) + t = QTableWidgetItem(_('Title')) + self.books_table.setHorizontalHeaderItem(0, t) + t = QTableWidgetItem(_('Authors')) + self.books_table.setHorizontalHeaderItem(1, t) + t = QTableWidgetItem(_('Series')) + self.books_table.setHorizontalHeaderItem(2, t) + self.books_table_header_height = self.books_table.height() + self.books_table.cellDoubleClicked.connect(self.book_doubleclicked) + self.books_table.cellClicked.connect(self.book_clicked) + self.books_table.sortByColumn(0, Qt.AscendingOrder) + + # get the standard table row height. Do this here because calling + # resizeRowsToContents can word wrap long cell contents, creating + # double-high rows + self.books_table.setRowCount(1) + self.books_table.setItem(0, 0, TableItem('A', '')) + self.books_table.resizeRowsToContents() + self.books_table_row_height = self.books_table.rowHeight(0) + self.books_table.setRowCount(0) + + self.search_button.clicked.connect(self.do_search) + self.search_button.setDefault(False) + self.search_text.lineEdit().returnPressed.connect(self.return_pressed) + + self.buttonBox.accepted.connect(self.accept) + self.buttonBox.rejected.connect(self.reject) + self.ignore_next_key = False + + def return_pressed(self): + self.ignore_next_key = True + self.do_search() + + def keyPressEvent(self, e): + if self.ignore_next_key: + self.ignore_next_key = False + else: + QDialog.keyPressEvent(self, e) + + def do_search(self): + query = unicode(self.search_text.text()) + if not query: + d = error_dialog(self.gui, _('Match books'), + _('You must enter a search expression into the search box')) + d.exec_() + return + books = self.library_db.data.search(query, return_matches=True) + self.books_table.setRowCount(len(books)) + + self.books_table.setSortingEnabled(False) + for row, b in enumerate(books): + mi = self.library_db.get_metadata(b, index_is_id=True, get_user_categories=False) + a = TableItem(mi.title, mi.title_sort) + a.setData(Qt.UserRole, b) + self.books_table.setItem(row, 0, a) + a = TableItem(' & '.join(mi.authors), mi.author_sort) + self.books_table.setItem(row, 1, a) + series = mi.format_field('series')[1] + if series is None: + series = '' + a = TableItem(series, mi.series, mi.series_index) + self.books_table.setItem(row, 2, a) + self.books_table.setRowHeight(row, self.books_table_row_height) + + self.books_table.setSortingEnabled(True) + + # Deal with sizing the table columns. Done here because the numbers are not + # correct until the first paint. + def resizeEvent(self, *args): + QDialog.resizeEvent(self, *args) + if self.books_table_column_widths is not None: + for c,w in enumerate(self.books_table_column_widths): + self.books_table.setColumnWidth(c, w) + else: + # the vertical scroll bar might not be rendered, so might not yet + # have a width. Assume 25. Not a problem because user-changed column + # widths will be remembered + w = self.books_table.width() - 25 - self.books_table.verticalHeader().width() + w /= self.books_table.columnCount() + for c in range(0, self.books_table.columnCount()): + self.books_table.setColumnWidth(c, w) + self.save_state() + + def book_clicked(self, row, column): + self.book_selected = True; + id_ = self.books_table.item(row, 0).data(Qt.UserRole).toInt()[0] + self.current_library_book_id = id_ + + def book_doubleclicked(self, row, column): + self.book_clicked(row, column) + self.accept() + + def save_state(self): + self.books_table_column_widths = [] + for c in range(0, self.books_table.columnCount()): + self.books_table_column_widths.append(self.books_table.columnWidth(c)) + gprefs['match_books_dialog_books_table_widths'] = self.books_table_column_widths + gprefs['match_books_dialog_geometry'] = bytearray(self.saveGeometry()) + self.search_text.save_history() + + def close(self): + self.save_state() + # clean up to prevent memory leaks + self.device_db = self.view = self.gui = None + + def accept(self): + if not self.current_library_book_id: + d = error_dialog(self.gui, _('Match books'), + _('You must select a matching book')) + d.exec_() + return + mi = self.library_db.get_metadata(self.current_library_book_id, + index_is_id=True, get_user_categories=False) + self.device_db[self.current_device_book_id].smart_update(mi, replace_metadata=True) + self.device_db[self.current_device_book_id].in_library_waiting = True + self.save_state() + QDialog.accept(self) + + def reject(self): + self.close() + QDialog.reject(self) \ No newline at end of file diff --git a/src/calibre/gui2/dialogs/match_books.ui b/src/calibre/gui2/dialogs/match_books.ui new file mode 100644 index 0000000000..814b3b831d --- /dev/null +++ b/src/calibre/gui2/dialogs/match_books.ui @@ -0,0 +1,138 @@ + + + MatchBooks + + + + 0 + 0 + 768 + 342 + + + + + 0 + 0 + + + + Match Books + + + + + + + 100 + 0 + + + + + 350 + 0 + + + + QComboBox::AdjustToMinimumContentsLengthWithIcon + + + 30 + + + + + + + Search + + + + + + + Do a search to find the book you want to match + + + + + + + + 4 + 0 + + + + 0 + + + 0 + + + + + + + <p>Be sure to update metadata on the device when you are + finished matching books (Device -> Update Metadata)</p> + + + + + + + + + Qt::Horizontal + + + + 0 + 0 + + + + + + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + false + + + + + + + + + + + HistoryLineEdit + QComboBox +
calibre/gui2/widgets.h
+
+
+ + + buttonBox + rejected() + MatchBooks + reject() + + + 297 + 217 + + + 286 + 234 + + + + +
diff --git a/src/calibre/gui2/layout.py b/src/calibre/gui2/layout.py index 82e127970c..d8d91b99af 100644 --- a/src/calibre/gui2/layout.py +++ b/src/calibre/gui2/layout.py @@ -25,6 +25,7 @@ class LocationManager(QObject): # {{{ unmount_device = pyqtSignal() location_selected = pyqtSignal(object) configure_device = pyqtSignal() + update_device_metadata = pyqtSignal() def __init__(self, parent=None): QObject.__init__(self, parent) @@ -60,6 +61,9 @@ class LocationManager(QObject): # {{{ a = m.addAction(QIcon(I('config.png')), _('Configure this device')) a.triggered.connect(self._configure_requested) self._mem.append(a) + a = m.addAction(QIcon(I('sync.png')), _('Update metadata on device')) + a.triggered.connect(lambda x : self.update_device_metadata.emit()) + self._mem.append(a) else: ac.setToolTip(tooltip) diff --git a/src/calibre/gui2/library/models.py b/src/calibre/gui2/library/models.py index 324031aff0..3dc85bf46a 100644 --- a/src/calibre/gui2/library/models.py +++ b/src/calibre/gui2/library/models.py @@ -1207,6 +1207,8 @@ class DeviceBooksModel(BooksModel): # {{{ self.search_engine = OnDeviceSearch(self) self.editable = ['title', 'authors', 'collections'] self.book_in_library = None + self.sync_icon = QIcon(I('sync.png')) + def counts(self): return Counts(len(self.db), len(self.db), len(self.map)) @@ -1535,6 +1537,8 @@ class DeviceBooksModel(BooksModel): # {{{ elif DEBUG and cname == 'inlibrary': return QVariant(self.db[self.map[row]].in_library) elif role == Qt.ToolTipRole and index.isValid(): + if col == 0 and hasattr(self.db[self.map[row]], 'in_library_waiting'): + return QVariant(_('Waiting for metadata to be updated')) if self.is_row_marked_for_deletion(row): return QVariant(_('Marked for deletion')) if cname in ['title', 'authors'] or (cname == 'collections' and @@ -1543,6 +1547,8 @@ 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 hasattr(self.db[self.map[row]], 'in_library_waiting'): + return QVariant(self.sync_icon) elif self.db[self.map[row]].in_library is not None: return QVariant(self.bool_no_icon) elif role == Qt.TextAlignmentRole: diff --git a/src/calibre/gui2/ui.py b/src/calibre/gui2/ui.py index c9f07ef6f6..06cb4d904f 100644 --- a/src/calibre/gui2/ui.py +++ b/src/calibre/gui2/ui.py @@ -293,6 +293,7 @@ class Main(MainWindow, MainWindowMixin, DeviceMixin, EmailMixin, # {{{ self.location_manager.location_selected.connect(self.location_selected) self.location_manager.unmount_device.connect(self.device_manager.umount_device) self.location_manager.configure_device.connect(self.configure_connected_device) + self.location_manager.update_device_metadata.connect(self.update_metadata_on_device) self.eject_action.triggered.connect(self.device_manager.umount_device) #################### Update notification ###################