diff --git a/src/calibre/gui2/library.py b/src/calibre/gui2/library.py index 75e3411fdf..632e29f103 100644 --- a/src/calibre/gui2/library.py +++ b/src/calibre/gui2/library.py @@ -772,7 +772,7 @@ class BooksView(TableView): self.setItemDelegateForColumn(cm.index('series'), self.series_delegate) def set_context_menu(self, edit_metadata, send_to_device, convert, view, - save, open_folder, book_details, delete, similar_menu=None): + save, open_folder, book_details, merge, delete, similar_menu=None): self.setContextMenuPolicy(Qt.DefaultContextMenu) self.context_menu = QMenu(self) if edit_metadata is not None: @@ -785,6 +785,8 @@ class BooksView(TableView): self.context_menu.addAction(save) if open_folder is not None: self.context_menu.addAction(open_folder) + if merge is not None: + self.context_menu.addAction(merge) if delete is not None: self.context_menu.addAction(delete) if book_details is not None: diff --git a/src/calibre/gui2/main.ui b/src/calibre/gui2/main.ui index 5cf8f9d35e..09f7e1a35f 100644 --- a/src/calibre/gui2/main.ui +++ b/src/calibre/gui2/main.ui @@ -470,6 +470,7 @@ + @@ -530,6 +531,21 @@ false + + + + :/images/merge_books.svg:/images/merge_books.svg + + + Merge books + + + M + + + false + + false diff --git a/src/calibre/gui2/ui.py b/src/calibre/gui2/ui.py index 86aaa7c89c..4631f8557f 100644 --- a/src/calibre/gui2/ui.py +++ b/src/calibre/gui2/ui.py @@ -255,6 +255,13 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI): md.addAction(_('Download only covers')) md.addAction(_('Download only social metadata')) self.metadata_menu = md + + mb = QMenu() + mb.addAction(_('Merge into first selected book - delete others.')) + mb.addSeparator() + mb.addAction(_('Merge into first selected book - keep others.')) + self.merge_menu = mb + self.add_menu = QMenu() self.add_menu.addAction(_('Add books from a single directory')) self.add_menu.addAction(_('Add books from directories, including ' @@ -301,6 +308,14 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI): QObject.connect(md.actions()[7], SIGNAL('triggered(bool)'), self.__em6__) + QObject.connect(self.action_merge, SIGNAL("triggered(bool)"), + self.merge_books) + self.__mb1__ = partial(self.merge_books) + QObject.connect(mb.actions()[0], SIGNAL('triggered(bool)'), + self.__mb1__) + self.__mb2__ = partial(self.merge_books_safe) + QObject.connect(mb.actions()[2], SIGNAL('triggered(bool)'), + self.__mb2__) self.save_menu = QMenu() self.save_menu.addAction(_('Save to disk')) @@ -317,6 +332,11 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI): self.view_menu.addAction(_('View specific format')) self.action_view.setMenu(self.view_menu) + self.merge_menu = QMenu() + self.merge_menu.addAction(_('Merge into first selected book - delete others.')) + self.merge_menu.addAction(_('Merge into first selected book - keep others.')) + self.action_merge.setMenu(self.merge_menu) + self.delete_menu = QMenu() self.delete_menu.addAction(_('Remove selected books')) self.delete_menu.addAction( @@ -343,6 +363,11 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI): Qt.QueuedConnection) self.connect(self.action_open_containing_folder, SIGNAL('triggered(bool)'), self.view_folder) + + self.merge_menu.actions()[0].triggered.connect(self.merge_books) + self.merge_menu.actions()[1].triggered.connect(self.merge_books_safe) + + self.delete_menu.actions()[0].triggered.connect(self.delete_books) self.delete_menu.actions()[1].triggered.connect(self.delete_selected_formats) self.delete_menu.actions()[2].triggered.connect(self.delete_all_but_selected_formats) @@ -354,7 +379,9 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI): self.action_sync.setEnabled(True) self.create_device_menu() self.action_edit.setMenu(md) + self.action_merge.setMenu(self.merge_menu) self.action_save.setMenu(self.save_menu) + cm = QMenu() cm.addAction(_('Convert individually')) cm.addAction(_('Bulk convert')) @@ -388,6 +415,8 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI): setPopupMode(QToolButton.MenuButtonPopup) self.tool_bar.widgetForAction(self.action_edit).\ setPopupMode(QToolButton.MenuButtonPopup) + self.tool_bar.widgetForAction(self.action_merge).\ + setPopupMode(QToolButton.MenuButtonPopup) self.tool_bar.widgetForAction(self.action_sync).\ setPopupMode(QToolButton.MenuButtonPopup) self.tool_bar.widgetForAction(self.action_convert).\ @@ -440,14 +469,17 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI): self.action_save, self.action_open_containing_folder, self.action_show_book_details, + self.action_merge, self.action_del, similar_menu=similar_menu) + self.memory_view.set_context_menu(None, None, None, - self.action_view, self.action_save, None, None, self.action_del) + self.action_view, self.action_save, None, None, None, self.action_del) self.card_a_view.set_context_menu(None, None, None, - self.action_view, self.action_save, None, None, self.action_del) + self.action_view, self.action_save, None, None, None, self.action_del) self.card_b_view.set_context_menu(None, None, None, - self.action_view, self.action_save, None, None, self.action_del) + self.action_view, self.action_save, None, None, None, self.action_del) + QObject.connect(self.library_view, SIGNAL('files_dropped(PyQt_PyObject)'), self.files_dropped, Qt.QueuedConnection) @@ -815,7 +847,6 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI): else: print msg - def current_view(self): '''Convenience method that returns the currently visible view ''' idx = self.stack.currentIndex() @@ -1517,6 +1548,139 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI): ############################################################################ + ############################### Merge books ############################## + def merge_books(self, safe_merge=False): + ''' + Merge selected books in library. + ''' + if self.stack.currentIndex() == 0: + self.db = self.library_view.model().db + rows = self.library_view.selectionModel().selectedRows() + if not rows or len(rows) == 0: + d = error_dialog(self, _('Cannot merge books'), + _('No books selected')) + d.exec_() + return + if len(rows) < 2: + d = error_dialog(self, _('Cannot merge books'), + _('At least two books must be selected for merging')) + d.exec_() + return + r = self.books_to_merge(rows) + dest_id = r[0] + src_books = r[1] + src_ids = r[2] + if safe_merge: + if not confirm('

