Bulk editing for device collections in the device view via the context menu

This commit is contained in:
Kovid Goyal 2010-06-23 22:05:08 -06:00
commit 195a3a9cd1
11 changed files with 204 additions and 101 deletions

View File

@ -99,7 +99,7 @@ class PRS505(USBMS):
if self._card_b_prefix is not None:
if not write_cache(self._card_b_prefix):
self._card_b_prefix = None
self.booklist_class.rebuild_collections = self.rebuild_collections
def get_device_information(self, end_session=True):
return (self.gui_name, '', '', '')
@ -156,4 +156,10 @@ class PRS505(USBMS):
USBMS.sync_booklists(self, booklists, end_session=end_session)
debug_print('PRS505: finished sync_booklists')
def rebuild_collections(self, booklist, oncard):
debug_print('PRS505: started rebuild_collections')
c = self.initialize_XML_cache()
c.rebuild_collections(booklist, {'carda':1, 'cardb':2}.get(oncard, 0))
c.write()
debug_print('PRS505: finished rebuild_collections')

View File

@ -61,8 +61,7 @@ class XMLCache(object):
def __init__(self, paths, prefixes, use_author_sort):
if DEBUG:
debug_print('Building XMLCache...')
pprint(paths)
debug_print('Building XMLCache...', paths)
self.paths = paths
self.prefixes = prefixes
self.use_author_sort = use_author_sort
@ -347,6 +346,12 @@ class XMLCache(object):
self.fix_ids()
debug_print('Finished update XML from JSON')
def rebuild_collections(self, booklist, bl_index):
if bl_index not in self.record_roots:
return
root = self.record_roots[bl_index]
self.update_playlists(bl_index, root, booklist, [])
def update_playlists(self, bl_index, root, booklist, collections_attributes):
debug_print('Starting update_playlists', collections_attributes)
collections = booklist.get_collections(collections_attributes)

View File

@ -167,3 +167,15 @@ class CollectionsBookList(BookList):
books.sort(cmp=lambda x,y:cmp(getter(x), getter(y)))
return collections
def rebuild_collections(self, booklist, oncard):
'''
For each book in the booklist for the card oncard, remove it from all
its current collections, then add it to the collections specified in
device_collections.
oncard is None for the main memory, carda for card A, cardb for card B,
etc.
booklist is the object created by the :method:`books` call above.
'''
pass

View File

@ -21,6 +21,7 @@ from calibre.utils.filenames import ascii_filename
from calibre.gui2.widgets import IMAGE_EXTENSIONS
from calibre.gui2.dialogs.metadata_single import MetadataSingleDialog
from calibre.gui2.dialogs.metadata_bulk import MetadataBulkDialog
from calibre.gui2.dialogs.tag_list_editor import TagListEditor
from calibre.gui2.tools import convert_single_ebook, convert_bulk_ebook, \
fetch_scheduled_recipe, generate_catalog
from calibre.constants import preferred_encoding, filesystem_encoding, \
@ -831,6 +832,21 @@ class EditMetadataAction(object): # {{{
db.set_metadata(dest_id, dest_mi, ignore_errors=False)
# }}}
def edit_device_collections(self, view):
model = view.model()
result = model.get_collections_with_ids()
compare = (lambda x,y:cmp(x.lower(), y.lower()))
d = TagListEditor(self, tag_to_match=None, data=result, compare=compare)
d.exec_()
if d.result() == d.Accepted:
to_rename = d.to_rename # dict of new text to old id
to_delete = d.to_delete # list of ids
for text in to_rename:
model.rename_collection(old_id=to_rename[text], new_name=unicode(text))
for item in to_delete:
model.delete_collection_using_id(item)
self.upload_collections(model.db, view=view)
# }}}
class SaveToDiskAction(object): # {{{

View File

