%PDF- %PDF-
Direktori : /lib/calibre/calibre/gui2/metadata/ |
Current File : //lib/calibre/calibre/gui2/metadata/single.py |
#!/usr/bin/env python3 __license__ = 'GPL v3' __copyright__ = '2011, Kovid Goyal <kovid@kovidgoyal.net>' __docformat__ = 'restructuredtext en' import errno import os from datetime import datetime from functools import partial from qt.core import ( QApplication, QDialog, QDialogButtonBox, QFrame, QGridLayout, QGroupBox, QHBoxLayout, QIcon, QInputDialog, QKeySequence, QMenu, QPushButton, QScrollArea, QShortcut, QSize, QSizePolicy, QSpacerItem, QSplitter, Qt, QTabWidget, QToolButton, QVBoxLayout, QWidget, pyqtSignal ) from calibre.constants import ismacos from calibre.ebooks.metadata import authors_to_string, string_to_authors from calibre.ebooks.metadata.book.base import Metadata from calibre.gui2 import error_dialog, gprefs, pixmap_to_data from calibre.gui2.custom_column_widgets import Comments, populate_metadata_page from calibre.gui2.dialogs.confirm_delete import confirm from calibre.gui2.metadata.basic_widgets import ( AuthorsEdit, AuthorSortEdit, BuddyLabel, CommentsEdit, Cover, DateEdit, FormatsManager, IdentifiersEdit, LanguagesEdit, PubdateEdit, PublisherEdit, RatingEdit, RightClickButton, SeriesEdit, SeriesIndexEdit, TagsEdit, TitleEdit, TitleSortEdit, show_locked_file_error ) from calibre.gui2.metadata.single_download import FullFetch from calibre.gui2.widgets2 import CenteredToolButton from calibre.library.comments import merge_comments as merge_two_comments from calibre.utils.date import local_tz from calibre.utils.localization import canonicalize_lang from polyglot.builtins import iteritems BASE_TITLE = _('Edit metadata') fetched_fields = ('title', 'title_sort', 'authors', 'author_sort', 'series', 'series_index', 'languages', 'publisher', 'tags', 'rating', 'comments', 'pubdate') class ScrollArea(QScrollArea): def __init__(self, widget=None, parent=None): QScrollArea.__init__(self, parent) self.setFrameShape(QFrame.Shape.NoFrame) self.setWidgetResizable(True) if widget is not None: self.setWidget(widget) class MetadataSingleDialogBase(QDialog): view_format = pyqtSignal(object, object) edit_format = pyqtSignal(object, object) one_line_comments_toolbar = False use_toolbutton_for_config_metadata = True def __init__(self, db, parent=None, editing_multiple=False): self.db = db self.was_data_edited = False self.changed = set() self.books_to_refresh = set() self.rows_to_refresh = set() self.metadata_before_fetch = None self.editing_multiple = editing_multiple self.comments_edit_state_at_apply = {} QDialog.__init__(self, parent) self.setupUi() def setupUi(self, *args): # {{{ self.download_shortcut = QShortcut(self) self.download_shortcut.setKey(QKeySequence('Ctrl+D', QKeySequence.SequenceFormat.PortableText)) p = self.parent() if hasattr(p, 'keyboard'): kname = 'Interface Action: Edit Metadata (Edit Metadata) : menu action : download' sc = p.keyboard.keys_map.get(kname, None) if sc: self.download_shortcut.setKey(sc[0]) self.swap_title_author_shortcut = s = QShortcut(self) s.setKey(QKeySequence('Alt+Down', QKeySequence.SequenceFormat.PortableText)) self.button_box = bb = QDialogButtonBox(self) self.button_box.accepted.connect(self.accept) self.button_box.rejected.connect(self.reject) self.next_button = QPushButton(QIcon(I('forward.png')), _('Next'), self) self.next_button.setShortcut(QKeySequence('Alt+Right')) self.next_button.clicked.connect(self.next_clicked) self.prev_button = QPushButton(QIcon(I('back.png')), _('Previous'), self) self.prev_button.setShortcut(QKeySequence('Alt+Left')) self.button_box.addButton(self.prev_button, QDialogButtonBox.ButtonRole.ActionRole) self.button_box.addButton(self.next_button, QDialogButtonBox.ButtonRole.ActionRole) self.prev_button.clicked.connect(self.prev_clicked) bb.setStandardButtons(QDialogButtonBox.StandardButton.Ok|QDialogButtonBox.StandardButton.Cancel) bb.button(QDialogButtonBox.StandardButton.Ok).setDefault(True) self.central_widget = QTabWidget(self) self.l = QVBoxLayout(self) self.setLayout(self.l) self.l.addWidget(self.central_widget) ll = self.button_box_layout = QHBoxLayout() self.l.addLayout(ll) ll.addSpacing(10) ll.addWidget(self.button_box) self.setWindowIcon(QIcon(I('edit_input.png'))) self.setWindowTitle(BASE_TITLE) self.create_basic_metadata_widgets() if len(self.db.custom_column_label_map): self.create_custom_metadata_widgets() self.comments_edit_state_at_apply = {self.comments:None} self.do_layout() geom = gprefs.get('metasingle_window_geometry3', None) if geom is not None: QApplication.instance().safe_restore_geometry(self, bytes(geom)) else: self.resize(self.sizeHint()) self.restore_widget_settings() # }}} def sizeHint(self): geom = self.screen().availableSize() nh, nw = max(300, geom.height()-50), max(400, geom.width()-70) return QSize(nw, nh) def create_basic_metadata_widgets(self): # {{{ self.basic_metadata_widgets = [] self.languages = LanguagesEdit(self) self.basic_metadata_widgets.append(self.languages) self.title = TitleEdit(self) self.title.textChanged.connect(self.update_window_title) self.deduce_title_sort_button = QToolButton(self) self.deduce_title_sort_button.setToolTip( _('Automatically create the title sort entry based on the current ' 'title entry.\nUsing this button to create title sort will ' 'change title sort from red to green.')) self.deduce_title_sort_button.setWhatsThis( self.deduce_title_sort_button.toolTip()) self.title_sort = TitleSortEdit(self, self.title, self.deduce_title_sort_button, self.languages) self.basic_metadata_widgets.extend([self.title, self.title_sort]) self.deduce_author_sort_button = b = RightClickButton(self) b.setToolTip('<p>' + _('Automatically create the author sort entry based on the current ' 'author entry. Using this button to create author sort will ' 'change author sort from red to green. There is a menu of ' 'functions available under this button. Click and hold ' 'on the button to see it.') + '</p>') if ismacos: # Workaround for https://bugreports.qt-project.org/browse/QTBUG-41017 class Menu(QMenu): def mouseReleaseEvent(self, ev): ac = self.actionAt(ev.pos()) if ac is not None: ac.trigger() return QMenu.mouseReleaseEvent(self, ev) b.m = m = Menu(b) else: b.m = m = QMenu(b) ac = m.addAction(QIcon(I('forward.png')), _('Set author sort from author')) ac2 = m.addAction(QIcon(I('back.png')), _('Set author from author sort')) ac3 = m.addAction(QIcon(I('user_profile.png')), _('Manage authors')) ac4 = m.addAction(QIcon(I('next.png')), _('Copy author to author sort')) ac5 = m.addAction(QIcon(I('previous.png')), _('Copy author sort to author')) b.setMenu(m) self.authors = AuthorsEdit(self, ac3) self.author_sort = AuthorSortEdit(self, self.authors, b, self.db, ac, ac2, ac4, ac5) self.basic_metadata_widgets.extend([self.authors, self.author_sort]) self.swap_title_author_button = QToolButton(self) self.swap_title_author_button.setIcon(QIcon(I('swap.png'))) self.swap_title_author_button.setToolTip(_( 'Swap the author and title') + ' [%s]' % self.swap_title_author_shortcut.key().toString(QKeySequence.SequenceFormat.NativeText)) self.swap_title_author_button.clicked.connect(self.swap_title_author) self.swap_title_author_shortcut.activated.connect(self.swap_title_author_button.click) self.manage_authors_button = QToolButton(self) self.manage_authors_button.setIcon(QIcon(I('user_profile.png'))) self.manage_authors_button.setToolTip('<p>' + _( 'Manage authors. Use to rename authors and correct ' 'individual author\'s sort values') + '</p>') self.manage_authors_button.clicked.connect(self.authors.manage_authors) self.series = SeriesEdit(self) self.clear_series_button = QToolButton(self) self.clear_series_button.setToolTip( _('Clear series')) self.clear_series_button.clicked.connect(self.series.clear) self.series_index = SeriesIndexEdit(self, self.series) self.basic_metadata_widgets.extend([self.series, self.series_index]) self.formats_manager = FormatsManager(self, self.copy_fmt) # We want formats changes to be committed before title/author, as # otherwise we could have data loss if the title/author changed and the # user was trying to add an extra file from the old books directory. self.basic_metadata_widgets.insert(0, self.formats_manager) self.formats_manager.metadata_from_format_button.clicked.connect( self.metadata_from_format) self.formats_manager.cover_from_format_button.clicked.connect( self.cover_from_format) self.cover = Cover(self) self.cover.download_cover.connect(self.download_cover) self.basic_metadata_widgets.append(self.cover) self.comments = CommentsEdit(self, self.one_line_comments_toolbar) self.basic_metadata_widgets.append(self.comments) self.rating = RatingEdit(self) self.clear_ratings_button = QToolButton(self) self.clear_ratings_button.setToolTip(_('Clear rating')) self.clear_ratings_button.setIcon(QIcon(I('trash.png'))) self.clear_ratings_button.clicked.connect(self.rating.zero) self.basic_metadata_widgets.append(self.rating) self.tags = TagsEdit(self) self.tags_editor_button = QToolButton(self) self.tags_editor_button.setToolTip(_('Open Tag editor')) self.tags_editor_button.setIcon(QIcon(I('chapters.png'))) self.tags_editor_button.clicked.connect(self.tags_editor) self.tags.tag_editor_requested.connect(self.tags_editor) self.clear_tags_button = QToolButton(self) self.clear_tags_button.setToolTip(_('Clear all tags')) self.clear_tags_button.setIcon(QIcon(I('trash.png'))) self.clear_tags_button.clicked.connect(self.tags.clear) self.basic_metadata_widgets.append(self.tags) self.identifiers = IdentifiersEdit(self) self.basic_metadata_widgets.append(self.identifiers) self.clear_identifiers_button = QToolButton(self) self.clear_identifiers_button.setIcon(QIcon(I('trash.png'))) self.clear_identifiers_button.setToolTip(_('Clear Ids')) self.clear_identifiers_button.clicked.connect(self.identifiers.clear) self.paste_isbn_button = b = RightClickButton(self) b.setToolTip('<p>' + _('Paste the contents of the clipboard into the ' 'identifiers prefixed with isbn: or url:. Or right click, ' 'to choose a different prefix.') + '</p>') b.setIcon(QIcon(I('edit-paste.png'))) b.clicked.connect(self.identifiers.paste_identifier) b.setPopupMode(QToolButton.ToolButtonPopupMode.DelayedPopup) b.setMenu(QMenu(b)) self.update_paste_identifiers_menu() self.publisher = PublisherEdit(self) self.basic_metadata_widgets.append(self.publisher) self.timestamp = DateEdit(self) self.pubdate = PubdateEdit(self) self.basic_metadata_widgets.extend([self.timestamp, self.pubdate]) self.fetch_metadata_button = b = CenteredToolButton(QIcon(I('download-metadata.png')), _('&Download metadata'), self) b.setPopupMode(QToolButton.ToolButtonPopupMode.DelayedPopup) b.setToolTip(_('Download metadata for this book [%s]') % self.download_shortcut.key().toString(QKeySequence.SequenceFormat.NativeText)) self.fetch_metadata_button.clicked.connect(self.fetch_metadata) self.fetch_metadata_menu = m = QMenu(self.fetch_metadata_button) m.addAction(QIcon(I('edit-undo.png')), _('Undo last metadata download'), self.undo_fetch_metadata) self.fetch_metadata_button.setMenu(m) self.download_shortcut.activated.connect(self.fetch_metadata_button.click) if self.use_toolbutton_for_config_metadata: self.config_metadata_button = QToolButton(self) self.config_metadata_button.setIcon(QIcon(I('config.png'))) else: self.config_metadata_button = QPushButton(self) self.config_metadata_button.setText(_('Configure download metadata')) self.config_metadata_button.setIcon(QIcon(I('config.png'))) self.config_metadata_button.clicked.connect(self.configure_metadata) self.config_metadata_button.setToolTip( _('Change how calibre downloads metadata')) for w in self.basic_metadata_widgets: w.data_changed.connect(self.data_changed) # }}} def update_paste_identifiers_menu(self): m = self.paste_isbn_button.menu() m.clear() m.addAction(_('Edit list of prefixes'), self.edit_prefix_list) m.addSeparator() for prefix in gprefs['paste_isbn_prefixes'][1:]: m.addAction(prefix, partial(self.identifiers.paste_prefix, prefix)) def edit_prefix_list(self): prefixes, ok = QInputDialog.getMultiLineText( self, _('Edit prefixes'), _('Enter prefixes, one on a line. The first prefix becomes the default.'), '\n'.join(list(map(str, gprefs['paste_isbn_prefixes'])))) if ok: gprefs['paste_isbn_prefixes'] = list(filter(None, (x.strip() for x in prefixes.splitlines()))) or gprefs.defaults['paste_isbn_prefixes'] self.update_paste_identifiers_menu() def use_two_columns_for_custom_metadata(self): raise NotImplementedError def create_custom_metadata_widgets(self): # {{{ self.custom_metadata_widgets_parent = w = QWidget(self) layout = QGridLayout() w.setLayout(layout) self.custom_metadata_widgets, self.__cc_spacers = \ populate_metadata_page(layout, self.db, None, parent=w, bulk=False, two_column=self.use_two_columns_for_custom_metadata()) self.__custom_col_layouts = [layout] for widget in self.custom_metadata_widgets: widget.connect_data_changed(self.data_changed) if isinstance(widget, Comments): self.comments_edit_state_at_apply[widget] = None # }}} def set_custom_metadata_tab_order(self, before=None, after=None): # {{{ sto = QWidget.setTabOrder if getattr(self, 'custom_metadata_widgets', []): ans = self.custom_metadata_widgets for i in range(len(ans)-1): if before is not None and i == 0: pass if len(ans[i+1].widgets) == 2: sto(ans[i].widgets[-1], ans[i+1].widgets[1]) else: sto(ans[i].widgets[-1], ans[i+1].widgets[0]) for c in range(2, len(ans[i].widgets), 2): sto(ans[i].widgets[c-1], ans[i].widgets[c+1]) if after is not None: pass # }}} def do_view_format(self, path, fmt): if path: self.view_format.emit(None, path) else: self.view_format.emit(self.book_id, fmt) def do_edit_format(self, path, fmt): if self.was_data_edited: from calibre.gui2.tweak_book import tprefs tprefs.refresh() # In case they were changed in a Tweak Book process from calibre.gui2 import question_dialog if tprefs['update_metadata_from_calibre'] and question_dialog( self, _('Save changed metadata?'), _("You've changed the metadata for this book." " Edit book is set to update embedded metadata when opened." " You need to save your changes for them to be included."), yes_text=_('&Save'), no_text=_("&Don't save"), yes_icon='dot_green.png', no_icon='dot_red.png', default_yes=True, skip_dialog_name='edit-metadata-save-before-edit-format'): if self.apply_changes(): self.was_data_edited = False self.edit_format.emit(self.book_id, fmt) def copy_fmt(self, fmt, f): self.db.copy_format_to(self.book_id, fmt, f, index_is_id=True) def do_layout(self): raise NotImplementedError() def save_widget_settings(self): pass def restore_widget_settings(self): pass def data_changed(self): self.was_data_edited = True def __call__(self, id_): self.book_id = id_ self.books_to_refresh = set() self.metadata_before_fetch = None for widget in self.basic_metadata_widgets: widget.initialize(self.db, id_) for widget in getattr(self, 'custom_metadata_widgets', []): widget.initialize(id_) if callable(self.set_current_callback): self.set_current_callback(id_) self.was_data_edited = False # Commented out as it doesn't play nice with Next, Prev buttons # self.fetch_metadata_button.setFocus(Qt.FocusReason.OtherFocusReason) # Miscellaneous interaction methods {{{ def update_window_title(self, *args): title = self.title.current_val if len(title) > 50: title = title[:50] + '\u2026' self.setWindowTitle(BASE_TITLE + ' - ' + title + ' - ' + _(' [%(num)d of %(tot)d]')%dict(num=self.current_row+1, tot=len(self.row_list))) def swap_title_author(self, *args): title = self.title.current_val self.title.current_val = authors_to_string(self.authors.current_val) self.authors.current_val = string_to_authors(title) self.title_sort.auto_generate() self.author_sort.auto_generate() def tags_editor(self, *args): self.tags.edit(self.db, self.book_id) def metadata_from_format(self, *args): mi, ext = self.formats_manager.get_selected_format_metadata(self.db, self.book_id) if mi is not None: self.update_from_mi(mi) def get_pdf_cover(self): pdfpath = self.formats_manager.get_format_path(self.db, self.book_id, 'pdf') from calibre.gui2.metadata.pdf_covers import PDFCovers d = PDFCovers(pdfpath, parent=self) if d.exec() == QDialog.DialogCode.Accepted: cpath = d.cover_path if cpath: with open(cpath, 'rb') as f: self.update_cover(f.read(), 'PDF') d.cleanup() def cover_from_format(self, *args): ext = self.formats_manager.get_selected_format() if ext is None: return if ext == 'pdf': return self.get_pdf_cover() try: mi, ext = self.formats_manager.get_selected_format_metadata(self.db, self.book_id) except OSError as err: if getattr(err, 'errno', None) == errno.EACCES: # Permission denied import traceback fname = err.filename if err.filename else 'file' error_dialog(self, _('Permission denied'), _('Could not open %s. Is it being used by another' ' program?')%fname, det_msg=traceback.format_exc(), show=True) return raise if mi is None: return cdata = None if mi.cover and os.access(mi.cover, os.R_OK): with open(mi.cover, 'rb') as f: cdata = f.read() elif mi.cover_data[1] is not None: cdata = mi.cover_data[1] if cdata is None: error_dialog(self, _('Could not read cover'), _('Could not read cover from %s format')%ext.upper()).exec() return self.update_cover(cdata, ext) def update_cover(self, cdata, fmt): orig = self.cover.current_val self.cover.current_val = cdata if self.cover.current_val is None: self.cover.current_val = orig return error_dialog(self, _('Could not read cover'), _('The cover in the %s format is invalid')%fmt, show=True) return def update_from_mi(self, mi, update_sorts=True, merge_tags=True, merge_comments=False): fw = self.focusWidget() if not mi.is_null('title'): self.title.set_value(mi.title) if update_sorts: self.title_sort.auto_generate() if not mi.is_null('authors'): self.authors.set_value(mi.authors) if not mi.is_null('author_sort'): self.author_sort.set_value(mi.author_sort) elif update_sorts and not mi.is_null('authors'): self.author_sort.auto_generate() if not mi.is_null('rating'): self.rating.set_value(mi.rating * 2) if not mi.is_null('publisher'): self.publisher.set_value(mi.publisher) if not mi.is_null('tags'): old_tags = self.tags.current_val tags = mi.tags if mi.tags else [] if old_tags and merge_tags: ltags, lotags = {t.lower() for t in tags}, {t.lower() for t in old_tags} tags = [t for t in tags if t.lower() in ltags-lotags] + old_tags self.tags.set_value(tags) if not mi.is_null('identifiers'): current = self.identifiers.current_val current.update(mi.identifiers) self.identifiers.set_value(current) if not mi.is_null('pubdate'): self.pubdate.set_value(mi.pubdate) if not mi.is_null('series') and mi.series.strip(): self.series.set_value(mi.series) if mi.series_index is not None: self.series_index.reset_original() self.series_index.set_value(float(mi.series_index)) if not mi.is_null('languages'): langs = [canonicalize_lang(x) for x in mi.languages] langs = [x for x in langs if x is not None] if langs: self.languages.set_value(langs) if mi.comments and mi.comments.strip(): val = mi.comments if val and merge_comments: cval = self.comments.current_val if cval: val = merge_two_comments(cval, val) self.comments.set_value(val) if fw is not None: fw.setFocus(Qt.FocusReason.OtherFocusReason) def fetch_metadata(self, *args): from calibre.ebooks.metadata.sources.update import update_sources update_sources() d = FullFetch(self.cover.pixmap(), self) ret = d.start(title=self.title.current_val, authors=self.authors.current_val, identifiers=self.identifiers.current_val) if ret == QDialog.DialogCode.Accepted: self.metadata_before_fetch = {f:getattr(self, f).current_val for f in fetched_fields} from calibre.ebooks.metadata.sources.prefs import msprefs mi = d.book dummy = Metadata(_('Unknown')) for f in msprefs['ignore_fields']: if ':' not in f: setattr(mi, f, getattr(dummy, f)) if mi is not None: pd = mi.pubdate if pd is not None: # Put the downloaded published date into the local timezone # as we discard time info and the date is timezone # invariant. This prevents the as_local_timezone() call in # update_from_mi from changing the pubdate mi.pubdate = datetime(pd.year, pd.month, pd.day, tzinfo=local_tz) self.update_from_mi(mi, merge_comments=msprefs['append_comments']) if d.cover_pixmap is not None: self.metadata_before_fetch['cover'] = self.cover.current_val self.cover.current_val = pixmap_to_data(d.cover_pixmap) def undo_fetch_metadata(self): if self.metadata_before_fetch is None: return error_dialog(self, _('No downloaded metadata'), _( 'There is no downloaded metadata to undo'), show=True) for field, val in iteritems(self.metadata_before_fetch): getattr(self, field).current_val = val self.metadata_before_fetch = None def configure_metadata(self): from calibre.gui2.preferences import show_config_widget gui = self.parent() show_config_widget('Sharing', 'Metadata download', parent=self, gui=gui, never_shutdown=True) def download_cover(self, *args): from calibre.ebooks.metadata.sources.update import update_sources update_sources() from calibre.gui2.metadata.single_download import CoverFetch d = CoverFetch(self.cover.pixmap(), self) ret = d.start(self.title.current_val, self.authors.current_val, self.identifiers.current_val) if ret == QDialog.DialogCode.Accepted: if d.cover_pixmap is not None: self.cover.current_val = pixmap_to_data(d.cover_pixmap) # }}} def to_book_metadata(self): mi = Metadata(_('Unknown')) if self.db is None: return mi mi.set_all_user_metadata(self.db.field_metadata.custom_field_metadata()) for widget in self.basic_metadata_widgets: widget.apply_to_metadata(mi) for widget in getattr(self, 'custom_metadata_widgets', []): widget.apply_to_metadata(mi) return mi def apply_changes(self): self.changed.add(self.book_id) if self.db is None: # break_cycles has already been called, don't know why this should # happen but a user reported it return True self.comments_edit_state_at_apply = {w:w.tab for w in self.comments_edit_state_at_apply} for widget in self.basic_metadata_widgets: try: if hasattr(widget, 'validate_for_commit'): title, msg, det_msg = widget.validate_for_commit() if title is not None: error_dialog(self, title, msg, det_msg=det_msg, show=True) return False widget.commit(self.db, self.book_id) self.books_to_refresh |= getattr(widget, 'books_to_refresh', set()) except OSError as err: if getattr(err, 'errno', None) == errno.EACCES: # Permission denied show_locked_file_error(self, err) return False raise for widget in getattr(self, 'custom_metadata_widgets', []): self.books_to_refresh |= widget.commit(self.book_id) self.db.commit() rows = self.db.refresh_ids(list(self.books_to_refresh)) if rows: self.rows_to_refresh |= set(rows) return True def accept(self): self.save_state() if not self.apply_changes(): return if self.editing_multiple and self.current_row != len(self.row_list) - 1: num = len(self.row_list) - 1 - self.current_row from calibre.gui2 import question_dialog pm = ngettext('There is another book to edit in this set.', 'There are still {} more books to edit in this set.', num).format(num) if not question_dialog( self, _('Are you sure?'), pm + ' ' + _( 'Are you sure you want to stop? Use the "Next" button' ' instead of the "OK" button to move through books in the set.'), yes_text=_('&Stop editing'), no_text=_('&Continue editing'), yes_icon='dot_red.png', no_icon='dot_green.png', default_yes=False, skip_dialog_name='edit-metadata-single-confirm-ok-on-multiple'): return self.do_one(delta=1, apply_changes=False) QDialog.accept(self) def reject(self): self.save_state() if self.was_data_edited and not confirm( title=_('Are you sure?'), name='confirm-cancel-edit-single-metadata', msg=_( 'You will lose all unsaved changes. Are you sure?'), parent=self): return QDialog.reject(self) def save_state(self): try: gprefs['metasingle_window_geometry3'] = bytearray(self.saveGeometry()) self.save_widget_settings() except: # Weird failure, see https://bugs.launchpad.net/bugs/995271 import traceback traceback.print_exc() # Dialog use methods {{{ def start(self, row_list, current_row, view_slot=None, edit_slot=None, set_current_callback=None): self.row_list = row_list self.current_row = current_row if view_slot is not None: self.view_format.connect(view_slot) if edit_slot is not None: self.edit_format.connect(edit_slot) self.set_current_callback = set_current_callback self.do_one(apply_changes=False) ret = self.exec() self.break_cycles() return ret def next_clicked(self): if not self.apply_changes(): return self.do_one(delta=1, apply_changes=False) def prev_clicked(self): if not self.apply_changes(): return self.do_one(delta=-1, apply_changes=False) def do_one(self, delta=0, apply_changes=True): if apply_changes: self.apply_changes() self.current_row += delta self.update_window_title() prev = next_ = None if self.current_row > 0: prev = self.db.title(self.row_list[self.current_row-1]) if self.current_row < len(self.row_list) - 1: next_ = self.db.title(self.row_list[self.current_row+1]) if next_ is not None: tip = _('Save changes and edit the metadata of {} [Alt+Right]').format(next_) self.next_button.setToolTip(tip) self.next_button.setEnabled(next_ is not None) if prev is not None: tip = _('Save changes and edit the metadata of {} [Alt+Left]').format(prev) self.prev_button.setToolTip(tip) self.prev_button.setEnabled(prev is not None) self.button_box.button(QDialogButtonBox.StandardButton.Ok).setDefault(True) self.button_box.button(QDialogButtonBox.StandardButton.Ok).setFocus(Qt.FocusReason.OtherFocusReason) self(self.db.id(self.row_list[self.current_row])) for w, state in iteritems(self.comments_edit_state_at_apply): if state == 'code': w.tab = 'code' def break_cycles(self): # Break any reference cycles that could prevent python # from garbage collecting this dialog self.set_current_callback = self.db = None self.metadata_before_fetch = None def disconnect(signal): try: signal.disconnect() except: pass # Fails if view format was never connected disconnect(self.view_format) disconnect(self.edit_format) for b in ('next_button', 'prev_button'): x = getattr(self, b, None) if x is not None: disconnect(x.clicked) for widget in self.basic_metadata_widgets: bc = getattr(widget, 'break_cycles', None) if bc is not None and callable(bc): bc() for widget in getattr(self, 'custom_metadata_widgets', []): widget.break_cycles() # }}} class Splitter(QSplitter): frame_resized = pyqtSignal(object) def resizeEvent(self, ev): self.frame_resized.emit(ev) return QSplitter.resizeEvent(self, ev) class MetadataSingleDialog(MetadataSingleDialogBase): # {{{ def use_two_columns_for_custom_metadata(self): return gprefs['edit_metadata_single_use_2_cols_for_custom_fields'] def do_layout(self): if len(self.db.custom_column_label_map) == 0: self.central_widget.tabBar().setVisible(False) self.central_widget.clear() self.tabs = [] self.labels = [] self.tabs.append(QWidget(self)) self.central_widget.addTab(ScrollArea(self.tabs[0], self), _("&Basic metadata")) self.tabs[0].l = l = QVBoxLayout() self.tabs[0].tl = tl = QGridLayout() self.tabs[0].setLayout(l) w = getattr(self, 'custom_metadata_widgets_parent', None) if w is not None: self.tabs.append(w) self.central_widget.addTab(ScrollArea(w, self), _('&Custom metadata')) l.addLayout(tl) l.addItem(QSpacerItem(10, 15, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)) sto = QWidget.setTabOrder sto(self.button_box, self.fetch_metadata_button) sto(self.fetch_metadata_button, self.config_metadata_button) sto(self.config_metadata_button, self.title) def create_row(row, one, two, three, col=1, icon='forward.png'): ql = BuddyLabel(one) tl.addWidget(ql, row, col+0, 1, 1) self.labels.append(ql) tl.addWidget(one, row, col+1, 1, 1) if two is not None: tl.addWidget(two, row, col+2, 1, 1) two.setIcon(QIcon(I(icon))) ql = BuddyLabel(three) tl.addWidget(ql, row, col+3, 1, 1) self.labels.append(ql) tl.addWidget(three, row, col+4, 1, 1) sto(one, two) sto(two, three) tl.addWidget(self.swap_title_author_button, 0, 0, 1, 1) tl.addWidget(self.manage_authors_button, 1, 0, 1, 1) sto(self.swap_title_author_button, self.title) create_row(0, self.title, self.deduce_title_sort_button, self.title_sort) sto(self.title_sort, self.manage_authors_button) sto(self.manage_authors_button, self.authors) create_row(1, self.authors, self.deduce_author_sort_button, self.author_sort) sto(self.author_sort, self.series) create_row(2, self.series, self.clear_series_button, self.series_index, icon='trash.png') tl.addWidget(self.formats_manager, 0, 6, 3, 1) self.splitter = Splitter(Qt.Orientation.Horizontal, self) self.splitter.addWidget(self.cover) self.splitter.frame_resized.connect(self.cover.frame_resized) l.addWidget(self.splitter) self.tabs[0].gb = gb = QGroupBox(_('Change cover'), self) gb.l = l = QGridLayout() gb.setLayout(l) for i, b in enumerate(self.cover.buttons[:3]): l.addWidget(b, 0, i, 1, 1) sto(b, self.cover.buttons[i+1]) gb.hl = QHBoxLayout() for b in self.cover.buttons[3:]: gb.hl.addWidget(b) sto(self.cover.buttons[-2], self.cover.buttons[-1]) l.addLayout(gb.hl, 1, 0, 1, 3) self.tabs[0].middle = w = QWidget(self) w.l = l = QGridLayout() w.setLayout(w.l) self.splitter.addWidget(w) def create_row2(row, widget, button=None, front_button=None): row += 1 ql = BuddyLabel(widget) if front_button: ltl = QHBoxLayout() ltl.addWidget(front_button) ltl.addWidget(ql) l.addLayout(ltl, row, 0, 1, 1) else: l.addWidget(ql, row, 0, 1, 1) l.addWidget(widget, row, 1, 1, 2 if button is None else 1) if button is not None: l.addWidget(button, row, 2, 1, 1) if button is not None: sto(widget, button) l.addWidget(gb, 0, 0, 1, 3) self.tabs[0].spc_one = QSpacerItem(10, 10, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) l.addItem(self.tabs[0].spc_one, 1, 0, 1, 3) sto(self.cover.buttons[-1], self.rating) create_row2(1, self.rating, self.clear_ratings_button) sto(self.rating, self.clear_ratings_button) sto(self.clear_ratings_button, self.tags_editor_button) sto(self.tags_editor_button, self.tags) create_row2(2, self.tags, self.clear_tags_button, front_button=self.tags_editor_button) sto(self.clear_tags_button, self.paste_isbn_button) sto(self.paste_isbn_button, self.identifiers) create_row2(3, self.identifiers, self.clear_identifiers_button, front_button=self.paste_isbn_button) sto(self.clear_identifiers_button, self.timestamp) create_row2(4, self.timestamp, self.timestamp.clear_button) sto(self.timestamp.clear_button, self.pubdate) create_row2(5, self.pubdate, self.pubdate.clear_button) sto(self.pubdate.clear_button, self.publisher) create_row2(6, self.publisher, self.publisher.clear_button) sto(self.publisher.clear_button, self.languages) create_row2(7, self.languages) self.tabs[0].spc_two = QSpacerItem(10, 10, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) l.addItem(self.tabs[0].spc_two, 9, 0, 1, 3) l.addWidget(self.fetch_metadata_button, 10, 0, 1, 2) l.addWidget(self.config_metadata_button, 10, 2, 1, 1) self.tabs[0].gb2 = gb = QGroupBox(_('Co&mments'), self) gb.l = l = QVBoxLayout() l.setContentsMargins(0, 0, 0, 0) gb.setLayout(l) l.addWidget(self.comments) self.splitter.addWidget(gb) self.set_custom_metadata_tab_order() def save_widget_settings(self): gprefs['basic_metadata_widget_splitter_state'] = bytearray(self.splitter.saveState()) def restore_widget_settings(self): s = gprefs.get('basic_metadata_widget_splitter_state') if s is not None: self.splitter.restoreState(s) # }}} class DragTrackingWidget(QWidget): # {{{ def __init__(self, parent, on_drag_enter): QWidget.__init__(self, parent) self.on_drag_enter = on_drag_enter def dragEnterEvent(self, ev): self.on_drag_enter.emit() # }}} class MetadataSingleDialogAlt1(MetadataSingleDialogBase): # {{{ one_line_comments_toolbar = True use_toolbutton_for_config_metadata = False on_drag_enter = pyqtSignal() def handle_drag_enter(self): self.central_widget.setCurrentIndex(1) def use_two_columns_for_custom_metadata(self): return False def do_layout(self): self.central_widget.clear() self.tabs = [] self.labels = [] sto = QWidget.setTabOrder self.on_drag_enter.connect(self.handle_drag_enter) self.tabs.append(DragTrackingWidget(self, self.on_drag_enter)) self.central_widget.addTab(ScrollArea(self.tabs[0], self), _("&Metadata")) self.tabs[0].l = QGridLayout() self.tabs[0].setLayout(self.tabs[0].l) self.tabs.append(QWidget(self)) self.central_widget.addTab(ScrollArea(self.tabs[1], self), _("&Cover and formats")) self.tabs[1].l = QGridLayout() self.tabs[1].setLayout(self.tabs[1].l) # accept drop events so we can automatically switch to the second tab to # drop covers and formats self.tabs[0].setAcceptDrops(True) # Tab 0 tab0 = self.tabs[0] tl = QGridLayout() gb = QGroupBox(_('&Basic metadata'), self.tabs[0]) self.tabs[0].l.addWidget(gb, 0, 0, 1, 1) gb.setLayout(tl) self.button_box_layout.insertWidget(1, self.fetch_metadata_button) self.button_box_layout.insertWidget(2, self.config_metadata_button) sto(self.button_box, self.fetch_metadata_button) sto(self.fetch_metadata_button, self.config_metadata_button) sto(self.config_metadata_button, self.title) def create_row(row, widget, tab_to, button=None, icon=None, span=1): ql = BuddyLabel(widget) tl.addWidget(ql, row, 1, 1, 1) tl.addWidget(widget, row, 2, 1, 1) if button is not None: tl.addWidget(button, row, 3, span, 1) if icon is not None: button.setIcon(QIcon(I(icon))) if tab_to is not None: if button is not None: sto(widget, button) sto(button, tab_to) else: sto(widget, tab_to) tl.addWidget(self.swap_title_author_button, 0, 0, 2, 1) tl.addWidget(self.manage_authors_button, 2, 0, 1, 1) tl.addWidget(self.paste_isbn_button, 12, 0, 1, 1) tl.addWidget(self.tags_editor_button, 6, 0, 1, 1) create_row(0, self.title, self.title_sort, button=self.deduce_title_sort_button, span=2, icon='auto_author_sort.png') create_row(1, self.title_sort, self.authors) create_row(2, self.authors, self.author_sort, button=self.deduce_author_sort_button, span=2, icon='auto_author_sort.png') create_row(3, self.author_sort, self.series) create_row(4, self.series, self.series_index, button=self.clear_series_button, icon='trash.png') create_row(5, self.series_index, self.tags) create_row(6, self.tags, self.rating, button=self.clear_tags_button) create_row(7, self.rating, self.pubdate, button=self.clear_ratings_button) create_row(8, self.pubdate, self.publisher, button=self.pubdate.clear_button, icon='trash.png') create_row(9, self.publisher, self.languages, button=self.publisher.clear_button, icon='trash.png') create_row(10, self.languages, self.timestamp) create_row(11, self.timestamp, self.identifiers, button=self.timestamp.clear_button, icon='trash.png') create_row(12, self.identifiers, self.comments, button=self.clear_identifiers_button, icon='trash.png') sto(self.clear_identifiers_button, self.swap_title_author_button) sto(self.swap_title_author_button, self.manage_authors_button) sto(self.manage_authors_button, self.tags_editor_button) sto(self.tags_editor_button, self.paste_isbn_button) tl.addItem(QSpacerItem(1, 1, QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding), 13, 1, 1 ,1) w = getattr(self, 'custom_metadata_widgets_parent', None) if w is not None: gb = QGroupBox(_('C&ustom metadata'), tab0) gbl = QVBoxLayout() gb.setLayout(gbl) sr = QScrollArea(tab0) sr.setWidgetResizable(True) sr.setFrameStyle(QFrame.Shape.NoFrame) sr.setWidget(w) gbl.addWidget(sr) self.tabs[0].l.addWidget(gb, 0, 1, 1, 1) sto(self.identifiers, gb) w = QGroupBox(_('&Comments'), tab0) sp = QSizePolicy() sp.setVerticalStretch(10) sp.setHorizontalPolicy(QSizePolicy.Policy.Expanding) sp.setVerticalPolicy(QSizePolicy.Policy.Expanding) w.setSizePolicy(sp) l = QHBoxLayout() w.setLayout(l) l.addWidget(self.comments) tab0.l.addWidget(w, 1, 0, 1, 2) # Tab 1 tab1 = self.tabs[1] wsp = QWidget(tab1) wgl = QVBoxLayout() wsp.setLayout(wgl) # right-hand side of splitter gb = QGroupBox(_('Change cover'), tab1) l = QGridLayout() gb.setLayout(l) for i, b in enumerate(self.cover.buttons[:3]): l.addWidget(b, 0, i, 1, 1) sto(b, self.cover.buttons[i+1]) hl = QHBoxLayout() for b in self.cover.buttons[3:]: hl.addWidget(b) sto(self.cover.buttons[-2], self.cover.buttons[-1]) l.addLayout(hl, 1, 0, 1, 3) wgl.addWidget(gb) wgl.addItem(QSpacerItem(10, 10, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)) wgl.addItem(QSpacerItem(10, 10, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)) wgl.addWidget(self.formats_manager) self.splitter = QSplitter(Qt.Orientation.Horizontal, tab1) tab1.l.addWidget(self.splitter) self.splitter.addWidget(self.cover) self.splitter.addWidget(wsp) self.formats_manager.formats.setMaximumWidth(10000) self.formats_manager.formats.setIconSize(QSize(64, 64)) # }}} class MetadataSingleDialogAlt2(MetadataSingleDialogBase): # {{{ one_line_comments_toolbar = True use_toolbutton_for_config_metadata = False def use_two_columns_for_custom_metadata(self): return False def do_layout(self): self.central_widget.clear() self.labels = [] sto = QWidget.setTabOrder # The dialog is in three main parts. Basic and custom metadata in one # panel on the left over another panel containing comments, separated # by a splitter. The cover and format information is in a panel on the # right, separated by another splitter. main_splitter = self.main_splitter = Splitter(Qt.Orientation.Horizontal, self) self.central_widget.tabBar().setVisible(False) self.central_widget.addTab(ScrollArea(main_splitter, self), _("&Metadata")) # Left side (metadata & comments) # basic and custom split from comments metadata_splitter = self.metadata_splitter = Splitter(Qt.Orientation.Vertical, self) main_splitter.addWidget(metadata_splitter) metadata_widget = QWidget() metadata_layout = QHBoxLayout() metadata_layout.setContentsMargins(3, 0, 0, 0) metadata_widget.setLayout(metadata_layout) metadata_splitter.addWidget(metadata_widget) gb = QGroupBox(_('Basic metadata'), metadata_splitter) metadata_layout.addWidget(gb) # Basic metadata in col 0, custom in col 1 tl = QGridLayout() gb.setLayout(tl) self.button_box_layout.insertWidget(1, self.fetch_metadata_button) self.button_box_layout.insertWidget(2, self.config_metadata_button) sto(self.button_box, self.fetch_metadata_button) sto(self.fetch_metadata_button, self.config_metadata_button) sto(self.config_metadata_button, self.title) def create_row(row, widget, tab_to, button=None, icon=None, span=1): ql = BuddyLabel(widget) tl.addWidget(ql, row, 1, 1, 1) tl.addWidget(widget, row, 2, 1, 1) if button is not None: tl.addWidget(button, row, 3, span, 1) if icon is not None: button.setIcon(QIcon(I(icon))) if tab_to is not None: if button is not None: sto(widget, button) sto(button, tab_to) else: sto(widget, tab_to) tl.addWidget(self.swap_title_author_button, 0, 0, 2, 1) tl.addWidget(self.manage_authors_button, 2, 0, 2, 1) tl.addWidget(self.paste_isbn_button, 12, 0, 1, 1) tl.addWidget(self.tags_editor_button, 6, 0, 1, 1) create_row(0, self.title, self.title_sort, button=self.deduce_title_sort_button, span=2, icon='auto_author_sort.png') create_row(1, self.title_sort, self.authors) create_row(2, self.authors, self.author_sort, button=self.deduce_author_sort_button, span=2, icon='auto_author_sort.png') create_row(3, self.author_sort, self.series) create_row(4, self.series, self.series_index, button=self.clear_series_button, icon='trash.png') create_row(5, self.series_index, self.tags) create_row(6, self.tags, self.rating, button=self.clear_tags_button) create_row(7, self.rating, self.pubdate, button=self.clear_ratings_button) create_row(8, self.pubdate, self.publisher, button=self.pubdate.clear_button, icon='trash.png') create_row(9, self.publisher, self.languages, button=self.publisher.clear_button, icon='trash.png') create_row(10, self.languages, self.timestamp) create_row(11, self.timestamp, self.identifiers, button=self.timestamp.clear_button, icon='trash.png') create_row(12, self.identifiers, self.comments, button=self.clear_identifiers_button, icon='trash.png') sto(self.clear_identifiers_button, self.swap_title_author_button) sto(self.swap_title_author_button, self.manage_authors_button) sto(self.manage_authors_button, self.tags_editor_button) sto(self.tags_editor_button, self.paste_isbn_button) tl.addItem(QSpacerItem(1, 1, QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding), 13, 1, 1 ,1) # Custom metadata in col 1 w = getattr(self, 'custom_metadata_widgets_parent', None) if w is not None: gb = QGroupBox(_('Custom metadata'), metadata_splitter) gbl = QVBoxLayout() gb.setLayout(gbl) sr = QScrollArea(gb) sr.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) sr.setWidgetResizable(True) sr.setFrameStyle(QFrame.Shape.NoFrame) sr.setWidget(w) gbl.addWidget(sr) metadata_layout.addWidget(gb) sp = QSizePolicy() sp.setVerticalStretch(10) sp.setHorizontalPolicy(QSizePolicy.Policy.Minimum) sp.setVerticalPolicy(QSizePolicy.Policy.Expanding) gb.setSizePolicy(sp) self.set_custom_metadata_tab_order() # comments below metadata splitter. The mess of widgets is to get the # contents margins right so things line up cw = QWidget() cl = QHBoxLayout() cw.setLayout(cl) cl.setContentsMargins(3, 0, 0, 0) metadata_splitter.addWidget(cw) # Now the real stuff sp = QSizePolicy() sp.setVerticalStretch(10) sp.setHorizontalPolicy(QSizePolicy.Policy.Expanding) sp.setVerticalPolicy(QSizePolicy.Policy.Expanding) cw.setSizePolicy(sp) gb = QGroupBox(_('Comments'), metadata_splitter) gbl = QHBoxLayout() gbl.setContentsMargins(0, 0, 0, 0) gb.setLayout(gbl) cl.addWidget(gb) gbl.addWidget(self.comments) # Cover & formats on right side # First the cover & buttons cover_group_box = QGroupBox(_('Cover'), main_splitter) cover_layout = QVBoxLayout() cover_layout.setContentsMargins(0, 0, 0, 0) cover_group_box.setLayout(cover_layout) cover_layout.addWidget(self.cover) sto(self.manage_authors_button, self.cover.buttons[0]) # First row of cover buttons hl = QHBoxLayout() hl.setContentsMargins(0, 0, 0, 0) for i, b in enumerate(self.cover.buttons[:3]): hl.addWidget(b) sto(b, self.cover.buttons[i+1]) cover_layout.addLayout(hl) # Second row of cover buttons hl = QHBoxLayout() hl.setContentsMargins(0, 0, 0, 0) for b in self.cover.buttons[3:]: hl.addWidget(b) cover_layout.addLayout(hl) sto(self.cover.buttons[-2], self.cover.buttons[-1]) # Splitter for both cover & formats boxes self.cover_and_formats = cover_and_formats = QSplitter(Qt.Orientation.Vertical) # Put a very small margin on the left so that the word "Cover" doesn't # touch the splitter cover_and_formats.setContentsMargins(1, 0, 0, 0) cover_and_formats.addWidget(cover_group_box) # Add the formats manager box cover_and_formats.addWidget(self.formats_manager) sto(self.cover.buttons[-1], self.formats_manager) self.formats_manager.formats.setMaximumWidth(10000) self.formats_manager.formats.setIconSize(QSize(32, 32)) main_splitter.addWidget(cover_and_formats) def save_widget_settings(self): gprefs['all_on_one_metadata_splitter_1_state'] = bytearray(self.metadata_splitter.saveState()) gprefs['all_on_one_metadata_splitter_2_state'] = bytearray(self.main_splitter.saveState()) gprefs['all_on_one_metadata_splitter_3_state'] = bytearray(self.cover_and_formats.saveState()) def restore_widget_settings(self): s = gprefs.get('all_on_one_metadata_splitter_1_state') if s is not None: self.metadata_splitter.restoreState(s) s = gprefs.get('all_on_one_metadata_splitter_2_state') if s is not None: self.main_splitter.restoreState(s) s = gprefs.get('all_on_one_metadata_splitter_3_state') if s is not None: self.cover_and_formats.restoreState(s) # }}} editors = {'default': MetadataSingleDialog, 'alt1': MetadataSingleDialogAlt1, 'alt2': MetadataSingleDialogAlt2} def edit_metadata(db, row_list, current_row, parent=None, view_slot=None, edit_slot=None, set_current_callback=None, editing_multiple=False): cls = gprefs.get('edit_metadata_single_layout', '') if cls not in editors: cls = 'default' d = editors[cls](db, parent, editing_multiple=editing_multiple) try: d.start(row_list, current_row, view_slot=view_slot, edit_slot=edit_slot, set_current_callback=set_current_callback) return d.changed, d.rows_to_refresh finally: # possible workaround for bug reports of occasional ghost edit metadata dialog on windows d.deleteLater() if __name__ == '__main__': from calibre.gui2 import Application app = Application([]) from calibre.library import db db = db() row_list = list(range(len(db.data))) edit_metadata(db, row_list, 0)