%PDF- %PDF-
Direktori : /lib/calibre/calibre/gui2/toc/ |
Current File : //lib/calibre/calibre/gui2/toc/main.py |
#!/usr/bin/env python3 # License: GPLv3 Copyright: 2013, Kovid Goyal <kovid at kovidgoyal.net> import os import sys import tempfile import textwrap from functools import partial from qt.core import ( QAbstractItemView, QApplication, QCheckBox, QCursor, QDialog, QDialogButtonBox, QEvent, QFrame, QGridLayout, QIcon, QInputDialog, QItemSelectionModel, QKeySequence, QLabel, QMenu, QPushButton, QScrollArea, QSize, QSizePolicy, QStackedWidget, Qt, QToolButton, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget, pyqtSignal ) from threading import Thread from time import monotonic from calibre.constants import TOC_DIALOG_APP_UID, islinux, iswindows from calibre.ebooks.oeb.polish.container import AZW3Container, get_container from calibre.ebooks.oeb.polish.toc import ( TOC, add_id, commit_toc, from_files, from_links, from_xpaths, get_toc ) from calibre.gui2 import ( Application, error_dialog, info_dialog, question_dialog, set_app_uid ) from calibre.gui2.convert.xpath_wizard import XPathEdit from calibre.gui2.progress_indicator import ProgressIndicator from calibre.gui2.toc.location import ItemEdit from calibre.ptempfile import reset_base_dir from calibre.utils.config import JSONConfig from calibre.utils.filenames import atomic_rename from calibre.utils.logging import GUILog ICON_SIZE = 24 class XPathDialog(QDialog): # {{{ def __init__(self, parent, prefs): QDialog.__init__(self, parent) self.prefs = prefs self.setWindowTitle(_('Create ToC from XPath')) self.l = l = QVBoxLayout() self.setLayout(l) self.la = la = QLabel(_( 'Specify a series of XPath expressions for the different levels of' ' the Table of Contents. You can use the wizard buttons to help' ' you create XPath expressions.')) la.setWordWrap(True) l.addWidget(la) self.widgets = [] for i in range(5): la = _('Level %s ToC:')%('&%d'%(i+1)) xp = XPathEdit(self) xp.set_msg(la) self.widgets.append(xp) l.addWidget(xp) self.bb = bb = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok|QDialogButtonBox.StandardButton.Cancel) bb.accepted.connect(self.accept) bb.rejected.connect(self.reject) self.ssb = b = bb.addButton(_('&Save settings'), QDialogButtonBox.ButtonRole.ActionRole) b.clicked.connect(self.save_settings) self.load_button = b = bb.addButton(_('&Load settings'), QDialogButtonBox.ButtonRole.ActionRole) self.load_menu = QMenu(b) b.setMenu(self.load_menu) self.setup_load_button() self.remove_duplicates_cb = QCheckBox(_('Do not add duplicate entries at the same level')) self.remove_duplicates_cb.setChecked(self.prefs.get('xpath_toc_remove_duplicates', True)) l.addWidget(self.remove_duplicates_cb) l.addStretch() l.addWidget(bb) self.resize(self.sizeHint() + QSize(50, 75)) def save_settings(self): xpaths = self.xpaths if not xpaths: return error_dialog(self, _('No XPaths'), _('No XPaths have been entered'), show=True) if not self.check(): return name, ok = QInputDialog.getText(self, _('Choose name'), _('Choose a name for these settings')) if ok: name = str(name).strip() if name: saved = self.prefs.get('xpath_toc_settings', {}) # in JSON all keys have to be strings saved[name] = {str(i):x for i, x in enumerate(xpaths)} self.prefs.set('xpath_toc_settings', saved) self.setup_load_button() def setup_load_button(self): saved = self.prefs.get('xpath_toc_settings', {}) m = self.load_menu m.clear() self.__actions = [] a = self.__actions.append for name in sorted(saved): a(m.addAction(name, partial(self.load_settings, name))) m.addSeparator() a(m.addAction(_('Remove saved settings'), self.clear_settings)) self.load_button.setEnabled(bool(saved)) def clear_settings(self): self.prefs.set('xpath_toc_settings', {}) self.setup_load_button() def load_settings(self, name): saved = self.prefs.get('xpath_toc_settings', {}).get(name, {}) for i, w in enumerate(self.widgets): txt = saved.get(str(i), '') w.edit.setText(txt) def check(self): for w in self.widgets: if not w.check(): error_dialog(self, _('Invalid XPath'), _('The XPath expression %s is not valid.')%w.xpath, show=True) return False return True def accept(self): if self.check(): self.prefs.set('xpath_toc_remove_duplicates', self.remove_duplicates_cb.isChecked()) super().accept() @property def xpaths(self): return [w.xpath for w in self.widgets if w.xpath.strip()] # }}} class ItemView(QStackedWidget): # {{{ add_new_item = pyqtSignal(object, object) delete_item = pyqtSignal() flatten_item = pyqtSignal() go_to_root = pyqtSignal() create_from_xpath = pyqtSignal(object, object) create_from_links = pyqtSignal() create_from_files = pyqtSignal() flatten_toc = pyqtSignal() def __init__(self, parent, prefs): QStackedWidget.__init__(self, parent) self.prefs = prefs self.setMinimumWidth(250) self.root_pane = rp = QWidget(self) self.item_pane = ip = QWidget(self) self.current_item = None sa = QScrollArea(self) sa.setWidgetResizable(True) sa.setWidget(rp) self.addWidget(sa) sa = QScrollArea(self) sa.setWidgetResizable(True) sa.setWidget(ip) self.addWidget(sa) self.l1 = la = QLabel('<p>'+_( 'You can edit existing entries in the Table of Contents by clicking them' ' in the panel to the left.')+'<p>'+_( 'Entries with a green tick next to them point to a location that has ' 'been verified to exist. Entries with a red dot are broken and may need' ' to be fixed.')) la.setStyleSheet('QLabel { margin-bottom: 20px }') la.setWordWrap(True) l = rp.l = QVBoxLayout() rp.setLayout(l) l.addWidget(la) self.add_new_to_root_button = b = QPushButton(_('Create a &new entry')) b.clicked.connect(self.add_new_to_root) l.addWidget(b) l.addStretch() self.cfmhb = b = QPushButton(_('Generate ToC from &major headings')) b.clicked.connect(self.create_from_major_headings) b.setToolTip(textwrap.fill(_( 'Generate a Table of Contents from the major headings in the book.' ' This will work if the book identifies its headings using HTML' ' heading tags. Uses the <h1>, <h2> and <h3> tags.'))) l.addWidget(b) self.cfmab = b = QPushButton(_('Generate ToC from &all headings')) b.clicked.connect(self.create_from_all_headings) b.setToolTip(textwrap.fill(_( 'Generate a Table of Contents from all the headings in the book.' ' This will work if the book identifies its headings using HTML' ' heading tags. Uses the <h1-6> tags.'))) l.addWidget(b) self.lb = b = QPushButton(_('Generate ToC from &links')) b.clicked.connect(self.create_from_links) b.setToolTip(textwrap.fill(_( 'Generate a Table of Contents from all the links in the book.' ' Links that point to destinations that do not exist in the book are' ' ignored. Also multiple links with the same destination or the same' ' text are ignored.' ))) l.addWidget(b) self.cfb = b = QPushButton(_('Generate ToC from &files')) b.clicked.connect(self.create_from_files) b.setToolTip(textwrap.fill(_( 'Generate a Table of Contents from individual files in the book.' ' Each entry in the ToC will point to the start of the file, the' ' text of the entry will be the "first line" of text from the file.' ))) l.addWidget(b) self.xpb = b = QPushButton(_('Generate ToC from &XPath')) b.clicked.connect(self.create_from_user_xpath) b.setToolTip(textwrap.fill(_( 'Generate a Table of Contents from arbitrary XPath expressions.' ))) l.addWidget(b) self.fal = b = QPushButton(_('&Flatten the ToC')) b.clicked.connect(self.flatten_toc) b.setToolTip(textwrap.fill(_( 'Flatten the Table of Contents, putting all entries at the top level' ))) l.addWidget(b) l.addStretch() self.w1 = la = QLabel(_('<b>WARNING:</b> calibre only supports the ' 'creation of linear ToCs in AZW3 files. In a ' 'linear ToC every entry must point to a ' 'location after the previous entry. If you ' 'create a non-linear ToC it will be ' 'automatically re-arranged inside the AZW3 file.' )) la.setWordWrap(True) l.addWidget(la) l = ip.l = QGridLayout() ip.setLayout(l) la = ip.heading = QLabel('') l.addWidget(la, 0, 0, 1, 2) la.setWordWrap(True) la = ip.la = QLabel(_( 'You can move this entry around the Table of Contents by drag ' 'and drop or using the up and down buttons to the left')) la.setWordWrap(True) l.addWidget(la, 1, 0, 1, 2) # Item status ip.hl1 = hl = QFrame() hl.setFrameShape(QFrame.Shape.HLine) l.addWidget(hl, l.rowCount(), 0, 1, 2) self.icon_label = QLabel() self.status_label = QLabel() self.status_label.setWordWrap(True) l.addWidget(self.icon_label, l.rowCount(), 0) l.addWidget(self.status_label, l.rowCount()-1, 1) ip.hl2 = hl = QFrame() hl.setFrameShape(QFrame.Shape.HLine) l.addWidget(hl, l.rowCount(), 0, 1, 2) # Edit/remove item rs = l.rowCount() ip.b1 = b = QPushButton(QIcon(I('edit_input.png')), _('Change the &location this entry points to'), self) b.clicked.connect(self.edit_item) l.addWidget(b, l.rowCount()+1, 0, 1, 2) ip.b2 = b = QPushButton(QIcon(I('trash.png')), _('&Remove this entry'), self) l.addWidget(b, l.rowCount(), 0, 1, 2) b.clicked.connect(self.delete_item) ip.hl3 = hl = QFrame() hl.setFrameShape(QFrame.Shape.HLine) l.addWidget(hl, l.rowCount(), 0, 1, 2) l.setRowMinimumHeight(rs, 20) # Add new item rs = l.rowCount() ip.b3 = b = QPushButton(QIcon(I('plus.png')), _('New entry &inside this entry')) connect_lambda(b.clicked, self, lambda self: self.add_new('inside')) l.addWidget(b, l.rowCount()+1, 0, 1, 2) ip.b4 = b = QPushButton(QIcon(I('plus.png')), _('New entry &above this entry')) connect_lambda(b.clicked, self, lambda self: self.add_new('before')) l.addWidget(b, l.rowCount(), 0, 1, 2) ip.b5 = b = QPushButton(QIcon(I('plus.png')), _('New entry &below this entry')) connect_lambda(b.clicked, self, lambda self: self.add_new('after')) l.addWidget(b, l.rowCount(), 0, 1, 2) # Flatten entry ip.b3 = b = QPushButton(QIcon(I('heuristics.png')), _('&Flatten this entry')) b.clicked.connect(self.flatten_item) b.setToolTip(_('All children of this entry are brought to the same ' 'level as this entry.')) l.addWidget(b, l.rowCount()+1, 0, 1, 2) ip.hl4 = hl = QFrame() hl.setFrameShape(QFrame.Shape.HLine) l.addWidget(hl, l.rowCount(), 0, 1, 2) l.setRowMinimumHeight(rs, 20) # Return to welcome rs = l.rowCount() ip.b4 = b = QPushButton(QIcon(I('back.png')), _('&Return to welcome screen')) b.clicked.connect(self.go_to_root) b.setToolTip(_('Go back to the top level view')) l.addWidget(b, l.rowCount()+1, 0, 1, 2) l.setRowMinimumHeight(rs, 20) l.addWidget(QLabel(), l.rowCount(), 0, 1, 2) l.setColumnStretch(1, 10) l.setRowStretch(l.rowCount()-1, 10) self.w2 = la = QLabel(self.w1.text()) self.w2.setWordWrap(True) l.addWidget(la, l.rowCount(), 0, 1, 2) def ask_if_duplicates_should_be_removed(self): return not question_dialog(self, _('Remove duplicates'), _( 'Should headings with the same text at the same level be included?'), yes_text=_('&Include duplicates'), no_text=_('&Remove duplicates')) def create_from_major_headings(self): self.create_from_xpath.emit(['//h:h%d'%i for i in range(1, 4)], self.ask_if_duplicates_should_be_removed()) def create_from_all_headings(self): self.create_from_xpath.emit(['//h:h%d'%i for i in range(1, 7)], self.ask_if_duplicates_should_be_removed()) def create_from_user_xpath(self): d = XPathDialog(self, self.prefs) if d.exec() == QDialog.DialogCode.Accepted and d.xpaths: self.create_from_xpath.emit(d.xpaths, d.remove_duplicates_cb.isChecked()) def hide_azw3_warning(self): self.w1.setVisible(False), self.w2.setVisible(False) def add_new_to_root(self): self.add_new_item.emit(None, None) def add_new(self, where): self.add_new_item.emit(self.current_item, where) def edit_item(self): self.add_new_item.emit(self.current_item, None) def __call__(self, item): if item is None: self.current_item = None self.setCurrentIndex(0) else: self.current_item = item self.setCurrentIndex(1) self.populate_item_pane() def populate_item_pane(self): item = self.current_item name = str(item.data(0, Qt.ItemDataRole.DisplayRole) or '') self.item_pane.heading.setText('<h2>%s</h2>'%name) self.icon_label.setPixmap(item.data(0, Qt.ItemDataRole.DecorationRole ).pixmap(32, 32)) tt = _('This entry points to an existing destination') toc = item.data(0, Qt.ItemDataRole.UserRole) if toc.dest_exists is False: tt = _('The location this entry points to does not exist') elif toc.dest_exists is None: tt = '' self.status_label.setText(tt) def data_changed(self, item): if item is self.current_item: self.populate_item_pane() # }}} NODE_FLAGS = (Qt.ItemFlag.ItemIsDragEnabled|Qt.ItemFlag.ItemIsEditable|Qt.ItemFlag.ItemIsEnabled|Qt.ItemFlag.ItemIsSelectable|Qt.ItemFlag.ItemIsDropEnabled) class TreeWidget(QTreeWidget): # {{{ edit_item = pyqtSignal() history_state_changed = pyqtSignal() def __init__(self, parent): QTreeWidget.__init__(self, parent) self.history = [] self.setHeaderLabel(_('Table of Contents')) self.setIconSize(QSize(ICON_SIZE, ICON_SIZE)) self.setDragEnabled(True) self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) self.viewport().setAcceptDrops(True) self.setDropIndicatorShown(True) self.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove) self.setAutoScroll(True) self.setAutoScrollMargin(ICON_SIZE*2) self.setDefaultDropAction(Qt.DropAction.MoveAction) self.setAutoExpandDelay(1000) self.setAnimated(True) self.setMouseTracking(True) self.in_drop_event = False self.root = self.invisibleRootItem() self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.customContextMenuRequested.connect(self.show_context_menu) def push_history(self): self.history.append(self.serialize_tree()) self.history_state_changed.emit() def pop_history(self): if self.history: self.unserialize_tree(self.history.pop()) self.history_state_changed.emit() def commitData(self, editor): self.push_history() return QTreeWidget.commitData(self, editor) def iter_items(self, parent=None): if parent is None: parent = self.invisibleRootItem() for i in range(parent.childCount()): child = parent.child(i) yield child yield from self.iter_items(parent=child) def update_status_tip(self, item): c = item.data(0, Qt.ItemDataRole.UserRole) if c is not None: frag = c.frag or '' if frag: frag = '#'+frag item.setStatusTip(0, _('<b>Title</b>: {0} <b>Dest</b>: {1}{2}').format( c.title, c.dest, frag)) def serialize_tree(self): def serialize_node(node): return { 'title': node.data(0, Qt.ItemDataRole.DisplayRole), 'toc_node': node.data(0, Qt.ItemDataRole.UserRole), 'icon': node.data(0, Qt.ItemDataRole.DecorationRole), 'tooltip': node.data(0, Qt.ItemDataRole.ToolTipRole), 'is_selected': node.isSelected(), 'is_expanded': node.isExpanded(), 'children': list(map(serialize_node, (node.child(i) for i in range(node.childCount())))), } node = self.invisibleRootItem() return {'children': list(map(serialize_node, (node.child(i) for i in range(node.childCount()))))} def unserialize_tree(self, serialized): def unserialize_node(dict_node, parent): n = QTreeWidgetItem(parent) n.setData(0, Qt.ItemDataRole.DisplayRole, dict_node['title']) n.setData(0, Qt.ItemDataRole.UserRole, dict_node['toc_node']) n.setFlags(NODE_FLAGS) n.setData(0, Qt.ItemDataRole.DecorationRole, dict_node['icon']) n.setData(0, Qt.ItemDataRole.ToolTipRole, dict_node['tooltip']) self.update_status_tip(n) n.setExpanded(dict_node['is_expanded']) n.setSelected(dict_node['is_selected']) for c in dict_node['children']: unserialize_node(c, n) i = self.invisibleRootItem() i.takeChildren() for child in serialized['children']: unserialize_node(child, i) def dropEvent(self, event): self.in_drop_event = True self.push_history() try: super().dropEvent(event) finally: self.in_drop_event = False def selectedIndexes(self): ans = super().selectedIndexes() if self.in_drop_event: # For order to be be preserved when moving by drag and drop, we # have to ensure that selectedIndexes returns an ordered list of # indexes. sort_map = {self.indexFromItem(item):i for i, item in enumerate(self.iter_items())} ans = sorted(ans, key=lambda x:sort_map.get(x, -1)) return ans def highlight_item(self, item): self.setCurrentItem(item, 0, QItemSelectionModel.SelectionFlag.ClearAndSelect) self.scrollToItem(item) def check_multi_selection(self): if len(self.selectedItems()) > 1: info_dialog(self, _('Multiple items selected'), _( 'You are trying to move multiple items at once, this is not supported. Instead use' ' Drag and Drop to move multiple items'), show=True) return False return True def move_left(self): if not self.check_multi_selection(): return self.push_history() item = self.currentItem() if item is not None: parent = item.parent() if parent is not None: is_expanded = item.isExpanded() or item.childCount() == 0 gp = parent.parent() or self.invisibleRootItem() idx = gp.indexOfChild(parent) for gc in [parent.child(i) for i in range(parent.indexOfChild(item)+1, parent.childCount())]: parent.removeChild(gc) item.addChild(gc) parent.removeChild(item) gp.insertChild(idx+1, item) if is_expanded: self.expandItem(item) self.highlight_item(item) def move_right(self): if not self.check_multi_selection(): return self.push_history() item = self.currentItem() if item is not None: parent = item.parent() or self.invisibleRootItem() idx = parent.indexOfChild(item) if idx > 0: is_expanded = item.isExpanded() np = parent.child(idx-1) parent.removeChild(item) np.addChild(item) if is_expanded: self.expandItem(item) self.highlight_item(item) def move_down(self): if not self.check_multi_selection(): return self.push_history() item = self.currentItem() if item is None: if self.root.childCount() == 0: return item = self.root.child(0) self.highlight_item(item) return parent = item.parent() or self.root idx = parent.indexOfChild(item) if idx == parent.childCount() - 1: # At end of parent, need to become sibling of parent if parent is self.root: return gp = parent.parent() or self.root parent.removeChild(item) gp.insertChild(gp.indexOfChild(parent)+1, item) else: sibling = parent.child(idx+1) parent.removeChild(item) sibling.insertChild(0, item) self.highlight_item(item) def move_up(self): if not self.check_multi_selection(): return self.push_history() item = self.currentItem() if item is None: if self.root.childCount() == 0: return item = self.root.child(self.root.childCount()-1) self.highlight_item(item) return parent = item.parent() or self.root idx = parent.indexOfChild(item) if idx == 0: # At end of parent, need to become sibling of parent if parent is self.root: return gp = parent.parent() or self.root parent.removeChild(item) gp.insertChild(gp.indexOfChild(parent), item) else: sibling = parent.child(idx-1) parent.removeChild(item) sibling.addChild(item) self.highlight_item(item) def del_items(self): self.push_history() for item in self.selectedItems(): p = item.parent() or self.root p.removeChild(item) def title_case(self): self.push_history() from calibre.utils.titlecase import titlecase for item in self.selectedItems(): t = str(item.data(0, Qt.ItemDataRole.DisplayRole) or '') item.setData(0, Qt.ItemDataRole.DisplayRole, titlecase(t)) def upper_case(self): self.push_history() for item in self.selectedItems(): t = str(item.data(0, Qt.ItemDataRole.DisplayRole) or '') item.setData(0, Qt.ItemDataRole.DisplayRole, icu_upper(t)) def lower_case(self): self.push_history() for item in self.selectedItems(): t = str(item.data(0, Qt.ItemDataRole.DisplayRole) or '') item.setData(0, Qt.ItemDataRole.DisplayRole, icu_lower(t)) def swap_case(self): self.push_history() from calibre.utils.icu import swapcase for item in self.selectedItems(): t = str(item.data(0, Qt.ItemDataRole.DisplayRole) or '') item.setData(0, Qt.ItemDataRole.DisplayRole, swapcase(t)) def capitalize(self): self.push_history() from calibre.utils.icu import capitalize for item in self.selectedItems(): t = str(item.data(0, Qt.ItemDataRole.DisplayRole) or '') item.setData(0, Qt.ItemDataRole.DisplayRole, capitalize(t)) def bulk_rename(self): from calibre.gui2.tweak_book.file_list import get_bulk_rename_settings sort_map = {id(item):i for i, item in enumerate(self.iter_items())} items = sorted(self.selectedItems(), key=lambda x:sort_map.get(id(x), -1)) settings = get_bulk_rename_settings(self, len(items), prefix=_('Chapter '), msg=_( 'All selected items will be renamed to the form prefix-number'), sanitize=lambda x:x, leading_zeros=False) fmt, num = settings['prefix'], settings['start'] if fmt is not None and num is not None: self.push_history() for i, item in enumerate(items): item.setData(0, Qt.ItemDataRole.DisplayRole, fmt % (num + i)) def keyPressEvent(self, ev): if ev.key() == Qt.Key.Key_Left and ev.modifiers() & Qt.KeyboardModifier.ControlModifier: self.move_left() ev.accept() elif ev.key() == Qt.Key.Key_Right and ev.modifiers() & Qt.KeyboardModifier.ControlModifier: self.move_right() ev.accept() elif ev.key() == Qt.Key.Key_Up and (ev.modifiers() & Qt.KeyboardModifier.ControlModifier or ev.modifiers() & Qt.KeyboardModifier.AltModifier): self.move_up() ev.accept() elif ev.key() == Qt.Key.Key_Down and (ev.modifiers() & Qt.KeyboardModifier.ControlModifier or ev.modifiers() & Qt.KeyboardModifier.AltModifier): self.move_down() ev.accept() elif ev.key() in (Qt.Key.Key_Delete, Qt.Key.Key_Backspace): self.del_items() ev.accept() else: return super().keyPressEvent(ev) def show_context_menu(self, point): item = self.currentItem() def key(k): sc = str(QKeySequence(k | Qt.KeyboardModifier.ControlModifier).toString(QKeySequence.SequenceFormat.NativeText)) return ' [%s]'%sc if item is not None: m = QMenu(self) m.addAction(QIcon(I('edit_input.png')), _('Change the location this entry points to'), self.edit_item) m.addAction(QIcon(I('modified.png')), _('Bulk rename all selected items'), self.bulk_rename) m.addAction(QIcon(I('trash.png')), _('Remove all selected items'), self.del_items) m.addSeparator() ci = str(item.data(0, Qt.ItemDataRole.DisplayRole) or '') p = item.parent() or self.invisibleRootItem() idx = p.indexOfChild(item) if idx > 0: m.addAction(QIcon(I('arrow-up.png')), (_('Move "%s" up')%ci)+key(Qt.Key.Key_Up), self.move_up) if idx + 1 < p.childCount(): m.addAction(QIcon(I('arrow-down.png')), (_('Move "%s" down')%ci)+key(Qt.Key.Key_Down), self.move_down) if item.parent() is not None: m.addAction(QIcon(I('back.png')), (_('Unindent "%s"')%ci)+key(Qt.Key.Key_Left), self.move_left) if idx > 0: m.addAction(QIcon(I('forward.png')), (_('Indent "%s"')%ci)+key(Qt.Key.Key_Right), self.move_right) m.addSeparator() case_menu = QMenu(_('Change case'), m) case_menu.addAction(_('Upper case'), self.upper_case) case_menu.addAction(_('Lower case'), self.lower_case) case_menu.addAction(_('Swap case'), self.swap_case) case_menu.addAction(_('Title case'), self.title_case) case_menu.addAction(_('Capitalize'), self.capitalize) m.addMenu(case_menu) m.exec(QCursor.pos()) # }}} class TOCView(QWidget): # {{{ add_new_item = pyqtSignal(object, object) def __init__(self, parent, prefs): QWidget.__init__(self, parent) self.toc_title = None self.prefs = prefs l = self.l = QGridLayout() self.setLayout(l) self.tocw = t = TreeWidget(self) self.tocw.edit_item.connect(self.edit_item) l.addWidget(t, 0, 0, 7, 3) self.up_button = b = QToolButton(self) b.setIcon(QIcon(I('arrow-up.png'))) b.setIconSize(QSize(ICON_SIZE, ICON_SIZE)) l.addWidget(b, 0, 3) b.setToolTip(_('Move current entry up [Ctrl+Up]')) b.clicked.connect(self.move_up) self.left_button = b = QToolButton(self) b.setIcon(QIcon(I('back.png'))) b.setIconSize(QSize(ICON_SIZE, ICON_SIZE)) l.addWidget(b, 2, 3) b.setToolTip(_('Unindent the current entry [Ctrl+Left]')) b.clicked.connect(self.tocw.move_left) self.del_button = b = QToolButton(self) b.setIcon(QIcon(I('trash.png'))) b.setIconSize(QSize(ICON_SIZE, ICON_SIZE)) l.addWidget(b, 3, 3) b.setToolTip(_('Remove all selected entries')) b.clicked.connect(self.del_items) self.right_button = b = QToolButton(self) b.setIcon(QIcon(I('forward.png'))) b.setIconSize(QSize(ICON_SIZE, ICON_SIZE)) l.addWidget(b, 4, 3) b.setToolTip(_('Indent the current entry [Ctrl+Right]')) b.clicked.connect(self.tocw.move_right) self.down_button = b = QToolButton(self) b.setIcon(QIcon(I('arrow-down.png'))) b.setIconSize(QSize(ICON_SIZE, ICON_SIZE)) l.addWidget(b, 6, 3) b.setToolTip(_('Move current entry down [Ctrl+Down]')) b.clicked.connect(self.move_down) self.expand_all_button = b = QPushButton(_('&Expand all')) col = 7 l.addWidget(b, col, 0) b.clicked.connect(self.tocw.expandAll) self.collapse_all_button = b = QPushButton(_('&Collapse all')) b.clicked.connect(self.tocw.collapseAll) l.addWidget(b, col, 1) self.default_msg = _('Double click on an entry to change the text') self.hl = hl = QLabel(self.default_msg) hl.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Ignored) l.addWidget(hl, col, 2, 1, -1) self.item_view = i = ItemView(self, self.prefs) self.item_view.delete_item.connect(self.delete_current_item) i.add_new_item.connect(self.add_new_item) i.create_from_xpath.connect(self.create_from_xpath) i.create_from_links.connect(self.create_from_links) i.create_from_files.connect(self.create_from_files) i.flatten_item.connect(self.flatten_item) i.flatten_toc.connect(self.flatten_toc) i.go_to_root.connect(self.go_to_root) l.addWidget(i, 0, 4, col, 1) l.setColumnStretch(2, 10) def edit_item(self): self.item_view.edit_item() def event(self, e): if e.type() == QEvent.Type.StatusTip: txt = str(e.tip()) or self.default_msg self.hl.setText(txt) return super().event(e) def item_title(self, item): return str(item.data(0, Qt.ItemDataRole.DisplayRole) or '') def del_items(self): self.tocw.del_items() def delete_current_item(self): item = self.tocw.currentItem() if item is not None: self.tocw.push_history() p = item.parent() or self.root p.removeChild(item) def iter_items(self, parent=None): yield from self.tocw.iter_items(parent=parent) def flatten_toc(self): self.tocw.push_history() found = True while found: found = False for item in self.iter_items(): if item.childCount() > 0: self._flatten_item(item) found = True break def flatten_item(self): self.tocw.push_history() self._flatten_item(self.tocw.currentItem()) def _flatten_item(self, item): if item is not None: p = item.parent() or self.root idx = p.indexOfChild(item) children = [item.child(i) for i in range(item.childCount())] for child in reversed(children): item.removeChild(child) p.insertChild(idx+1, child) def go_to_root(self): self.tocw.setCurrentItem(None) def highlight_item(self, item): self.tocw.highlight_item(item) def move_up(self): self.tocw.move_up() def move_down(self): self.tocw.move_down() def data_changed(self, top_left, bottom_right): for r in range(top_left.row(), bottom_right.row()+1): idx = self.tocw.model().index(r, 0, top_left.parent()) new_title = str(idx.data(Qt.ItemDataRole.DisplayRole) or '').strip() toc = idx.data(Qt.ItemDataRole.UserRole) if toc is not None: toc.title = new_title or _('(Untitled)') item = self.tocw.itemFromIndex(idx) self.tocw.update_status_tip(item) self.item_view.data_changed(item) def create_item(self, parent, child, idx=-1): if idx == -1: c = QTreeWidgetItem(parent) else: c = QTreeWidgetItem() parent.insertChild(idx, c) self.populate_item(c, child) return c def populate_item(self, c, child): c.setData(0, Qt.ItemDataRole.DisplayRole, child.title or _('(Untitled)')) c.setData(0, Qt.ItemDataRole.UserRole, child) c.setFlags(NODE_FLAGS) c.setData(0, Qt.ItemDataRole.DecorationRole, self.icon_map[child.dest_exists]) if child.dest_exists is False: c.setData(0, Qt.ItemDataRole.ToolTipRole, _( 'The location this entry point to does not exist:\n%s') %child.dest_error) else: c.setData(0, Qt.ItemDataRole.ToolTipRole, None) self.tocw.update_status_tip(c) def __call__(self, ebook): self.ebook = ebook if not isinstance(ebook, AZW3Container): self.item_view.hide_azw3_warning() self.toc = get_toc(self.ebook) self.toc_lang, self.toc_uid = self.toc.lang, self.toc.uid self.toc_title = self.toc.toc_title self.blank = QIcon(I('blank.png')) self.ok = QIcon(I('ok.png')) self.err = QIcon(I('dot_red.png')) self.icon_map = {None:self.blank, True:self.ok, False:self.err} def process_item(toc_node, parent): for child in toc_node: c = self.create_item(parent, child) process_item(child, c) root = self.root = self.tocw.invisibleRootItem() root.setData(0, Qt.ItemDataRole.UserRole, self.toc) process_item(self.toc, root) self.tocw.model().dataChanged.connect(self.data_changed) self.tocw.currentItemChanged.connect(self.current_item_changed) self.tocw.setCurrentItem(None) def current_item_changed(self, current, previous): self.item_view(current) def update_item(self, item, where, name, frag, title): if isinstance(frag, tuple): frag = add_id(self.ebook, name, *frag) child = TOC(title, name, frag) child.dest_exists = True self.tocw.push_history() if item is None: # New entry at root level c = self.create_item(self.root, child) self.tocw.setCurrentItem(c, 0, QItemSelectionModel.SelectionFlag.ClearAndSelect) self.tocw.scrollToItem(c) else: if where is None: # Editing existing entry self.populate_item(item, child) else: if where == 'inside': parent = item idx = -1 else: parent = item.parent() or self.root idx = parent.indexOfChild(item) if where == 'after': idx += 1 c = self.create_item(parent, child, idx=idx) self.tocw.setCurrentItem(c, 0, QItemSelectionModel.SelectionFlag.ClearAndSelect) self.tocw.scrollToItem(c) def create_toc(self): root = TOC() def process_node(parent, toc_parent): for i in range(parent.childCount()): item = parent.child(i) title = str(item.data(0, Qt.ItemDataRole.DisplayRole) or '').strip() toc = item.data(0, Qt.ItemDataRole.UserRole) dest, frag = toc.dest, toc.frag toc = toc_parent.add(title, dest, frag) process_node(item, toc) process_node(self.tocw.invisibleRootItem(), root) return root def insert_toc_fragment(self, toc): def process_node(root, tocparent, added): for child in tocparent: item = self.create_item(root, child) added.append(item) process_node(item, child, added) self.tocw.push_history() nodes = [] process_node(self.root, toc, nodes) self.highlight_item(nodes[0]) def create_from_xpath(self, xpaths, remove_duplicates=True): toc = from_xpaths(self.ebook, xpaths) if len(toc) == 0: return error_dialog(self, _('No items found'), _('No items were found that could be added to the Table of Contents.'), show=True) if remove_duplicates: toc.remove_duplicates() self.insert_toc_fragment(toc) def create_from_links(self): toc = from_links(self.ebook) if len(toc) == 0: return error_dialog(self, _('No items found'), _('No links were found that could be added to the Table of Contents.'), show=True) self.insert_toc_fragment(toc) def create_from_files(self): toc = from_files(self.ebook) if len(toc) == 0: return error_dialog(self, _('No items found'), _('No files were found that could be added to the Table of Contents.'), show=True) self.insert_toc_fragment(toc) def undo(self): self.tocw.pop_history() # }}} te_prefs = JSONConfig('toc-editor') class TOCEditor(QDialog): # {{{ explode_done = pyqtSignal(object) writing_done = pyqtSignal(object) def __init__(self, pathtobook, title=None, parent=None, prefs=None, write_result_to=None): QDialog.__init__(self, parent) self.last_reject_at = self.last_accept_at = -1000 self.write_result_to = write_result_to self.prefs = prefs or te_prefs self.pathtobook = pathtobook self.working = True t = title or os.path.basename(pathtobook) self.book_title = t self.setWindowTitle(_('Edit the ToC in %s')%t) self.setWindowIcon(QIcon(I('highlight_only_on.png'))) l = self.l = QVBoxLayout() self.setLayout(l) self.stacks = s = QStackedWidget(self) l.addWidget(s) self.loading_widget = lw = QWidget(self) s.addWidget(lw) ll = self.ll = QVBoxLayout() lw.setLayout(ll) self.pi = pi = ProgressIndicator() pi.setDisplaySize(QSize(200, 200)) pi.startAnimation() ll.addWidget(pi, alignment=Qt.AlignmentFlag.AlignHCenter|Qt.AlignmentFlag.AlignCenter) la = self.wait_label = QLabel(_('Loading %s, please wait...')%t) la.setWordWrap(True) f = la.font() f.setPointSize(20), la.setFont(f) ll.addWidget(la, alignment=Qt.AlignmentFlag.AlignHCenter|Qt.AlignmentFlag.AlignTop) self.toc_view = TOCView(self, self.prefs) self.toc_view.add_new_item.connect(self.add_new_item) self.toc_view.tocw.history_state_changed.connect(self.update_history_buttons) s.addWidget(self.toc_view) self.item_edit = ItemEdit(self) s.addWidget(self.item_edit) bb = self.bb = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok|QDialogButtonBox.StandardButton.Cancel) l.addWidget(bb) bb.accepted.connect(self.accept) bb.rejected.connect(self.reject) self.undo_button = b = bb.addButton(_('&Undo'), QDialogButtonBox.ButtonRole.ActionRole) b.setToolTip(_('Undo the last action, if any')) b.setIcon(QIcon(I('edit-undo.png'))) b.clicked.connect(self.toc_view.undo) self.explode_done.connect(self.read_toc, type=Qt.ConnectionType.QueuedConnection) self.writing_done.connect(self.really_accept, type=Qt.ConnectionType.QueuedConnection) r = self.screen().availableSize() self.resize(r.width() - 100, r.height() - 100) geom = self.prefs.get('toc_editor_window_geom', None) if geom is not None: QApplication.instance().safe_restore_geometry(self, bytes(geom)) self.stacks.currentChanged.connect(self.update_history_buttons) self.update_history_buttons() def update_history_buttons(self): self.undo_button.setVisible(self.stacks.currentIndex() == 1) self.undo_button.setEnabled(bool(self.toc_view.tocw.history)) def add_new_item(self, item, where): self.item_edit(item, where) self.stacks.setCurrentIndex(2) def accept(self): if monotonic() - self.last_accept_at < 1: return self.last_accept_at = monotonic() if self.stacks.currentIndex() == 2: self.toc_view.update_item(*self.item_edit.result) self.prefs['toc_edit_splitter_state'] = bytearray(self.item_edit.splitter.saveState()) self.stacks.setCurrentIndex(1) elif self.stacks.currentIndex() == 1: self.working = False Thread(target=self.write_toc).start() self.pi.startAnimation() self.wait_label.setText(_('Writing %s, please wait...')% self.book_title) self.stacks.setCurrentIndex(0) self.bb.setEnabled(False) def really_accept(self, tb): self.prefs['toc_editor_window_geom'] = bytearray(self.saveGeometry()) if tb: error_dialog(self, _('Failed to write book'), _('Could not write %s. Click "Show details" for' ' more information.')%self.book_title, det_msg=tb, show=True) super().reject() return self.write_result(0) super().accept() def reject(self): if not self.bb.isEnabled(): return if monotonic() - self.last_reject_at < 1: return self.last_reject_at = monotonic() if self.stacks.currentIndex() == 2: self.prefs['toc_edit_splitter_state'] = bytearray(self.item_edit.splitter.saveState()) self.stacks.setCurrentIndex(1) else: self.working = False self.prefs['toc_editor_window_geom'] = bytearray(self.saveGeometry()) self.write_result(1) super().reject() def write_result(self, res): if self.write_result_to: with tempfile.NamedTemporaryFile(dir=os.path.dirname(self.write_result_to), delete=False) as f: src = f.name f.write(str(res).encode('utf-8')) f.flush() atomic_rename(src, self.write_result_to) def start(self): t = Thread(target=self.explode) t.daemon = True self.log = GUILog() t.start() def explode(self): tb = None try: self.ebook = get_container(self.pathtobook, log=self.log) except: import traceback tb = traceback.format_exc() if self.working: self.working = False self.explode_done.emit(tb) def read_toc(self, tb): if tb: error_dialog(self, _('Failed to load book'), _('Could not load %s. Click "Show details" for' ' more information.')%self.book_title, det_msg=tb, show=True) self.reject() return self.pi.stopAnimation() self.toc_view(self.ebook) self.item_edit.load(self.ebook) self.stacks.setCurrentIndex(1) def write_toc(self): tb = None try: toc = self.toc_view.create_toc() toc.toc_title = getattr(self.toc_view, 'toc_title', None) commit_toc(self.ebook, toc, lang=self.toc_view.toc_lang, uid=self.toc_view.toc_uid) self.ebook.commit() except: import traceback tb = traceback.format_exc() self.writing_done.emit(tb) # }}} def main(path=None, title=None): # Ensure we can continue to function if GUI is closed os.environ.pop('CALIBRE_WORKER_TEMP_DIR', None) reset_base_dir() if iswindows: # Ensure that all instances are grouped together in the task bar. This # prevents them from being grouped with viewer/editor process when # launched from within calibre, as both use calibre-parallel.exe set_app_uid(TOC_DIALOG_APP_UID) with open(path + '.started', 'w'): pass override = 'calibre-gui' if islinux else None app = Application([], override_program_name=override) d = TOCEditor(path, title=title, write_result_to=path + '.result') d.start() ret = 1 if d.exec() == QDialog.DialogCode.Accepted: ret = 0 del d del app raise SystemExit(ret) if __name__ == '__main__': main(path=sys.argv[-1], title='test') os.remove(sys.argv[-1] + '.lock')