'+_('All book formats and metadata from the selected books ' + 'will be added to the first selected book. The second and ' + 'subsequently selected books will not be deleted or changed. ' + 'Please confirm you want to proceed.') + +'

', 'merge_books_safe', self): + return + self.add_formats(dest_id, src_books) + self.merge_metadata(dest_id, src_ids) + else: + if not confirm('

'+_('All book formats and metadata from the selected books will be merged ' + 'into the first selected book. After merger the second and ' + 'subsequently selected books will be deleted. ' + 'All book formats of the first selected book will be kept ' + 'and any duplicate formats in the second and subsequently selected books ' + 'will be permanently deleted from your computer. ' + 'Are you sure you want to proceed?') + +'

', 'merge_books', self): + return + if len(rows)>5: + if not confirm('

'+_('You are about to merge more than 5 books. ' + 'Are you sure you want to proceed?') + +'

', 'merge_too_many_books', self): + return + self.add_formats(dest_id, src_books) + self.merge_metadata(dest_id, src_ids) + self.delete_books_after_merge(src_ids) + # leave the selection highlight on first selected book + dest_row = rows[0].row() + for row in rows: + if row.row() < rows[0].row(): + dest_row -= 1 + ci = self.library_view.model().index(dest_row, 0) + if ci.isValid(): + self.library_view.setCurrentIndex(ci) + + def merge_books_safe(self): + self.merge_books(safe_merge=True) + + def add_formats(self, dest_id, src_books, replace=False): + for src_book in src_books: + if src_book: + fmt = os.path.splitext(src_book)[-1].replace('.', '').upper() + with open(src_book, 'rb') as f: + self.db.add_format(dest_id, fmt, f, index_is_id=True, + notify=False, replace=replace) + + def books_to_merge (self, rows): + dest_book = True + src_books = [] + src_ids = [] + for row in rows: + if dest_book: + dest_id = self.library_view.model().db.id(row.row()) + else: + self._metadata_view_id = self.library_view.model().db.id(row.row()) + dbfmts = self.library_view.model().db.formats(self._metadata_view_id, index_is_id=True) + id = self._metadata_view_id + src_ids.append(id) + library_path = self.library_path + book_path = self.library_view.model().db.construct_path_name(id) + book_fname = self.library_view.model().db.construct_file_name(id) + current_path_fname = library_path + '/' + book_path + '/' + book_fname + current_path_fname = current_path_fname.replace(os.sep, '/') + if dbfmts: + dbfmts = dbfmts.split(',') + for dbfmt in dbfmts: + file_name = current_path_fname + '.' + dbfmt + src_books.append(file_name) + dest_book = False + return [dest_id, src_books, src_ids] + + def delete_books_after_merge(self, ids_to_delete): + for id in ids_to_delete: + self.library_view.model().db.delete_book(id, notify=True) + + def merge_metadata(self, dest_id, src_ids): + db = self.library_view.model().db + dest_mi = db.get_metadata(dest_id, index_is_id=True, get_cover=True) + for src_id in src_ids: + src_mi = db.get_metadata(src_id, index_is_id=True, get_cover=True) + if not dest_mi.comments or len(dest_mi.comments) == 0: + dest_mi.comments = src_mi.comments + elif dest_mi.comments == src_mi.comments: + pass + else: + dest_mi.comments = unicode(dest_mi.comments) + u'\r' + unicode(src_mi.comments) + if dest_mi.title == _('Unknown'): + dest_mi.title = src_mi.title + if dest_mi.authors[0] == _('Unknown'): + dest_mi.authors = src_mi.authors + dest_mi.author_sort = src_mi.author_sort + if not dest_mi.tags: + dest_mi.tags = src_mi.tags + else: + for tag in src_mi.tags: + dest_mi.tags.append(tag) + # sort tags? + if not dest_mi.cover: + dest_mi.cover = src_mi.cover + if not dest_mi.publisher: + dest_mi.publisher = src_mi.publisher + if not dest_mi.rating: + dest_mi.rating = src_mi.rating + if not dest_mi.series: + dest_mi.series = src_mi.series + dest_mi.series_index = src_mi.series_index + db.set_metadata(dest_id, dest_mi, ignore_errors=False) + + ############################################################################ + ############################## Save to disk ################################ def save_single_format_to_disk(self, checked): @@ -2027,6 +2191,7 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI): self.status_bar.reset_info() if location == 'library': self.action_edit.setEnabled(True) + self.action_merge.setEnabled(True) self.action_convert.setEnabled(True) self.view_menu.actions()[1].setEnabled(True) self.action_open_containing_folder.setEnabled(True) @@ -2037,6 +2202,7 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI): action.setEnabled(True) else: self.action_edit.setEnabled(False) + self.action_merge.setEnabled(False) self.action_convert.setEnabled(False) self.view_menu.actions()[1].setEnabled(False) self.action_open_containing_folder.setEnabled(False)