Add two features:

1) The ability to update cached metadata on the device by right clicking
the device icon and selecting 'Update cached metadata'. This has the
same effect as disconnecting and re-connecting the device. It basically
refreshes the device view to reflect any changes made to metadata in the
library after the device was connected.

2) Allow manually matching books on the device to book in the library.
You can right click on a book in the device view and select "Match book
to library" to tell calibre that a book on the device is the same as a
particular book in the library. For the change to stick, you have to use
the 'Update cached metadata' action described above.
This commit is contained in:
Kovid Goyal 2013-07-11 13:14:58 +05:30
commit b18b6e98b9
10 changed files with 408 additions and 5 deletions

2
.gitignore vendored
View File

@ -14,7 +14,6 @@ build
dist dist
docs docs
resources/localization resources/localization
resources/images.qrc
resources/scripts.pickle resources/scripts.pickle
resources/ebook-convert-complete.pickle resources/ebook-convert-complete.pickle
resources/builtin_recipes.xml resources/builtin_recipes.xml
@ -42,3 +41,4 @@ calibre_plugins/
recipes/*.mobi recipes/*.mobi
recipes/*.epub recipes/*.epub
recipes/debug recipes/debug
/.metadata/

View File

@ -886,6 +886,11 @@ class ActionEditCollections(InterfaceActionBase):
actual_plugin = 'calibre.gui2.actions.edit_collections:EditCollectionsAction' actual_plugin = 'calibre.gui2.actions.edit_collections:EditCollectionsAction'
description = _('Edit the collections in which books are placed on your device') 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): class ActionCopyToLibrary(InterfaceActionBase):
name = 'Copy To Library' name = 'Copy To Library'
actual_plugin = 'calibre.gui2.actions.copy_to_library:CopyToLibraryAction' actual_plugin = 'calibre.gui2.actions.copy_to_library:CopyToLibraryAction'
@ -936,7 +941,7 @@ plugins += [ActionAdd, ActionFetchAnnotations, ActionGenerateCatalog,
ActionFetchNews, ActionSaveToDisk, ActionQuickview, ActionPolish, ActionFetchNews, ActionSaveToDisk, ActionQuickview, ActionPolish,
ActionShowBookDetails,ActionRestart, ActionOpenFolder, ActionConnectShare, ActionShowBookDetails,ActionRestart, ActionOpenFolder, ActionConnectShare,
ActionSendToDevice, ActionHelp, ActionPreferences, ActionSimilarBooks, ActionSendToDevice, ActionHelp, ActionPreferences, ActionSimilarBooks,
ActionAddToLibrary, ActionEditCollections, ActionChooseLibrary, ActionAddToLibrary, ActionEditCollections, ActionMatchBooks, ActionChooseLibrary,
ActionCopyToLibrary, ActionTweakEpub, ActionNextMatch, ActionStore, ActionCopyToLibrary, ActionTweakEpub, ActionNextMatch, ActionStore,
ActionPluginUpdater, ActionPickRandom, ActionEditToC] ActionPluginUpdater, ActionPickRandom, ActionEditToC]

View File

@ -73,7 +73,7 @@ defs['action-layout-context-menu'] = (
defs['action-layout-context-menu-device'] = ( defs['action-layout-context-menu-device'] = (
'View', 'Save To Disk', None, 'Remove Books', None, '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'] = ( defs['action-layout-context-menu-cover-browser'] = (

View File

@ -0,0 +1,39 @@
#!/usr/bin/env python
# vim:fileencoding=utf-8
from __future__ import (unicode_literals, division, absolute_import,
print_function)
__license__ = 'GPL v3'
__copyright__ = '2013, Kovid Goyal <kovid@kovidgoyal.net>'
__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_()

View File

@ -1604,6 +1604,10 @@ class DeviceMixin(object): # {{{
except: except:
pass 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): def book_on_device(self, id, reset=False):
''' '''
Return an indication of whether the given book represented by its db id 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] loc[4] |= self.book_db_uuid_path_map[id]
return loc 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. Set the ondevice indications in the device database.
This method should be called before book_on_device is called, because 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 '' x = x.lower() if x else ''
return string_pat.sub('', x) 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 get_covers = False
desired_thumbnail_height = 0 desired_thumbnail_height = 0

View File

@ -0,0 +1,202 @@
#!/usr/bin/env python
# vim:fileencoding=utf-8
from __future__ import (unicode_literals, division, absolute_import,
print_function)
__license__ = 'GPL v3'
__copyright__ = '2013, 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)

View File

@ -0,0 +1,140 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>MatchBooks</class>
<widget class="QDialog" name="MatchBooks">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>751</width>
<height>342</height>
</rect>
</property>
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="MinimumExpanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="windowTitle">
<string>Match Books</string>
</property>
<layout class="QGridLayout">
<item row="0" column="0">
<widget class="HistoryLineEdit" name="search_text">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
<horstretch>100</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>350</width>
<height>0</height>
</size>
</property>
<property name="sizeAdjustPolicy">
<enum>QComboBox::AdjustToMinimumContentsLengthWithIcon</enum>
</property>
<property name="minimumContentsLength">
<number>30</number>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QPushButton" name="search_button">
<property name="text">
<string>Search</string>
</property>
</widget>
</item>
<item row="1" column="0" colspan="2">
<widget class="QLabel" name="label">
<property name="text">
<string>Do a search to find the book you want to match</string>
</property>
</widget>
</item>
<item row="2" column="0" colspan="2">
<widget class="QTableWidget" name="books_table">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>4</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="rowCount">
<number>0</number>
</property>
<property name="columnCount">
<number>0</number>
</property>
</widget>
</item>
<item row="3" column="0" colspan="2">
<widget class="QLabel" name="label">
<property name="text">
<string>&lt;p&gt;Remember to update metadata on the device when you are done (Right click the device icon and select &lt;i&gt;Update cached metadata&lt;/i&gt;)&lt;/p&gt;</string>
</property>
<property name="wordWrap">
<bool>true</bool>
</property>
</widget>
</item>
<item row="4" column="0" colspan="2">
<layout class="QHBoxLayout">
<item>
<spacer>
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>0</width>
<height>0</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QDialogButtonBox" name="buttonBox">
<property name="standardButtons">
<set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
</property>
<property name="centerButtons">
<bool>false</bool>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<customwidgets>
<customwidget>
<class>HistoryLineEdit</class>
<extends>QComboBox</extends>
<header>calibre/gui2/widgets.h</header>
</customwidget>
</customwidgets>
<resources/>
<connections>
<connection>
<sender>buttonBox</sender>
<signal>rejected()</signal>
<receiver>MatchBooks</receiver>
<slot>reject()</slot>
<hints>
<hint type="sourcelabel">
<x>297</x>
<y>217</y>
</hint>
<hint type="destinationlabel">
<x>286</x>
<y>234</y>
</hint>
</hints>
</connection>
</connections>
</ui>

View File

@ -25,6 +25,7 @@ class LocationManager(QObject): # {{{
unmount_device = pyqtSignal() unmount_device = pyqtSignal()
location_selected = pyqtSignal(object) location_selected = pyqtSignal(object)
configure_device = pyqtSignal() configure_device = pyqtSignal()
update_device_metadata = pyqtSignal()
def __init__(self, parent=None): def __init__(self, parent=None):
QObject.__init__(self, parent) QObject.__init__(self, parent)
@ -60,6 +61,9 @@ class LocationManager(QObject): # {{{
a = m.addAction(QIcon(I('config.png')), _('Configure this device')) a = m.addAction(QIcon(I('config.png')), _('Configure this device'))
a.triggered.connect(self._configure_requested) a.triggered.connect(self._configure_requested)
self._mem.append(a) self._mem.append(a)
a = m.addAction(QIcon(I('sync.png')), _('Update cached metadata on device'))
a.triggered.connect(lambda x : self.update_device_metadata.emit())
self._mem.append(a)
else: else:
ac.setToolTip(tooltip) ac.setToolTip(tooltip)

View File

@ -1207,6 +1207,8 @@ class DeviceBooksModel(BooksModel): # {{{
self.search_engine = OnDeviceSearch(self) self.search_engine = OnDeviceSearch(self)
self.editable = ['title', 'authors', 'collections'] self.editable = ['title', 'authors', 'collections']
self.book_in_library = None self.book_in_library = None
self.sync_icon = QIcon(I('sync.png'))
def counts(self): def counts(self):
return Counts(len(self.db), len(self.db), len(self.map)) return Counts(len(self.db), len(self.db), len(self.map))
@ -1535,6 +1537,8 @@ class DeviceBooksModel(BooksModel): # {{{
elif DEBUG and cname == 'inlibrary': elif DEBUG and cname == 'inlibrary':
return QVariant(self.db[self.map[row]].in_library) return QVariant(self.db[self.map[row]].in_library)
elif role == Qt.ToolTipRole and index.isValid(): 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): if self.is_row_marked_for_deletion(row):
return QVariant(_('Marked for deletion')) return QVariant(_('Marked for deletion'))
if cname in ['title', 'authors'] or (cname == 'collections' and if cname in ['title', 'authors'] or (cname == 'collections' and
@ -1543,6 +1547,8 @@ class DeviceBooksModel(BooksModel): # {{{
elif role == Qt.DecorationRole and cname == 'inlibrary': elif role == Qt.DecorationRole and cname == 'inlibrary':
if self.db[self.map[row]].in_library: if self.db[self.map[row]].in_library:
return QVariant(self.bool_yes_icon) 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: elif self.db[self.map[row]].in_library is not None:
return QVariant(self.bool_no_icon) return QVariant(self.bool_no_icon)
elif role == Qt.TextAlignmentRole: elif role == Qt.TextAlignmentRole:

View File

@ -293,6 +293,7 @@ class Main(MainWindow, MainWindowMixin, DeviceMixin, EmailMixin, # {{{
self.location_manager.location_selected.connect(self.location_selected) self.location_manager.location_selected.connect(self.location_selected)
self.location_manager.unmount_device.connect(self.device_manager.umount_device) self.location_manager.unmount_device.connect(self.device_manager.umount_device)
self.location_manager.configure_device.connect(self.configure_connected_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) self.eject_action.triggered.connect(self.device_manager.umount_device)
#################### Update notification ################### #################### Update notification ###################