@ -294,6 +294,11 @@ class DeviceManager(Thread): # {{{
return self.create_job(self._sync_booklists, done, args=[booklists],
description=_('Send metadata to device'))
def upload_collections(self, done, booklist, on_card):
return self.create_job(booklist.rebuild_collections, done,
args=[booklist, on_card],
description=_('Send collections to device'))
def _upload_books(self, files, names, on_card=None, metadata=None):
'''Upload books to device: '''
return self.device.upload_books(files, names, on_card,
@ -1234,6 +1239,18 @@ class DeviceMixin(object): # {{{
self.card_a_view.reset()
self.card_b_view.reset()
def _upload_collections(self, job, view):
if job.failed:
self.device_job_exception(job)
view.reset()
def upload_collections(self, booklist, view):
on_card = 'carda' if self.stack.currentIndex() == 2 else \
'cardb' if self.stack.currentIndex() == 3 else \
None
done = partial(self._upload_collections, view=view)
return self.device_manager.upload_collections(done, booklist, on_card)
def upload_books(self, files, names, metadata, on_card=None, memory=None):
'''
Upload books to device.

View File

@ -1,51 +1,31 @@
__license__ = 'GPL v3'
__copyright__ = '2008, Kovid Goyal <kovid at kovidgoyal.net>'
from functools import partial
from PyQt4.QtCore import SIGNAL, Qt
from PyQt4.QtGui import QDialog, QListWidgetItem
from calibre.gui2.dialogs.tag_list_editor_ui import Ui_TagListEditor
from calibre.gui2 import question_dialog, error_dialog
from calibre.ebooks.metadata import title_sort
class TagListEditor(QDialog, Ui_TagListEditor):
def __init__(self, window, db, tag_to_match, category):
def __init__(self, window, tag_to_match, data, compare):
QDialog.__init__(self, window)
Ui_TagListEditor.__init__(self)
self.setupUi(self)
self.to_rename = {}
self.to_delete = []
self.db = db
self.all_tags = {}
self.category = category
if category == 'tags':
result = db.get_tags_with_ids()
compare = (lambda x,y:cmp(x.lower(), y.lower()))
elif category == 'series':
result = db.get_series_with_ids()
compare = (lambda x,y:cmp(title_sort(x).lower(), title_sort(y).lower()))
elif category == 'publisher':
result = db.get_publishers_with_ids()
compare = (lambda x,y:cmp(x.lower(), y.lower()))
else: # should be a custom field
self.cc_label = None
if category in db.field_metadata:
self.cc_label = db.field_metadata[category]['label']
result = self.db.get_custom_items_with_ids(label=self.cc_label)
else:
result = []
compare = (lambda x,y:cmp(x.lower(), y.lower()))
for k,v in result:
for k,v in data:
self.all_tags[v] = k
for tag in sorted(self.all_tags.keys(), cmp=compare):
item = QListWidgetItem(tag)
item.setData(Qt.UserRole, self.all_tags[tag])
self.available_tags.addItem(item)
if tag_to_match is not None:
items = self.available_tags.findItems(tag_to_match, Qt.MatchExactly)
if len(items) == 1:
self.available_tags.setCurrentItem(items[0])
@ -62,11 +42,6 @@ class TagListEditor(QDialog, Ui_TagListEditor):
item.setText(self.item_before_editing.text())
return
if item.text() != self.item_before_editing.text():
if item.text() in self.all_tags.keys() or item.text() in self.to_rename.keys():
error_dialog(self, _('Item already used'),
_('The item %s is already used.')%(item.text())).exec_()
item.setText(self.item_before_editing.text())
return
(id,ign) = self.item_before_editing.data(Qt.UserRole).toInt()
self.to_rename[item.text()] = id
@ -99,30 +74,3 @@ class TagListEditor(QDialog, Ui_TagListEditor):
self.to_delete.append(id)
self.available_tags.takeItem(self.available_tags.row(item))
def accept(self):
rename_func = None
if self.category == 'tags':
rename_func = self.db.rename_tag
delete_func = self.db.delete_tag_using_id
elif self.category == 'series':
rename_func = self.db.rename_series
delete_func = self.db.delete_series_using_id
elif self.category == 'publisher':
rename_func = self.db.rename_publisher
delete_func = self.db.delete_publisher_using_id
else:
rename_func = partial(self.db.rename_custom_item, label=self.cc_label)
delete_func = partial(self.db.delete_custom_item_using_id, label=self.cc_label)
work_done = False
if rename_func:
for text in self.to_rename:
work_done = True
rename_func(id=self.to_rename[text], new_name=unicode(text))
for item in self.to_delete:
work_done = True
delete_func(item)
if not work_done:
QDialog.reject(self)
else:
QDialog.accept(self)

View File

@ -226,17 +226,22 @@ class LibraryViewMixin(object): # {{{
self.action_show_book_details,
self.action_del,
add_to_library = None,
edit_device_collections=None,
similar_menu=similar_menu)
add_to_library = (_('Add books to library'), self.add_books_from_device)
edit_device_collections = (_('Manage collections'), self.edit_device_collections)
self.memory_view.set_context_menu(None, None, None,
self.action_view, self.action_save, None, None, self.action_del,
add_to_library=add_to_library)
add_to_library=add_to_library,
edit_device_collections=edit_device_collections)
self.card_a_view.set_context_menu(None, None, None,
self.action_view, self.action_save, None, None, self.action_del,
add_to_library=add_to_library)
add_to_library=add_to_library,
edit_device_collections=edit_device_collections)
self.card_b_view.set_context_menu(None, None, None,
self.action_view, self.action_save, None, None, self.action_del,
add_to_library=add_to_library)
add_to_library=add_to_library,
edit_device_collections=edit_device_collections)
self.library_view.files_dropped.connect(self.files_dropped, type=Qt.QueuedConnection)
for func, args in [
@ -249,8 +254,11 @@ class LibraryViewMixin(object): # {{{
getattr(view, func)(*args)
self.memory_view.connect_dirtied_signal(self.upload_booklists)
self.memory_view.connect_upload_collections_signal(self.upload_collections)
self.card_a_view.connect_dirtied_signal(self.upload_booklists)
self.card_a_view.connect_upload_collections_signal(self.upload_collections)
self.card_b_view.connect_dirtied_signal(self.upload_booklists)
self.card_b_view.connect_upload_collections_signal(self.upload_collections)
self.book_on_device(None, reset=True)
db.set_book_on_device_func(self.book_on_device)

View File

@ -857,6 +857,7 @@ class OnDeviceSearch(SearchQueryParser): # {{{
class DeviceBooksModel(BooksModel): # {{{
booklist_dirtied = pyqtSignal()
upload_collections = pyqtSignal(object)
def __init__(self, parent):
BooksModel.__init__(self, parent)
@ -977,8 +978,8 @@ class DeviceBooksModel(BooksModel): # {{{
x, y = int(self.db[x].size), int(self.db[y].size)
return cmp(x, y)
def tagscmp(x, y):
x = ','.join(self.db[x].device_collections)
y = ','.join(self.db[y].device_collections)
x = ','.join(getattr(self.db[x], 'device_collections', [])).lower()
y = ','.join(getattr(self.db[y], 'device_collections', [])).lower()
return cmp(x, y)
def libcmp(x, y):
x, y = self.db[x].in_library, self.db[y].in_library
@ -1026,6 +1027,9 @@ class DeviceBooksModel(BooksModel): # {{{
def set_database(self, db):
self.custom_columns = {}
self.db = db
for book in db:
if book.device_collections is not None:
book.device_collections.sort(cmp=lambda x,y: cmp(x.lower(), y.lower()))
self.map = list(range(0, len(db)))
def current_changed(self, current, previous):
@ -1079,6 +1083,36 @@ class DeviceBooksModel(BooksModel): # {{{
res.append((r,b))
return res
def get_collections_with_ids(self):
collections = set()
for book in self.db:
if book.device_collections is not None:
collections.update(set(book.device_collections))
self.collections = []
result = []
for i,collection in enumerate(collections):
result.append((i, collection))
self.collections.append(collection)
return result
def rename_collection(self, old_id, new_name):
old_name = self.collections[old_id]
for book in self.db:
if book.device_collections is None:
continue
if old_name in book.device_collections:
book.device_collections.remove(old_name)
if new_name not in book.device_collections:
book.device_collections.append(new_name)
def delete_collection_using_id(self, old_id):
old_name = self.collections[old_id]
for book in self.db:
if book.device_collections is None:
continue
if old_name in book.device_collections:
book.device_collections.remove(old_name)
def indices(self, rows):
'''
Return indices into underlying database from rows
@ -1109,7 +1143,7 @@ class DeviceBooksModel(BooksModel): # {{{
elif cname == 'collections':
tags = self.db[self.map[row]].device_collections
if tags:
return QVariant(', '.join(sorted(tags, key=str.lower)))
return QVariant(', '.join(tags))
elif role == Qt.ToolTipRole and index.isValid():
if self.map[row] in self.indices_to_be_deleted():
return QVariant(_('Marked for deletion'))
@ -1151,14 +1185,17 @@ class DeviceBooksModel(BooksModel): # {{{
return False
val = unicode(value.toString()).strip()
idx = self.map[row]
if cname == 'collections':
tags = [i.strip() for i in val.split(',')]
tags = [t for t in tags if t]
self.db[idx].device_collections = tags
self.upload_collections.emit(self.db)
return True
if cname == 'title' :
self.db[idx].title = val
elif cname == 'authors':
self.db[idx].authors = string_to_authors(val)
elif cname == 'collections':
tags = [i.strip() for i in val.split(',')]
tags = [t for t in tags if t]
self.db[idx].device_collections = tags
self.dataChanged.emit(index, index)
self.booklist_dirtied.emit()
done = True

View File

@ -371,7 +371,8 @@ class BooksView(QTableView): # {{{
# Context Menu {{{
def set_context_menu(self, edit_metadata, send_to_device, convert, view,
save, open_folder, book_details, delete,
similar_menu=None, add_to_library=None):
similar_menu=None, add_to_library=None,
edit_device_collections=None):
self.setContextMenuPolicy(Qt.DefaultContextMenu)
self.context_menu = QMenu(self)
if edit_metadata is not None:
@ -393,6 +394,9 @@ class BooksView(QTableView): # {{{
if add_to_library is not None:
func = partial(add_to_library[1], view=self)
self.context_menu.addAction(add_to_library[0], func)
if edit_device_collections is not None:
func = partial(edit_device_collections[1], view=self)
self.context_menu.addAction(edit_device_collections[0], func)
def contextMenuEvent(self, event):
self.context_menu.popup(event.globalPos())
@ -505,6 +509,9 @@ class DeviceBooksView(BooksView): # {{{
def connect_dirtied_signal(self, slot):
self._model.booklist_dirtied.connect(slot)
def connect_upload_collections_signal(self, func):
self._model.upload_collections.connect(partial(func, view=self))
def dropEvent(self, *args):
error_dialog(self, _('Not allowed'),
_('Dropping onto a device is not supported. First add the book to the calibre library.')).exec_()

