def r(name, icon, text, checkable=False, shortcut=None): ac = QAction(QIcon(I(icon + '.png')), text, self) if checkable: ac.setCheckable(checkable) setattr(self, 'action_'+name, ac) ac.triggered.connect(getattr(self, 'do_' + name)) if shortcut is not None: sc = shortcut if isinstance(shortcut, QKeySequence) else QKeySequence(shortcut) ac.setShortcut(sc) ac.setToolTip(text + f' [{sc.toString(QKeySequence.SequenceFormat.NativeText)}]') self.addAction(ac)
def genesis(self): md = self.qaction.menu() cm = partial(self.create_menu_action, md) cm('individual', _('Edit metadata individually'), icon=self.qaction.icon(), triggered=partial(self.edit_metadata, False, bulk=False)) cm('bulk', _('Edit metadata in bulk'), triggered=partial(self.edit_metadata, False, bulk=True)) md.addSeparator() cm('download', _('Download metadata and covers'), triggered=partial(self.download_metadata, ids=None), shortcut='Ctrl+D') self.metadata_menu = md self.metamerge_menu = mb = QMenu() cm2 = partial(self.create_menu_action, mb) cm2('merge delete', _('Merge into first selected book - delete others'), triggered=self.merge_books) mb.addSeparator() cm2('merge keep', _('Merge into first selected book - keep others'), triggered=partial(self.merge_books, safe_merge=True), shortcut='Alt+M') mb.addSeparator() cm2('merge formats', _('Merge only formats into first selected book - delete others'), triggered=partial(self.merge_books, merge_only_formats=True), shortcut='Alt+Shift+M') self.merge_menu = mb md.addSeparator() self.action_copy = cm('copy', _('Copy metadata'), icon='edit-copy.png', triggered=self.copy_metadata) self.action_paset = cm('paste', _('Paste metadata'), icon='edit-paste.png', triggered=self.paste_metadata) self.action_merge = cm('merge', _('Merge book records'), icon='merge_books.png', shortcut=_('M'), triggered=self.merge_books) self.action_merge.setMenu(mb) self.qaction.triggered.connect(self.edit_metadata) ac = QAction(_('Copy URL to show book in calibre'), self.gui) ac.setToolTip(_('Copy URLs to show the currently selected books in calibre, to the system clipboard')) ac.triggered.connect(self.copy_show_link) self.gui.addAction(ac) self.gui.keyboard.register_shortcut( self.unique_name + ' - ' + 'copy_show_book', ac.text(), description=ac.toolTip(), action=ac, group=self.action_spec[0]) ac = QAction(_('Copy URL to open book in calibre'), self.gui) ac.triggered.connect(self.copy_view_link) ac.setToolTip(_('Copy URLs to open the currently selected books in calibre, to the system clipboard')) self.gui.addAction(ac) self.gui.keyboard.register_shortcut( self.unique_name + ' - ' + 'copy_view_book', ac.text(), description=ac.toolTip(), action=ac, group=self.action_spec[0])
class EditorWidget(QTextEdit, LineEditECM): # {{{ data_changed = pyqtSignal() @property def readonly(self): return self.isReadOnly() @readonly.setter def readonly(self, val): self.setReadOnly(bool(val)) @contextmanager def editing_cursor(self, set_cursor=True): c = self.textCursor() c.beginEditBlock() yield c c.endEditBlock() if set_cursor: self.setTextCursor(c) self.focus_self() def __init__(self, parent=None): QTextEdit.__init__(self, parent) self.setTabChangesFocus(True) self.document().setDefaultStyleSheet(css() + '\n\nli { margin-top: 0.5ex; margin-bottom: 0.5ex; }') font = self.font() f = QFontInfo(font) delta = tweaks['change_book_details_font_size_by'] + 1 if delta: font.setPixelSize(f.pixelSize() + delta) self.setFont(font) f = QFontMetrics(self.font()) self.em_size = f.horizontalAdvance('m') self.base_url = None self._parent = weakref.ref(parent) self.comments_pat = re.compile(r'<!--.*?-->', re.DOTALL) for rec in ( ('bold', 'format-text-bold', _('Bold'), True), ('italic', 'format-text-italic', _('Italic'), True), ('underline', 'format-text-underline', _('Underline'), True), ('strikethrough', 'format-text-strikethrough', _('Strikethrough'), True), ('superscript', 'format-text-superscript', _('Superscript'), True), ('subscript', 'format-text-subscript', _('Subscript'), True), ('ordered_list', 'format-list-ordered', _('Ordered list'), True), ('unordered_list', 'format-list-unordered', _('Unordered list'), True), ('align_left', 'format-justify-left', _('Align left'), True), ('align_center', 'format-justify-center', _('Align center'), True), ('align_right', 'format-justify-right', _('Align right'), True), ('align_justified', 'format-justify-fill', _('Align justified'), True), ('undo', 'edit-undo', _('Undo'), ), ('redo', 'edit-redo', _('Redo'), ), ('remove_format', 'edit-clear', _('Remove formatting'), ), ('copy', 'edit-copy', _('Copy'), ), ('paste', 'edit-paste', _('Paste'), ), ('paste_and_match_style', 'edit-paste', _('Paste and match style'), ), ('cut', 'edit-cut', _('Cut'), ), ('indent', 'format-indent-more', _('Increase indentation'), ), ('outdent', 'format-indent-less', _('Decrease indentation'), ), ('select_all', 'edit-select-all', _('Select all'), ), ('color', 'format-text-color', _('Foreground color')), ('background', 'format-fill-color', _('Background color')), ('insert_link', 'insert-link', _('Insert link or image'),), ('insert_hr', 'format-text-hr', _('Insert separator'),), ('clear', 'trash', _('Clear')), ): name, icon, text = rec[:3] checkable = len(rec) == 4 ac = QAction(QIcon(I(icon + '.png')), text, self) if checkable: ac.setCheckable(checkable) setattr(self, 'action_'+name, ac) ac.triggered.connect(getattr(self, 'do_' + name)) self.action_block_style = QAction(QIcon(I('format-text-heading.png')), _('Style text block'), self) self.action_block_style.setToolTip( _('Style the selected text block')) self.block_style_menu = QMenu(self) self.action_block_style.setMenu(self.block_style_menu) self.block_style_actions = [] h = _('Heading {0}') for text, name in ( (_('Normal'), 'p'), (h.format(1), 'h1'), (h.format(2), 'h2'), (h.format(3), 'h3'), (h.format(4), 'h4'), (h.format(5), 'h5'), (h.format(6), 'h6'), (_('Blockquote'), 'blockquote'), ): ac = QAction(text, self) self.block_style_menu.addAction(ac) ac.block_name = name ac.setCheckable(True) self.block_style_actions.append(ac) ac.triggered.connect(self.do_format_block) self.setHtml('') self.copyAvailable.connect(self.update_clipboard_actions) self.update_clipboard_actions(False) self.selectionChanged.connect(self.update_selection_based_actions) self.update_selection_based_actions() connect_lambda(self.undoAvailable, self, lambda self, yes: self.action_undo.setEnabled(yes)) connect_lambda(self.redoAvailable, self, lambda self, yes: self.action_redo.setEnabled(yes)) self.action_undo.setEnabled(False), self.action_redo.setEnabled(False) self.textChanged.connect(self.update_cursor_position_actions) self.cursorPositionChanged.connect(self.update_cursor_position_actions) self.textChanged.connect(self.data_changed) self.update_cursor_position_actions() def keyPressEvent(self, ev): if ev.matches(QKeySequence.StandardKey.Bold): ev.accept() self.action_bold.toggle(), self.action_bold.trigger() return if ev.matches(QKeySequence.StandardKey.Italic): ev.accept() self.action_italic.toggle(), self.action_italic.trigger() return if ev.matches(QKeySequence.StandardKey.Underline): ev.accept() self.action_underline.toggle(), self.action_underline.trigger() return return QTextEdit.keyPressEvent(self, ev) def update_clipboard_actions(self, copy_available): self.action_copy.setEnabled(copy_available) self.action_cut.setEnabled(copy_available) def update_selection_based_actions(self): pass def update_cursor_position_actions(self): c = self.textCursor() ls = c.currentList() self.action_ordered_list.setChecked(ls is not None and ls.format().style() == QTextListFormat.Style.ListDecimal) self.action_unordered_list.setChecked(ls is not None and ls.format().style() == QTextListFormat.Style.ListDisc) tcf = c.charFormat() vert = tcf.verticalAlignment() self.action_superscript.setChecked(vert == QTextCharFormat.VerticalAlignment.AlignSuperScript) self.action_subscript.setChecked(vert == QTextCharFormat.VerticalAlignment.AlignSubScript) self.action_bold.setChecked(tcf.fontWeight() == QFont.Weight.Bold) self.action_italic.setChecked(tcf.fontItalic()) self.action_underline.setChecked(tcf.fontUnderline()) self.action_strikethrough.setChecked(tcf.fontStrikeOut()) bf = c.blockFormat() a = bf.alignment() self.action_align_left.setChecked(a == Qt.AlignmentFlag.AlignLeft) self.action_align_right.setChecked(a == Qt.AlignmentFlag.AlignRight) self.action_align_center.setChecked(a == Qt.AlignmentFlag.AlignHCenter) self.action_align_justified.setChecked(a == Qt.AlignmentFlag.AlignJustify) lvl = bf.headingLevel() name = 'p' if lvl == 0: if bf.leftMargin() == bf.rightMargin() and bf.leftMargin() > 0: name = 'blockquote' else: name = 'h{}'.format(lvl) for ac in self.block_style_actions: ac.setChecked(ac.block_name == name) def set_readonly(self, what): self.readonly = what def focus_self(self): self.setFocus(Qt.FocusReason.TabFocusReason) def do_clear(self, *args): c = self.textCursor() c.beginEditBlock() c.movePosition(QTextCursor.MoveOperation.Start, QTextCursor.MoveMode.MoveAnchor) c.movePosition(QTextCursor.MoveOperation.End, QTextCursor.MoveMode.KeepAnchor) c.removeSelectedText() c.endEditBlock() self.focus_self() clear_text = do_clear def do_bold(self): with self.editing_cursor() as c: fmt = QTextCharFormat() fmt.setFontWeight( QFont.Weight.Bold if c.charFormat().fontWeight() != QFont.Weight.Bold else QFont.Weight.Normal) c.mergeCharFormat(fmt) def do_italic(self): with self.editing_cursor() as c: fmt = QTextCharFormat() fmt.setFontItalic(not c.charFormat().fontItalic()) c.mergeCharFormat(fmt) def do_underline(self): with self.editing_cursor() as c: fmt = QTextCharFormat() fmt.setFontUnderline(not c.charFormat().fontUnderline()) c.mergeCharFormat(fmt) def do_strikethrough(self): with self.editing_cursor() as c: fmt = QTextCharFormat() fmt.setFontStrikeOut(not c.charFormat().fontStrikeOut()) c.mergeCharFormat(fmt) def do_vertical_align(self, which): with self.editing_cursor() as c: fmt = QTextCharFormat() fmt.setVerticalAlignment(which) c.mergeCharFormat(fmt) def do_superscript(self): self.do_vertical_align(QTextCharFormat.VerticalAlignment.AlignSuperScript) def do_subscript(self): self.do_vertical_align(QTextCharFormat.VerticalAlignment.AlignSubScript) def do_list(self, fmt): with self.editing_cursor() as c: ls = c.currentList() if ls is not None: lf = ls.format() if lf.style() == fmt: c.setBlockFormat(QTextBlockFormat()) else: lf.setStyle(fmt) ls.setFormat(lf) else: ls = c.createList(fmt) def do_ordered_list(self): self.do_list(QTextListFormat.Style.ListDecimal) def do_unordered_list(self): self.do_list(QTextListFormat.Style.ListDisc) def do_alignment(self, which): with self.editing_cursor() as c: c = self.textCursor() fmt = QTextBlockFormat() fmt.setAlignment(which) c.mergeBlockFormat(fmt) def do_align_left(self): self.do_alignment(Qt.AlignmentFlag.AlignLeft) def do_align_center(self): self.do_alignment(Qt.AlignmentFlag.AlignHCenter) def do_align_right(self): self.do_alignment(Qt.AlignmentFlag.AlignRight) def do_align_justified(self): self.do_alignment(Qt.AlignmentFlag.AlignJustify) def do_undo(self): self.undo() self.focus_self() def do_redo(self): self.redo() self.focus_self() def do_remove_format(self): with self.editing_cursor() as c: c.setBlockFormat(QTextBlockFormat()) c.setCharFormat(QTextCharFormat()) def do_copy(self): self.copy() self.focus_self() def do_paste(self): self.paste() self.focus_self() def do_paste_and_match_style(self): text = QApplication.instance().clipboard().text() if text: self.setText(text) def do_cut(self): self.cut() self.focus_self() def indent_block(self, mult=1): with self.editing_cursor() as c: bf = c.blockFormat() bf.setTextIndent(bf.textIndent() + 2 * self.em_size * mult) c.setBlockFormat(bf) def do_indent(self): self.indent_block() def do_outdent(self): self.indent_block(-1) def do_select_all(self): with self.editing_cursor() as c: c.movePosition(QTextCursor.MoveOperation.Start, QTextCursor.MoveMode.MoveAnchor) c.movePosition(QTextCursor.MoveOperation.End, QTextCursor.MoveMode.KeepAnchor) def level_for_block_type(self, name): if name == 'blockquote': return 0 return {q: i for i, q in enumerate('p h1 h2 h3 h4 h5 h6'.split())}[name] def do_format_block(self): name = self.sender().block_name with self.editing_cursor() as c: bf = QTextBlockFormat() cf = QTextCharFormat() bcf = c.blockCharFormat() lvl = self.level_for_block_type(name) wt = QFont.Weight.Bold if lvl else None adjust = (0, 3, 2, 1, 0, -1, -1)[lvl] pos = None if not c.hasSelection(): pos = c.position() c.movePosition(QTextCursor.MoveOperation.StartOfBlock, QTextCursor.MoveMode.MoveAnchor) c.movePosition(QTextCursor.MoveOperation.EndOfBlock, QTextCursor.MoveMode.KeepAnchor) # margin values are taken from qtexthtmlparser.cpp hmargin = 0 if name == 'blockquote': hmargin = 40 tmargin = bmargin = 12 if name == 'h1': tmargin, bmargin = 18, 12 elif name == 'h2': tmargin, bmargin = 16, 12 elif name == 'h3': tmargin, bmargin = 14, 12 elif name == 'h4': tmargin, bmargin = 12, 12 elif name == 'h5': tmargin, bmargin = 12, 4 bf.setLeftMargin(hmargin), bf.setRightMargin(hmargin) bf.setTopMargin(tmargin), bf.setBottomMargin(bmargin) bf.setHeadingLevel(lvl) if adjust: bcf.setProperty(QTextFormat.Property.FontSizeAdjustment, adjust) cf.setProperty(QTextFormat.Property.FontSizeAdjustment, adjust) if wt: bcf.setProperty(QTextFormat.Property.FontWeight, wt) cf.setProperty(QTextFormat.Property.FontWeight, wt) c.setBlockCharFormat(bcf) c.mergeCharFormat(cf) c.mergeBlockFormat(bf) if pos is not None: c.setPosition(pos) def do_color(self): col = QColorDialog.getColor(Qt.GlobalColor.black, self, _('Choose foreground color'), QColorDialog.ColorDialogOption.ShowAlphaChannel) if col.isValid(): fmt = QTextCharFormat() fmt.setForeground(QBrush(col)) with self.editing_cursor() as c: c.mergeCharFormat(fmt) def do_background(self): col = QColorDialog.getColor(Qt.GlobalColor.white, self, _('Choose background color'), QColorDialog.ColorDialogOption.ShowAlphaChannel) if col.isValid(): fmt = QTextCharFormat() fmt.setBackground(QBrush(col)) with self.editing_cursor() as c: c.mergeCharFormat(fmt) def do_insert_hr(self, *args): with self.editing_cursor() as c: c.movePosition(QTextCursor.MoveOperation.EndOfBlock, QTextCursor.MoveMode.MoveAnchor) c.insertHtml('<hr>') def do_insert_link(self, *args): link, name, is_image = self.ask_link() if not link: return url = self.parse_link(link) if url.isValid(): url = unicode_type(url.toString(NO_URL_FORMATTING)) self.focus_self() with self.editing_cursor() as c: if is_image: c.insertImage(url) else: oldfmt = QTextCharFormat(c.charFormat()) fmt = QTextCharFormat() fmt.setAnchor(True) fmt.setAnchorHref(url) fmt.setForeground(QBrush(self.palette().color(QPalette.ColorRole.Link))) if name or not c.hasSelection(): c.mergeCharFormat(fmt) c.insertText(name or url) else: pos, anchor = c.position(), c.anchor() start, end = min(pos, anchor), max(pos, anchor) for i in range(start, end): cur = self.textCursor() cur.setPosition(i), cur.setPosition(i + 1, QTextCursor.MoveMode.KeepAnchor) cur.mergeCharFormat(fmt) c.setPosition(c.position()) c.setCharFormat(oldfmt) else: error_dialog(self, _('Invalid URL'), _('The url %r is invalid') % link, show=True) def ask_link(self): class Ask(QDialog): def accept(self): if self.treat_as_image.isChecked(): url = self.url.text() if url.lower().split(':', 1)[0] in ('http', 'https'): error_dialog(self, _('Remote images not supported'), _( 'You must download the image to your computer, URLs pointing' ' to remote images are not supported.'), show=True) return QDialog.accept(self) d = Ask(self) d.setWindowTitle(_('Create link')) l = QFormLayout() l.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow) d.setLayout(l) d.url = QLineEdit(d) d.name = QLineEdit(d) d.treat_as_image = QCheckBox(d) d.setMinimumWidth(600) d.bb = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok|QDialogButtonBox.StandardButton.Cancel) d.br = b = QPushButton(_('&Browse')) b.setIcon(QIcon(I('document_open.png'))) def cf(): filetypes = [] if d.treat_as_image.isChecked(): filetypes = [(_('Images'), 'png jpeg jpg gif'.split())] files = choose_files(d, 'select link file', _('Choose file'), filetypes, select_only_single_file=True) if files: path = files[0] d.url.setText(path) if path and os.path.exists(path): with lopen(path, 'rb') as f: q = what(f) is_image = q in {'jpeg', 'png', 'gif'} d.treat_as_image.setChecked(is_image) b.clicked.connect(cf) d.la = la = QLabel(_( 'Enter a URL. If you check the "Treat the URL as an image" box ' 'then the URL will be added as an image reference instead of as ' 'a link. You can also choose to create a link to a file on ' 'your computer. ' 'Note that if you create a link to a file on your computer, it ' 'will stop working if the file is moved.')) la.setWordWrap(True) la.setStyleSheet('QLabel { margin-bottom: 1.5ex }') l.setWidget(0, QFormLayout.ItemRole.SpanningRole, la) l.addRow(_('Enter &URL:'), d.url) l.addRow(_('Treat the URL as an &image'), d.treat_as_image) l.addRow(_('Enter &name (optional):'), d.name) l.addRow(_('Choose a file on your computer:'), d.br) l.addRow(d.bb) d.bb.accepted.connect(d.accept) d.bb.rejected.connect(d.reject) d.resize(d.sizeHint()) link, name, is_image = None, None, False if d.exec_() == QDialog.DialogCode.Accepted: link, name = unicode_type(d.url.text()).strip(), unicode_type(d.name.text()).strip() is_image = d.treat_as_image.isChecked() return link, name, is_image def parse_link(self, link): link = link.strip() if link and os.path.exists(link): return QUrl.fromLocalFile(link) has_schema = re.match(r'^[a-zA-Z]+:', link) if has_schema is not None: url = QUrl(link, QUrl.ParsingMode.TolerantMode) if url.isValid(): return url if os.path.exists(link): return QUrl.fromLocalFile(link) if has_schema is None: first, _, rest = link.partition('.') prefix = 'http' if first == 'ftp': prefix = 'ftp' url = QUrl(prefix +'://'+link, QUrl.ParsingMode.TolerantMode) if url.isValid(): return url return QUrl(link, QUrl.ParsingMode.TolerantMode) def sizeHint(self): return QSize(150, 150) @property def html(self): raw = original_html = self.toHtml() check = self.toPlainText().strip() raw = xml_to_unicode(raw, strip_encoding_pats=True, resolve_entities=True)[0] raw = self.comments_pat.sub('', raw) if not check and '<img' not in raw.lower(): return '' root = parse(raw, maybe_xhtml=False, sanitize_names=True) if root.xpath('//meta[@name="calibre-dont-sanitize"]'): # Bypass cleanup if special meta tag exists return original_html try: cleanup_qt_markup(root) except Exception: import traceback traceback.print_exc() elems = [] for body in root.xpath('//body'): if body.text: elems.append(body.text) elems += [html.tostring(x, encoding='unicode') for x in body if x.tag not in ('script', 'style')] if len(elems) > 1: ans = '<div>%s</div>'%(u''.join(elems)) else: ans = ''.join(elems) if not ans.startswith('<'): ans = '<p>%s</p>'%ans return xml_replace_entities(ans) @html.setter def html(self, val): self.setHtml(val) def set_base_url(self, qurl): self.base_url = qurl @pyqtSlot(int, 'QUrl', result='QVariant') def loadResource(self, rtype, qurl): if self.base_url: if qurl.isRelative(): qurl = self.base_url.resolved(qurl) if qurl.isLocalFile(): path = qurl.toLocalFile() try: with lopen(path, 'rb') as f: data = f.read() except EnvironmentError: if path.rpartition('.')[-1].lower() in {'jpg', 'jpeg', 'gif', 'png', 'bmp', 'webp'}: return QByteArray(bytearray.fromhex( '89504e470d0a1a0a0000000d49484452' '000000010000000108060000001f15c4' '890000000a49444154789c6300010000' '0500010d0a2db40000000049454e44ae' '426082')) else: return QByteArray(data) def set_html(self, val, allow_undo=True): if not allow_undo or self.readonly: self.html = val return with self.editing_cursor() as c: c.movePosition(QTextCursor.MoveOperation.Start, QTextCursor.MoveMode.MoveAnchor) c.movePosition(QTextCursor.MoveOperation.End, QTextCursor.MoveMode.KeepAnchor) c.removeSelectedText() c.insertHtml(val) def text(self): return self.textCursor().selectedText() def setText(self, text): with self.editing_cursor() as c: c.insertText(text) def contextMenuEvent(self, ev): menu = self.createStandardContextMenu() for action in menu.actions(): parts = action.text().split('\t') if len(parts) == 2 and QKeySequence(QKeySequence.StandardKey.Paste).toString(QKeySequence.SequenceFormat.NativeText) in parts[-1]: menu.insertAction(action, self.action_paste_and_match_style) break else: menu.addAction(self.action_paste_and_match_style) st = self.text() m = QMenu(_('Fonts')) m.addAction(self.action_bold), m.addAction(self.action_italic), m.addAction(self.action_underline) menu.addMenu(m) if st and st.strip(): self.create_change_case_menu(menu) parent = self._parent() if hasattr(parent, 'toolbars_visible'): vis = parent.toolbars_visible menu.addAction(_('%s toolbars') % (_('Hide') if vis else _('Show')), parent.toggle_toolbars) menu.addSeparator() am = QMenu(_('Advanced')) menu.addMenu(am) am.addAction(self.action_block_style) am.addAction(self.action_insert_link) am.addAction(self.action_background) am.addAction(self.action_color) menu.addAction(_('Smarten punctuation'), parent.smarten_punctuation) menu.exec_(ev.globalPos())
class PluginUpdaterDialog(SizePersistedDialog): initial_extra_size = QSize(350, 100) forum_label_text = _('Plugin homepage') def __init__(self, gui, initial_filter=FILTER_UPDATE_AVAILABLE): SizePersistedDialog.__init__( self, gui, 'Plugin Updater plugin:plugin updater dialog') self.gui = gui self.forum_link = None self.zip_url = None self.model = None self.do_restart = False self._initialize_controls() self._create_context_menu() try: display_plugins = read_available_plugins(raise_error=True) except Exception: display_plugins = [] import traceback error_dialog(self.gui, _('Update Check Failed'), _('Unable to reach the plugin index page.'), det_msg=traceback.format_exc(), show=True) if display_plugins: self.model = DisplayPluginModel(display_plugins) self.proxy_model = DisplayPluginSortFilterModel(self) self.proxy_model.setSourceModel(self.model) self.plugin_view.setModel(self.proxy_model) self.plugin_view.resizeColumnsToContents() self.plugin_view.selectionModel().currentRowChanged.connect( self._plugin_current_changed) self.plugin_view.doubleClicked.connect(self.install_button.click) self.filter_combo.setCurrentIndex(initial_filter) self._select_and_focus_view() else: self.filter_combo.setEnabled(False) # Cause our dialog size to be restored from prefs or created on first usage self.resize_dialog() def _initialize_controls(self): self.setWindowTitle(_('User plugins')) self.setWindowIcon(QIcon(I('plugins/plugin_updater.png'))) layout = QVBoxLayout(self) self.setLayout(layout) title_layout = ImageTitleLayout(self, 'plugins/plugin_updater.png', _('User plugins')) layout.addLayout(title_layout) header_layout = QHBoxLayout() layout.addLayout(header_layout) self.filter_combo = PluginFilterComboBox(self) self.filter_combo.setMinimumContentsLength(20) self.filter_combo.currentIndexChanged[int].connect( self._filter_combo_changed) la = QLabel(_('Filter list of &plugins') + ':', self) la.setBuddy(self.filter_combo) header_layout.addWidget(la) header_layout.addWidget(self.filter_combo) header_layout.addStretch(10) # filter plugins by name la = QLabel(_('Filter by &name') + ':', self) header_layout.addWidget(la) self.filter_by_name_lineedit = QLineEdit(self) la.setBuddy(self.filter_by_name_lineedit) self.filter_by_name_lineedit.setText("") self.filter_by_name_lineedit.textChanged.connect( self._filter_name_lineedit_changed) header_layout.addWidget(self.filter_by_name_lineedit) self.plugin_view = QTableView(self) self.plugin_view.horizontalHeader().setStretchLastSection(True) self.plugin_view.setSelectionBehavior( QAbstractItemView.SelectionBehavior.SelectRows) self.plugin_view.setSelectionMode( QAbstractItemView.SelectionMode.SingleSelection) self.plugin_view.setAlternatingRowColors(True) self.plugin_view.setSortingEnabled(True) self.plugin_view.setIconSize(QSize(28, 28)) layout.addWidget(self.plugin_view) details_layout = QHBoxLayout() layout.addLayout(details_layout) forum_label = self.forum_label = QLabel('') forum_label.setTextInteractionFlags( Qt.TextInteractionFlag.LinksAccessibleByMouse | Qt.TextInteractionFlag.LinksAccessibleByKeyboard) forum_label.linkActivated.connect(self._forum_label_activated) details_layout.addWidget(QLabel(_('Description') + ':', self), 0, Qt.AlignmentFlag.AlignLeft) details_layout.addWidget(forum_label, 1, Qt.AlignmentFlag.AlignRight) self.description = QLabel(self) self.description.setFrameStyle(QFrame.Shape.Panel | QFrame.Shadow.Sunken) self.description.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft) self.description.setMinimumHeight(40) self.description.setWordWrap(True) layout.addWidget(self.description) self.button_box = QDialogButtonBox( QDialogButtonBox.StandardButton.Close) self.button_box.rejected.connect(self.reject) self.finished.connect(self._finished) self.install_button = self.button_box.addButton( _('&Install'), QDialogButtonBox.ButtonRole.AcceptRole) self.install_button.setToolTip(_('Install the selected plugin')) self.install_button.clicked.connect(self._install_clicked) self.install_button.setEnabled(False) self.configure_button = self.button_box.addButton( ' ' + _('&Customize plugin ') + ' ', QDialogButtonBox.ButtonRole.ResetRole) self.configure_button.setToolTip( _('Customize the options for this plugin')) self.configure_button.clicked.connect(self._configure_clicked) self.configure_button.setEnabled(False) layout.addWidget(self.button_box) def update_forum_label(self): txt = '' if self.forum_link: txt = '<a href="%s">%s</a>' % (self.forum_link, self.forum_label_text) self.forum_label.setText(txt) def _create_context_menu(self): self.plugin_view.setContextMenuPolicy( Qt.ContextMenuPolicy.ActionsContextMenu) self.install_action = QAction( QIcon(I('plugins/plugin_upgrade_ok.png')), _('&Install'), self) self.install_action.setToolTip(_('Install the selected plugin')) self.install_action.triggered.connect(self._install_clicked) self.install_action.setEnabled(False) self.plugin_view.addAction(self.install_action) self.forum_action = QAction(QIcon(I('plugins/mobileread.png')), _('Plugin &forum thread'), self) self.forum_action.triggered.connect(self._forum_label_activated) self.forum_action.setEnabled(False) self.plugin_view.addAction(self.forum_action) sep1 = QAction(self) sep1.setSeparator(True) self.plugin_view.addAction(sep1) self.toggle_enabled_action = QAction(_('Enable/&disable plugin'), self) self.toggle_enabled_action.setToolTip( _('Enable or disable this plugin')) self.toggle_enabled_action.triggered.connect( self._toggle_enabled_clicked) self.toggle_enabled_action.setEnabled(False) self.plugin_view.addAction(self.toggle_enabled_action) self.uninstall_action = QAction(_('&Remove plugin'), self) self.uninstall_action.setToolTip(_('Uninstall the selected plugin')) self.uninstall_action.triggered.connect(self._uninstall_clicked) self.uninstall_action.setEnabled(False) self.plugin_view.addAction(self.uninstall_action) sep2 = QAction(self) sep2.setSeparator(True) self.plugin_view.addAction(sep2) self.donate_enabled_action = QAction(QIcon(I('donate.png')), _('Donate to developer'), self) self.donate_enabled_action.setToolTip( _('Donate to the developer of this plugin')) self.donate_enabled_action.triggered.connect(self._donate_clicked) self.donate_enabled_action.setEnabled(False) self.plugin_view.addAction(self.donate_enabled_action) sep3 = QAction(self) sep3.setSeparator(True) self.plugin_view.addAction(sep3) self.configure_action = QAction(QIcon(I('config.png')), _('&Customize plugin'), self) self.configure_action.setToolTip( _('Customize the options for this plugin')) self.configure_action.triggered.connect(self._configure_clicked) self.configure_action.setEnabled(False) self.plugin_view.addAction(self.configure_action) def _finished(self, *args): if self.model: update_plugins = list( filter(filter_upgradeable_plugins, self.model.display_plugins)) self.gui.recalc_update_label(len(update_plugins)) def _plugin_current_changed(self, current, previous): if current.isValid(): actual_idx = self.proxy_model.mapToSource(current) display_plugin = self.model.display_plugins[actual_idx.row()] self.description.setText(display_plugin.description) self.forum_link = display_plugin.forum_link self.zip_url = display_plugin.zip_url self.forum_action.setEnabled(bool(self.forum_link)) self.install_button.setEnabled( display_plugin.is_valid_to_install()) self.install_action.setEnabled(self.install_button.isEnabled()) self.uninstall_action.setEnabled(display_plugin.is_installed()) self.configure_button.setEnabled(display_plugin.is_installed()) self.configure_action.setEnabled(self.configure_button.isEnabled()) self.toggle_enabled_action.setEnabled( display_plugin.is_installed()) self.donate_enabled_action.setEnabled( bool(display_plugin.donation_link)) else: self.description.setText('') self.forum_link = None self.zip_url = None self.forum_action.setEnabled(False) self.install_button.setEnabled(False) self.install_action.setEnabled(False) self.uninstall_action.setEnabled(False) self.configure_button.setEnabled(False) self.configure_action.setEnabled(False) self.toggle_enabled_action.setEnabled(False) self.donate_enabled_action.setEnabled(False) self.update_forum_label() def _donate_clicked(self): plugin = self._selected_display_plugin() if plugin and plugin.donation_link: open_url(QUrl(plugin.donation_link)) def _select_and_focus_view(self, change_selection=True): if change_selection and self.plugin_view.model().rowCount() > 0: self.plugin_view.selectRow(0) else: idx = self.plugin_view.selectionModel().currentIndex() self._plugin_current_changed(idx, 0) self.plugin_view.setFocus() def _filter_combo_changed(self, idx): self.filter_by_name_lineedit.setText( "" ) # clear the name filter text when a different group was selected self.proxy_model.set_filter_criteria(idx) if idx == FILTER_NOT_INSTALLED: self.plugin_view.sortByColumn(5, Qt.SortOrder.DescendingOrder) else: self.plugin_view.sortByColumn(0, Qt.SortOrder.AscendingOrder) self._select_and_focus_view() def _filter_name_lineedit_changed(self, text): self.proxy_model.set_filter_text( text) # set the filter text for filterAcceptsRow def _forum_label_activated(self): if self.forum_link: open_url(QUrl(self.forum_link)) def _selected_display_plugin(self): idx = self.plugin_view.selectionModel().currentIndex() actual_idx = self.proxy_model.mapToSource(idx) return self.model.display_plugins[actual_idx.row()] def _uninstall_plugin(self, name_to_remove): if DEBUG: prints('Removing plugin: ', name_to_remove) remove_plugin(name_to_remove) # Make sure that any other plugins that required this plugin # to be uninstalled first have the requirement removed for display_plugin in self.model.display_plugins: # Make sure we update the status and display of the # plugin we just uninstalled if name_to_remove in display_plugin.uninstall_plugins: if DEBUG: prints('Removing uninstall dependency for: ', display_plugin.name) display_plugin.uninstall_plugins.remove(name_to_remove) if display_plugin.qname == name_to_remove: if DEBUG: prints('Resetting plugin to uninstalled status: ', display_plugin.name) display_plugin.installed_version = None display_plugin.plugin = None display_plugin.uninstall_plugins = [] if self.proxy_model.filter_criteria not in [ FILTER_INSTALLED, FILTER_UPDATE_AVAILABLE ]: self.model.refresh_plugin(display_plugin) def _uninstall_clicked(self): display_plugin = self._selected_display_plugin() if not question_dialog( self, _('Are you sure?'), '<p>' + _('Are you sure you want to uninstall the <b>%s</b> plugin?') % display_plugin.name, show_copy_button=False): return self._uninstall_plugin(display_plugin.qname) if self.proxy_model.filter_criteria in [ FILTER_INSTALLED, FILTER_UPDATE_AVAILABLE ]: self.model.beginResetModel(), self.model.endResetModel() self._select_and_focus_view() else: self._select_and_focus_view(change_selection=False) def _install_clicked(self): display_plugin = self._selected_display_plugin() if not question_dialog( self, _('Install %s') % display_plugin.name, '<p>' + _('Installing plugins is a <b>security risk</b>. ' 'Plugins can contain a virus/malware. ' 'Only install it if you got it from a trusted source.' ' Are you sure you want to proceed?'), show_copy_button=False): return if display_plugin.uninstall_plugins: uninstall_names = list(display_plugin.uninstall_plugins) if DEBUG: prints('Uninstalling plugin: ', ', '.join(uninstall_names)) for name_to_remove in uninstall_names: self._uninstall_plugin(name_to_remove) plugin_zip_url = display_plugin.zip_url if DEBUG: prints('Downloading plugin ZIP attachment: ', plugin_zip_url) self.gui.status_bar.showMessage( _('Downloading plugin ZIP attachment: %s') % plugin_zip_url) zip_path = self._download_zip(plugin_zip_url) if DEBUG: prints('Installing plugin: ', zip_path) self.gui.status_bar.showMessage(_('Installing plugin: %s') % zip_path) do_restart = False try: from calibre.customize.ui import config installed_plugins = frozenset(config['plugins']) try: plugin = add_plugin(zip_path) except NameConflict as e: return error_dialog(self.gui, _('Already exists'), unicode_type(e), show=True) # Check for any toolbars to add to. widget = ConfigWidget(self.gui) widget.gui = self.gui widget.check_for_add_to_toolbars(plugin, previously_installed=plugin.name in installed_plugins) self.gui.status_bar.showMessage( _('Plugin installed: %s') % display_plugin.name) d = info_dialog( self.gui, _('Success'), _('Plugin <b>{0}</b> successfully installed under <b>' '{1}</b>. You may have to restart calibre ' 'for the plugin to take effect.').format( plugin.name, plugin.type), show_copy_button=False) b = d.bb.addButton(_('&Restart calibre now'), QDialogButtonBox.ButtonRole.AcceptRole) b.setIcon(QIcon(I('lt.png'))) d.do_restart = False def rf(): d.do_restart = True b.clicked.connect(rf) d.set_details('') d.exec_() b.clicked.disconnect() do_restart = d.do_restart display_plugin.plugin = plugin # We cannot read the 'actual' version information as the plugin will not be loaded yet display_plugin.installed_version = display_plugin.available_version except: if DEBUG: prints('ERROR occurred while installing plugin: %s' % display_plugin.name) traceback.print_exc() error_dialog( self.gui, _('Install plugin failed'), _('A problem occurred while installing this plugin.' ' This plugin will now be uninstalled.' ' Please post the error message in details below into' ' the forum thread for this plugin and restart calibre.'), det_msg=traceback.format_exc(), show=True) if DEBUG: prints('Due to error now uninstalling plugin: %s' % display_plugin.name) remove_plugin(display_plugin.name) display_plugin.plugin = None display_plugin.uninstall_plugins = [] if self.proxy_model.filter_criteria in [ FILTER_NOT_INSTALLED, FILTER_UPDATE_AVAILABLE ]: self.model.beginResetModel(), self.model.endResetModel() self._select_and_focus_view() else: self.model.refresh_plugin(display_plugin) self._select_and_focus_view(change_selection=False) if do_restart: self.do_restart = True self.accept() def _configure_clicked(self): display_plugin = self._selected_display_plugin() plugin = display_plugin.plugin if not plugin.is_customizable(): return info_dialog(self, _('Plugin not customizable'), _('Plugin: %s does not need customization') % plugin.name, show=True) from calibre.customize import InterfaceActionBase if isinstance(plugin, InterfaceActionBase) and not getattr( plugin, 'actual_iaction_plugin_loaded', False): return error_dialog(self, _('Must restart'), _('You must restart calibre before you can' ' configure the <b>%s</b> plugin') % plugin.name, show=True) plugin.do_user_config(self.parent()) def _toggle_enabled_clicked(self): display_plugin = self._selected_display_plugin() plugin = display_plugin.plugin if not plugin.can_be_disabled: return error_dialog(self, _('Plugin cannot be disabled'), _('The plugin: %s cannot be disabled') % plugin.name, show=True) if is_disabled(plugin): enable_plugin(plugin) else: disable_plugin(plugin) self.model.refresh_plugin(display_plugin) def _download_zip(self, plugin_zip_url): from calibre.ptempfile import PersistentTemporaryFile raw = get_https_resource_securely( plugin_zip_url, headers={'User-Agent': '%s %s' % (__appname__, __version__)}) with PersistentTemporaryFile('.zip') as pt: pt.write(raw) return pt.name