View File

@ -15,6 +15,7 @@ from PyQt4.Qt import Qt, QTreeView, QApplication, pyqtSignal, \
QAbstractItemModel, QVariant, QModelIndex, QMenu, \
QPushButton, QWidget, QItemDelegate
from calibre.ebooks.metadata import title_sort
from calibre.gui2 import config, NONE
from calibre.utils.config import prefs
from calibre.library.field_metadata import TagsIcons
@ -680,9 +681,49 @@ class TagBrowserMixin(object): # {{{
self.tags_view.recount()
def do_tags_list_edit(self, tag, category):
d = TagListEditor(self, self.library_view.model().db, tag, category)
db=self.library_view.model().db
if category == 'tags':
result = db.get_tags_with_ids()
compare = (lambda x,y:cmp(x.lower(), y.lower()))
elif category == 'series':
result = db.get_series_with_ids()
compare = (lambda x,y:cmp(title_sort(x).lower(), title_sort(y).lower()))
elif category == 'publisher':
result = db.get_publishers_with_ids()
compare = (lambda x,y:cmp(x.lower(), y.lower()))
else: # should be a custom field
cc_label = None
if category in db.field_metadata:
cc_label = db.field_metadata[category]['label']
result = self.db.get_custom_items_with_ids(label=cc_label)
else:
result = []
compare = (lambda x,y:cmp(x.lower(), y.lower()))
d = TagListEditor(self, tag_to_match=tag, data=result, compare=compare)
d.exec_()
if d.result() == d.Accepted:
to_rename = d.to_rename # dict of new text to old id
to_delete = d.to_delete # list of ids
rename_func = None
if category == 'tags':
rename_func = db.rename_tag
delete_func = db.delete_tag_using_id
elif category == 'series':
rename_func = db.rename_series
delete_func = db.delete_series_using_id
elif category == 'publisher':
rename_func = db.rename_publisher
delete_func = db.delete_publisher_using_id
else:
rename_func = partial(db.rename_custom_item, label=cc_label)
delete_func = partial(db.delete_custom_item_using_id, label=cc_label)
if rename_func:
for text in to_rename:
rename_func(old_id=to_rename[text], new_name=unicode(text))
for item in to_delete:
delete_func(item)
# Clean up everything, as information could have changed for many books.
self.library_view.model().refresh()
self.tags_view.set_new_model()

View File

@ -104,14 +104,20 @@ will appear in the next release of |app|.
How does |app| manage collections on my SONY reader?
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
When you send a book to the device, |app| will create collections based on the metadata for that book. By default, collections are created
from tags and series. You can control what metadata is used by going to Preferences->Plugins->Device Interface plugins and customizing
When |app| connects with the device, it retrieves all collections for the books on the device. The collections
of which books are members are shown on the device view.
When you send a book to the device, |app| will if necessary create new collections based on the metadata for
that book, then add the book to the collections. By default, collections are created from tags and series. You
can control what metadata is used by going to Preferences->Plugins->Device Interface plugins and customizing
the SONY device interface plugin.
You can edit collections on the device in the device view in |app| by double clicking or right clicking on the collections field.
|app| will not delete already existing collections for a book on your device when you resend the book to the
device. To ensure that the collections are based only on current |app| metadata, first delete the books from
the device, and then resend the books.
|app| will not delete already existing collections on your device. To ensure that the collections are based only on current |app| metadata,
delete and resend the books to the device.
You can edit collections on the device in the device view in |app| by double clicking or right clicking on the
collections field. This is the only way to remove a book from a collection.
Can I use both |app| and the SONY software to manage my reader?
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@ -296,7 +302,7 @@ Take your pick:
Why does |app| show only some of my fonts on OS X?
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|app| embeds fonts in ebook files it creates. E-book files support embedding only TrueType (.ttf) fonts. Most fonts on OS X systems are in .dfont format, thus they cannot be embedded. |app| shows only TrueType fonts founf on your system. You can obtain many TrueType fonts on the web. Simply download the .ttf files and add them to the Library/Fonts directory in your home directory.
|app| embeds fonts in ebook files it creates. E-book files support embedding only TrueType (.ttf) fonts. Most fonts on OS X systems are in .dfont format, thus they cannot be embedded. |app| shows only TrueType fonts found on your system. You can obtain many TrueType fonts on the web. Simply download the .ttf files and add them to the Library/Fonts directory in your home directory.
|app| is not starting on Windows?
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~