diff --git a/resources/images/plugins/mobileread.png b/resources/images/plugins/mobileread.png new file mode 100644 index 0000000000..ec04625351 Binary files /dev/null and b/resources/images/plugins/mobileread.png differ diff --git a/resources/images/plugins/plugin_deprecated.png b/resources/images/plugins/plugin_deprecated.png new file mode 100644 index 0000000000..bf5e3e2d21 Binary files /dev/null and b/resources/images/plugins/plugin_deprecated.png differ diff --git a/resources/images/plugins/plugin_disabled_invalid.png b/resources/images/plugins/plugin_disabled_invalid.png new file mode 100644 index 0000000000..f905703564 Binary files /dev/null and b/resources/images/plugins/plugin_disabled_invalid.png differ diff --git a/resources/images/plugins/plugin_disabled_ok.png b/resources/images/plugins/plugin_disabled_ok.png new file mode 100644 index 0000000000..0ae52697cd Binary files /dev/null and b/resources/images/plugins/plugin_disabled_ok.png differ diff --git a/resources/images/plugins/plugin_disabled_valid.png b/resources/images/plugins/plugin_disabled_valid.png new file mode 100644 index 0000000000..f3c7ea5fb9 Binary files /dev/null and b/resources/images/plugins/plugin_disabled_valid.png differ diff --git a/resources/images/plugins/plugin_new.png b/resources/images/plugins/plugin_new.png new file mode 100644 index 0000000000..cf76fd0f67 Binary files /dev/null and b/resources/images/plugins/plugin_new.png differ diff --git a/resources/images/plugins/plugin_new_invalid.png b/resources/images/plugins/plugin_new_invalid.png new file mode 100644 index 0000000000..ac9143b2b2 Binary files /dev/null and b/resources/images/plugins/plugin_new_invalid.png differ diff --git a/resources/images/plugins/plugin_new_valid.png b/resources/images/plugins/plugin_new_valid.png new file mode 100644 index 0000000000..0dfea2852e Binary files /dev/null and b/resources/images/plugins/plugin_new_valid.png differ diff --git a/resources/images/plugins/plugin_updater.png b/resources/images/plugins/plugin_updater.png new file mode 100644 index 0000000000..bbd541dc03 Binary files /dev/null and b/resources/images/plugins/plugin_updater.png differ diff --git a/resources/images/plugins/plugin_updater_updates.png b/resources/images/plugins/plugin_updater_updates.png new file mode 100644 index 0000000000..f9cb8456ce Binary files /dev/null and b/resources/images/plugins/plugin_updater_updates.png differ diff --git a/resources/images/plugins/plugin_upgrade_invalid.png b/resources/images/plugins/plugin_upgrade_invalid.png new file mode 100644 index 0000000000..e19963ce62 Binary files /dev/null and b/resources/images/plugins/plugin_upgrade_invalid.png differ diff --git a/resources/images/plugins/plugin_upgrade_ok.png b/resources/images/plugins/plugin_upgrade_ok.png new file mode 100644 index 0000000000..7cd2e89127 Binary files /dev/null and b/resources/images/plugins/plugin_upgrade_ok.png differ diff --git a/resources/images/plugins/plugin_upgrade_valid.png b/resources/images/plugins/plugin_upgrade_valid.png new file mode 100644 index 0000000000..5a0b79b11a Binary files /dev/null and b/resources/images/plugins/plugin_upgrade_valid.png differ diff --git a/src/calibre/customize/builtins.py b/src/calibre/customize/builtins.py index bdac284af2..c281405eee 100644 --- a/src/calibre/customize/builtins.py +++ b/src/calibre/customize/builtins.py @@ -867,13 +867,20 @@ class ActionStore(InterfaceActionBase): from calibre.gui2.store.config.store import save_settings as save save(config_widget) +class ActionPluginUpdates(InterfaceActionBase): + name = 'Plugin Updates' + author = 'Grant Drake' + description = 'Queries the MobileRead forums for updates to plugins to install' + actual_plugin = 'calibre.gui2.actions.plugin_updates:PluginUpdatesAction' + plugins += [ActionAdd, ActionFetchAnnotations, ActionGenerateCatalog, ActionConvert, ActionDelete, ActionEditMetadata, ActionView, ActionFetchNews, ActionSaveToDisk, ActionShowBookDetails, ActionRestart, ActionOpenFolder, ActionConnectShare, ActionSendToDevice, ActionHelp, ActionPreferences, ActionSimilarBooks, ActionAddToLibrary, ActionEditCollections, ActionChooseLibrary, - ActionCopyToLibrary, ActionTweakEpub, ActionNextMatch, ActionStore] + ActionCopyToLibrary, ActionTweakEpub, ActionNextMatch, ActionStore, + ActionPluginUpdates] # }}} diff --git a/src/calibre/gui2/actions/plugin_updates.py b/src/calibre/gui2/actions/plugin_updates.py new file mode 100644 index 0000000000..e624361ad2 --- /dev/null +++ b/src/calibre/gui2/actions/plugin_updates.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python +# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai +from __future__ import (unicode_literals, division, absolute_import, + print_function) + +__license__ = 'GPL v3' +__copyright__ = '2011, Grant Drake ' +__docformat__ = 'restructuredtext en' + +from PyQt4.Qt import QApplication, Qt, QIcon +from calibre.gui2.actions import InterfaceAction +from calibre.gui2.dialogs.plugin_updater import (PluginUpdaterDialog, + FILTER_ALL, FILTER_UPDATE_AVAILABLE) + +class PluginUpdatesAction(InterfaceAction): + + name = 'Plugin Updates' + action_spec = (_('Plugin Updates'), None, None, None) + action_type = 'current' + + def genesis(self): + self.qaction.setIcon(QIcon(I('plugins/plugin_updater.png'))) + self.qaction.triggered.connect(self.check_for_plugin_updates) + + def check_for_plugin_updates(self): + # Get the user to choose a plugin to install + initial_filter = FILTER_UPDATE_AVAILABLE + mods = QApplication.keyboardModifiers() + if mods & Qt.ControlModifier or mods & Qt.ShiftModifier: + initial_filter = FILTER_ALL + + d = PluginUpdaterDialog(self.gui, initial_filter=initial_filter) + d.exec_() diff --git a/src/calibre/gui2/dialogs/plugin_updater.py b/src/calibre/gui2/dialogs/plugin_updater.py new file mode 100644 index 0000000000..5e0da6aca2 --- /dev/null +++ b/src/calibre/gui2/dialogs/plugin_updater.py @@ -0,0 +1,866 @@ +#!/usr/bin/env python +# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai +from __future__ import (unicode_literals, division, absolute_import, + print_function) + +__license__ = 'GPL v3' +__copyright__ = '2011, Grant Drake ' +__docformat__ = 'restructuredtext en' + +import re, datetime, traceback +from lxml import html +from PyQt4.Qt import (Qt, QUrl, QFrame, QVBoxLayout, QLabel, QBrush, QTextEdit, + QComboBox, QAbstractItemView, QHBoxLayout, QDialogButtonBox, + QAbstractTableModel, QVariant, QTableView, QModelIndex, + QSortFilterProxyModel, pyqtSignal, QAction, QIcon, QDialog, + QFont, QPixmap, QSize) +from PyQt4 import QtCore +from calibre import browser, prints +from calibre.constants import numeric_version, iswindows, isosx, DEBUG +from calibre.customize.ui import (initialized_plugins, is_disabled, remove_plugin, + add_plugin, enable_plugin, disable_plugin, NameConflict) +from calibre.gui2 import error_dialog, question_dialog, info_dialog, NONE, open_url, gprefs +from calibre.gui2.preferences.plugins import ConfigWidget +from calibre.utils.date import UNDEFINED_DATE, format_date + + +MR_URL = 'http://www.mobileread.com/forums/' +MR_INDEX_URL = MR_URL + 'showpost.php?p=1362767&postcount=1' + +FILTER_ALL = 0 +FILTER_INSTALLED = 1 +FILTER_UPDATE_AVAILABLE = 2 +FILTER_NOT_INSTALLED = 3 + +def get_plugin_updates_available(): + ''' + API exposed to read whether there are updates available for any + of the installed user plugins. + Returns None if no updates found + Returns list(DisplayPlugin) of plugins installed that have a new version + ''' + display_plugins = read_available_plugins() + if display_plugins: + update_plugins = filter(filter_upgradeable_plugins, display_plugins) + if len(update_plugins) > 0: + return update_plugins + return None + +def filter_upgradeable_plugins(display_plugin): + return display_plugin.is_upgrade_available() + +def filter_not_installed_plugins(display_plugin): + return not display_plugin.is_installed() + +def read_available_plugins(): + display_plugins = [] + br = browser() + br.set_handle_gzip(True) + try: + raw = br.open_novisit(MR_INDEX_URL).read() + if not raw: + return + except: + traceback.print_exc() + return + raw = raw.decode('utf-8', errors='replace') + root = html.fromstring(raw) + list_nodes = root.xpath('//div[@id="post_message_1362767"]/ul/li') + # Add our deprecated plugins which are nested in a grey span + list_nodes.extend(root.xpath('//div[@id="post_message_1362767"]/span/ul/li')) + for list_node in list_nodes: + try: + display_plugin = DisplayPlugin(list_node) + get_installed_plugin_status(display_plugin) + display_plugins.append(display_plugin) + except: + if DEBUG: + prints('======= MobileRead Parse Error =======') + traceback.print_exc() + prints(html.tostring(list_node)) + display_plugins = sorted(display_plugins, key=lambda k: k.name) + return display_plugins + +def get_installed_plugin_status(display_plugin): + display_plugin.installed_version = None + display_plugin.plugin = None + for plugin in initialized_plugins(): + if plugin.name == display_plugin.name: + display_plugin.plugin = plugin + display_plugin.installed_version = plugin.version + break + if display_plugin.uninstall_plugins: + # Plugin requires a specific plugin name to be uninstalled first + # This could occur when a plugin is renamed (Kindle Collections) + # or multiple plugins deprecated into a newly named one. + # Check whether user has the previous version(s) installed + plugins_to_remove = list(display_plugin.uninstall_plugins) + for plugin_to_uninstall in plugins_to_remove: + found = False + for plugin in initialized_plugins(): + if plugin.name == plugin_to_uninstall: + found = True + break + if not found: + display_plugin.uninstall_plugins.remove(plugin_to_uninstall) + + +class ImageTitleLayout(QHBoxLayout): + ''' + A reusable layout widget displaying an image followed by a title + ''' + def __init__(self, parent, icon_name, title): + QHBoxLayout.__init__(self) + title_font = QFont() + title_font.setPointSize(16) + title_image_label = QLabel(parent) + pixmap = QPixmap() + pixmap.load(I(icon_name)) + if pixmap is None: + error_dialog(parent, _('Restart required'), + _('You must restart Calibre before using this plugin!'), show=True) + else: + title_image_label.setPixmap(pixmap) + title_image_label.setMaximumSize(32, 32) + title_image_label.setScaledContents(True) + self.addWidget(title_image_label) + shelf_label = QLabel(title, parent) + shelf_label.setFont(title_font) + self.addWidget(shelf_label) + self.insertStretch(-1) + + +class SizePersistedDialog(QDialog): + ''' + This dialog is a base class for any dialogs that want their size/position + restored when they are next opened. + ''' + + initial_extra_size = QSize(0, 0) + + def __init__(self, parent, unique_pref_name): + QDialog.__init__(self, parent) + self.unique_pref_name = unique_pref_name + self.geom = gprefs.get(unique_pref_name, None) + self.finished.connect(self.dialog_closing) + + def resize_dialog(self): + if self.geom is None: + self.resize(self.sizeHint()+self.initial_extra_size) + else: + self.restoreGeometry(self.geom) + + def dialog_closing(self, result): + geom = bytearray(self.saveGeometry()) + gprefs[self.unique_pref_name] = geom + + +class VersionHistoryDialog(SizePersistedDialog): + + def __init__(self, parent, plugin_name, html): + SizePersistedDialog.__init__(self, parent, 'Plugin Updater plugin:version history dialog') + self.setWindowTitle(_('Version History for %s')%plugin_name) + + layout = QVBoxLayout(self) + self.setLayout(layout) + + self.notes = QTextEdit(html, self) + self.notes.setReadOnly(True) + layout.addWidget(self.notes) + + self.button_box = QDialogButtonBox(QDialogButtonBox.Close) + self.button_box.rejected.connect(self.reject) + layout.addWidget(self.button_box) + + # Cause our dialog size to be restored from prefs or created on first usage + self.resize_dialog() + + +class PluginFilterComboBox(QComboBox): + def __init__(self, parent): + QComboBox.__init__(self, parent) + items = [_('All'), _('Installed'), _('Update available'), _('Not installed')] + self.addItems(items) + + +class DisplayPlugin(object): + + def __init__(self, list_node): + # The html from the index web page looks like this: + ''' +
  • Book Sync
    +Add books to a list to be automatically sent to your device the next time it is connected.
    +Version: 1.1; Released: 02-22-2011; Calibre: 0.7.42; Author: kiwidude;
    +Platforms: Windows, OSX, Linux; History: Yes;
  • + ''' + self.name = list_node.xpath('a')[0].text_content().strip() + self.forum_link = list_node.xpath('a/@href')[0].strip() + self.installed_version = None + + description_text = list_node.xpath('i')[0].text_content() + description_parts = description_text.partition('Version:') + self.description = description_parts[0].strip() + + details_text = description_parts[1] + description_parts[2].replace('\r\n','') + details_pairs = details_text.split(';') + details = {} + for details_pair in details_pairs: + pair = details_pair.split(':') + if len(pair) == 2: + key = pair[0].strip().lower() + value = pair[1].strip() + details[key] = value + + donation_node = list_node.xpath('i/span/a/@href') + self.donation_link = donation_node[0] if donation_node else None + + self.available_version = self._version_text_to_tuple(details.get('version', None)) + + release_date = details.get('released', '01-01-0101').split('-') + date_parts = [int(re.search(r'(\d+)', x).group(1)) for x in release_date] + self.release_date = datetime.date(date_parts[2], date_parts[0], date_parts[1]) + + self.calibre_required_version = self._version_text_to_tuple(details.get('calibre', None)) + self.author = details.get('author', '') + self.platforms = [p.strip().lower() for p in details.get('platforms', '').split(',')] + # Optional pairing just for plugins which require checking for uninstall first + self.uninstall_plugins = [] + uninstall = details.get('uninstall', None) + if uninstall: + self.uninstall_plugins = [i.strip() for i in uninstall.split(',')] + self.has_changelog = details.get('history', 'No').lower() in ['yes', 'true'] + self.is_deprecated = details.get('deprecated', 'No').lower() in ['yes', 'true'] + + def _version_text_to_tuple(self, version_text): + if version_text: + ver = version_text.split('.') + while len(ver) < 3: + ver.append('0') + ver = [int(re.search(r'(\d+)', x).group(1)) for x in ver] + return tuple(ver) + else: + return None + + def is_disabled(self): + if self.plugin is None: + return False + return is_disabled(self.plugin) + + def is_installed(self): + return self.installed_version is not None + + def is_upgrade_available(self): + return self.is_installed() and (self.installed_version < self.available_version \ + or self.is_deprecated) + + def is_valid_platform(self): + if iswindows: + return 'windows' in self.platforms + if isosx: + return 'osx' in self.platforms + return 'linux' in self.platforms + + def is_valid_calibre(self): + return numeric_version >= self.calibre_required_version + + def is_valid_to_install(self): + return self.is_valid_platform() and self.is_valid_calibre() and not self.is_deprecated + + +class DisplayPluginSortFilterModel(QSortFilterProxyModel): + + def __init__(self, parent): + QSortFilterProxyModel.__init__(self, parent) + self.setSortRole(Qt.UserRole) + self.filter_criteria = FILTER_ALL + + def filterAcceptsRow(self, sourceRow, sourceParent): + index = self.sourceModel().index(sourceRow, 0, sourceParent) + display_plugin = self.sourceModel().display_plugins[index.row()] + if self.filter_criteria == FILTER_ALL: + return not (display_plugin.is_deprecated and not display_plugin.is_installed()) + if self.filter_criteria == FILTER_INSTALLED: + return display_plugin.is_installed() + if self.filter_criteria == FILTER_UPDATE_AVAILABLE: + return display_plugin.is_upgrade_available() + if self.filter_criteria == FILTER_NOT_INSTALLED: + return not display_plugin.is_installed() and not display_plugin.is_deprecated + return False + + def set_filter_criteria(self, filter_value): + self.filter_criteria = filter_value + self.invalidateFilter() + + +class DisplayPluginModel(QAbstractTableModel): + + def __init__(self, display_plugins): + QAbstractTableModel.__init__(self) + self.display_plugins = display_plugins + self.headers = map(QVariant, [_('Plugin Name'), _('Donate'), _('Status'), _('Installed'), + _('Available'), _('Released'), _('Calibre'), _('Author')]) + + def rowCount(self, *args): + return len(self.display_plugins) + + def columnCount(self, *args): + return len(self.headers) + + def headerData(self, section, orientation, role): + if role == Qt.DisplayRole and orientation == Qt.Horizontal: + return self.headers[section] + return NONE + + def data(self, index, role): + if not index.isValid(): + return NONE; + row, col = index.row(), index.column() + if row < 0 or row >= self.rowCount(): + return NONE + display_plugin = self.display_plugins[row] + if role in [Qt.DisplayRole, Qt.UserRole]: + if col == 0: + return QVariant(display_plugin.name) + if col == 1: + if display_plugin.donation_link: + return QVariant(_('PayPal')) + if col == 2: + return self._get_status(display_plugin) + if col == 3: + return QVariant(self._get_display_version(display_plugin.installed_version)) + if col == 4: + return QVariant(self._get_display_version(display_plugin.available_version)) + if col == 5: + if role == Qt.UserRole: + return self._get_display_release_date(display_plugin.release_date, 'yyyyMMdd') + else: + return self._get_display_release_date(display_plugin.release_date) + if col == 6: + return QVariant(self._get_display_version(display_plugin.calibre_required_version)) + if col == 7: + return QVariant(display_plugin.author) + elif role == Qt.DecorationRole: + if col == 0: + return self._get_status_icon(display_plugin) + if col == 1: + if display_plugin.donation_link: + return QIcon(I('donate.png')) + elif role == Qt.ToolTipRole: + if col == 1 and display_plugin.donation_link: + return QVariant(_('This plugin is FREE but you can reward the developer for their effort\n' + 'by donating to them via PayPal.\n\n' + 'Right-click and choose Donate to reward: ')+display_plugin.author) + else: + return self._get_status_tooltip(display_plugin) + elif role == Qt.ForegroundRole: + if col != 1: # Never change colour of the donation column + if display_plugin.is_deprecated: + return QVariant(QBrush(Qt.blue)) + if display_plugin.is_disabled(): + return QVariant(QBrush(Qt.gray)) + return NONE + + def plugin_to_index(self, display_plugin): + for i, p in enumerate(self.display_plugins): + if display_plugin == p: + return self.index(i, 0, QModelIndex()) + return QModelIndex() + + def refresh_plugin(self, display_plugin): + idx = self.plugin_to_index(display_plugin) + self.dataChanged.emit(idx, idx) + + def _get_display_release_date(self, date_value, format='dd MMM yyyy'): + if date_value and date_value != UNDEFINED_DATE: + return QVariant(format_date(date_value, format)) + return NONE + + def _get_display_version(self, version): + if version is None: + return '' + return '.'.join([str(v) for v in list(version)]) + + def _get_status(self, display_plugin): + if not display_plugin.is_valid_platform(): + return _('Platform unavailable') + if not display_plugin.is_valid_calibre(): + return _('Calibre upgrade required') + if display_plugin.is_installed(): + if display_plugin.is_deprecated: + return _('Plugin deprecated') + elif display_plugin.is_upgrade_available(): + return _('New version available') + else: + return _('Latest version installed') + return _('Not installed') + + def _get_status_icon(self, display_plugin): + if display_plugin.is_deprecated: + icon_name = 'plugin_deprecated.png' + elif display_plugin.is_disabled(): + if display_plugin.is_upgrade_available(): + if display_plugin.is_valid_to_install(): + icon_name = 'plugin_disabled_valid.png' + else: + icon_name = 'plugin_disabled_invalid.png' + else: + icon_name = 'plugin_disabled_ok.png' + elif display_plugin.is_installed(): + if display_plugin.is_upgrade_available(): + if display_plugin.is_valid_to_install(): + icon_name = 'plugin_upgrade_valid.png' + else: + icon_name = 'plugin_upgrade_invalid.png' + else: + icon_name = 'plugin_upgrade_ok.png' + else: # A plugin available not currently installed + if display_plugin.is_valid_to_install(): + icon_name = 'plugin_new_valid.png' + else: + icon_name = 'plugin_new_invalid.png' + return QIcon(I('plugins/' + icon_name)) + + def _get_status_tooltip(self, display_plugin): + if display_plugin.is_deprecated: + return QVariant(_('This plugin has been deprecated and should be uninstalled')+'\n\n'+ + _('Right-click to see more options')) + if not display_plugin.is_valid_platform(): + return QVariant(_('This plugin can only be installed on: %s') % \ + ', '.join(display_plugin.platforms)+'\n\n'+ + _('Right-click to see more options')) + if numeric_version < display_plugin.calibre_required_version: + return QVariant(_('You must upgrade to at least Calibre %s before installing this plugin') % \ + self._get_display_version(display_plugin.calibre_required_version)+'\n\n'+ + _('Right-click to see more options')) + if display_plugin.installed_version < display_plugin.available_version: + if display_plugin.installed_version is None: + return QVariant(_('You can install this plugin')+'\n\n'+ + _('Right-click to see more options')) + else: + return QVariant(_('A new version of this plugin is available')+'\n\n'+ + _('Right-click to see more options')) + return QVariant(_('This plugin is installed and up-to-date')+'\n\n'+ + _('Right-click to see more options')) + + +class PluginUpdaterDialog(SizePersistedDialog): + + update_found = pyqtSignal(object) + initial_extra_size = QSize(350, 100) + + 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.model = None + self._initialize_controls() + self._create_context_menu() + + display_plugins = read_available_plugins() + + 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: + error_dialog(self.gui, _('Update Check Failed'), + _('Unable to reach the MobileRead plugins forum index page.'), + det_msg=MR_INDEX_URL, show=True) + 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(_('Check for user plugin updates')) + layout = QVBoxLayout(self) + self.setLayout(layout) + title_layout = ImageTitleLayout(self, 'images/plugin_updater.png', _('User Plugin Status')) + 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) + header_layout.addWidget(QLabel(_('Filter list of plugins')+':', self)) + header_layout.addWidget(self.filter_combo) + header_layout.addStretch(10) + + self.plugin_view = QTableView(self) + self.plugin_view.horizontalHeader().setStretchLastSection(True) + self.plugin_view.setSelectionBehavior(QAbstractItemView.SelectRows) + self.plugin_view.setSelectionMode(QAbstractItemView.SingleSelection) + self.plugin_view.setAlternatingRowColors(True) + self.plugin_view.setSortingEnabled(True) + self.plugin_view.setIconSize(QtCore.QSize(28, 28)) + layout.addWidget(self.plugin_view) + + details_layout = QHBoxLayout() + layout.addLayout(details_layout) + forum_label = QLabel('Plugin Forum Thread', self) + forum_label.setTextInteractionFlags(Qt.LinksAccessibleByMouse | Qt.LinksAccessibleByKeyboard) + forum_label.linkActivated.connect(self._forum_label_activated) + details_layout.addWidget(QLabel(_('Description')+':', self), 0, Qt.AlignLeft) + details_layout.addWidget(forum_label, 1, Qt.AlignRight) + + self.description = QLabel(self) + self.description.setFrameStyle(QFrame.Panel | QFrame.Sunken) + self.description.setAlignment(Qt.AlignTop | Qt.AlignLeft) + self.description.setMinimumHeight(40) + layout.addWidget(self.description) + + self.button_box = QDialogButtonBox(QDialogButtonBox.Close) + self.button_box.rejected.connect(self._close_clicked) + self.install_button = self.button_box.addButton(_('&Install'), QDialogButtonBox.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.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 _create_context_menu(self): + self.plugin_view.setContextMenuPolicy(Qt.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.history_action = QAction(QIcon(I('chapters.png')), _('Version &History'), self) + self.history_action.setToolTip(_('Show history of changes to this plugin')) + self.history_action.triggered.connect(self._history_clicked) + self.history_action.setEnabled(False) + self.plugin_view.addAction(self.history_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 _close_clicked(self): + # Force our toolbar/action to be updated based on uninstalled updates + if self.model: + update_plugins = filter(filter_upgradeable_plugins, self.model.display_plugins) + self.update_found.emit(update_plugins) + self.reject() + + 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.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.history_action.setEnabled(display_plugin.has_changelog) + 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.forum_action.setEnabled(False) + self.install_button.setEnabled(False) + self.install_action.setEnabled(False) + self.uninstall_action.setEnabled(False) + self.history_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) + + 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.proxy_model.set_filter_criteria(idx) + if idx == FILTER_NOT_INSTALLED: + self.plugin_view.sortByColumn(5, Qt.DescendingOrder) + else: + self.plugin_view.sortByColumn(0, Qt.AscendingOrder) + self._select_and_focus_view() + + 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.name == 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?'), '

    '+ + _('Are you sure you want to uninstall the %s plugin?')%display_plugin.name, + show_copy_button=False): + return + self._uninstall_plugin(display_plugin.name) + if self.proxy_model.filter_criteria in [FILTER_INSTALLED, FILTER_UPDATE_AVAILABLE]: + self.model.reset() + 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, '

    ' + \ + _('Installing plugins is a security risk. ' + '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) + + if DEBUG: + prints('Locating zip file for %s: %s'% (display_plugin.name, display_plugin.forum_link)) + self.gui.status_bar.showMessage(_('Locating zip file for %s: %s') % (display_plugin.name, display_plugin.forum_link)) + plugin_zip_url = self._read_zip_attachment_url(display_plugin.forum_link) + if not plugin_zip_url: + return error_dialog(self.gui, _('Install Plugin Failed'), + _('Unable to locate a plugin zip file for %s') % display_plugin.name, + det_msg=display_plugin.forum_link, show=True) + + 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) + + try: + try: + plugin = add_plugin(zip_path) + except NameConflict as e: + return error_dialog(self.gui, _('Already exists'), + unicode(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) + self.gui.status_bar.showMessage(_('Plugin installed: %s') % display_plugin.name) + info_dialog(self.gui, _('Success'), + _('Plugin {0} successfully installed under ' + ' {1} plugins. You may have to restart calibre ' + 'for the plugin to take effect.').format(plugin.name, plugin.type), + show=True, show_copy_button=False) + + 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.reset() + self._select_and_focus_view() + else: + self.model.refresh_plugin(display_plugin) + self._select_and_focus_view(change_selection=False) + + def _history_clicked(self): + display_plugin = self._selected_display_plugin() + text = self._read_version_history_html(display_plugin.forum_link) + if text: + dlg = VersionHistoryDialog(self, display_plugin.name, text) + dlg.exec_() + else: + return error_dialog(self, _('Version history missing'), + _('Unable to find the version history for %s')%display_plugin.name, + show=True) + + 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 %s 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 _read_version_history_html(self, forum_link): + br = browser() + br.set_handle_gzip(True) + try: + raw = br.open_novisit(forum_link).read() + if not raw: + return None + except: + traceback.print_exc() + return None + raw = raw.decode('utf-8', errors='replace') + root = html.fromstring(raw) + spoiler_nodes = root.xpath('//div[@class="smallfont" and strong="Spoiler"]') + for spoiler_node in spoiler_nodes: + try: + if spoiler_node.getprevious() is None: + # This is a spoiler node that has been indented using [INDENT] + # Need to go up to parent div, then previous node to get header + heading_node = spoiler_node.getparent().getprevious() + else: + # This is a spoiler node after a BR tag from the heading + heading_node = spoiler_node.getprevious().getprevious() + if heading_node is None: + continue + if heading_node.text_content().lower().find('version history') != -1: + div_node = spoiler_node.xpath('div')[0] + text = html.tostring(div_node, method='html', encoding=unicode) + return re.sub('', '

    ', text) + except: + if DEBUG: + prints('======= MobileRead Parse Error =======') + traceback.print_exc() + prints(html.tostring(spoiler_node)) + return None + + def _read_zip_attachment_url(self, forum_link): + br = browser() + br.set_handle_gzip(True) + try: + raw = br.open_novisit(forum_link).read() + if not raw: + return None + except: + traceback.print_exc() + return None + raw = raw.decode('utf-8', errors='replace') + root = html.fromstring(raw) + attachment_nodes = root.xpath('//fieldset/table/tr/td/a') + for attachment_node in attachment_nodes: + try: + filename = attachment_node.text_content().lower() + if filename.find('.zip') != -1: + full_url = MR_URL + attachment_node.attrib['href'] + return full_url + except: + if DEBUG: + prints('======= MobileRead Parse Error =======') + traceback.print_exc() + prints(html.tostring(attachment_node)) + return None + + def _download_zip(self, plugin_zip_url): + from calibre.ptempfile import PersistentTemporaryFile + br = browser() + br.set_handle_gzip(True) + raw = br.open_novisit(plugin_zip_url).read() + pt = PersistentTemporaryFile('.zip') + pt.write(raw) + pt.close() + return pt.name diff --git a/src/calibre/gui2/preferences/plugins.py b/src/calibre/gui2/preferences/plugins.py index 4f88e5aa1d..c98906e79a 100644 --- a/src/calibre/gui2/preferences/plugins.py +++ b/src/calibre/gui2/preferences/plugins.py @@ -217,6 +217,7 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form): self.customize_plugin_button.clicked.connect(self.customize_plugin) self.remove_plugin_button.clicked.connect(self.remove_plugin) self.button_plugin_add.clicked.connect(self.add_plugin) + self.button_plugin_updates.clicked.connect(self.update_plugins) self.search.initialize('plugin_search_history', help_text=_('Search for plugin')) self.search.search.connect(self.find) @@ -353,6 +354,14 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form): plugin.name + _(' cannot be removed. It is a ' 'builtin plugin. Try disabling it instead.')).exec_() + def update_plugins(self): + from calibre.gui2.dialogs.plugin_updater import PluginUpdaterDialog + d = PluginUpdaterDialog(self.gui) + d.exec_() + self._plugin_model.populate() + self._plugin_model.reset() + self.changed_signal.emit() + def reload_store_plugins(self): self.gui.load_store_plugins() if self.gui.iactions.has_key('Store'): diff --git a/src/calibre/gui2/preferences/plugins.ui b/src/calibre/gui2/preferences/plugins.ui index ebf422dfe3..f67d1aa0e0 100644 --- a/src/calibre/gui2/preferences/plugins.ui +++ b/src/calibre/gui2/preferences/plugins.ui @@ -113,15 +113,30 @@ - - - &Add a new plugin - - - - :/images/plugins.png:/images/plugins.png - - + + + + + &Add a new plugin + + + + :/images/plugins.png:/images/plugins.png + + + + + + + &Plugin updates + + + + :/images/plugins/plugin_updater.png:/images/plugins/plugin_updater.png + + + + diff --git a/src/calibre/gui2/update.py b/src/calibre/gui2/update.py index 9aae245d98..80d807c82b 100644 --- a/src/calibre/gui2/update.py +++ b/src/calibre/gui2/update.py @@ -11,12 +11,14 @@ from calibre.constants import __appname__, __version__, iswindows, isosx from calibre import browser from calibre.utils.config import prefs from calibre.gui2 import config, dynamic, open_url +from calibre.gui2.dialogs.plugin_updater import get_plugin_updates_available URL = 'http://status.calibre-ebook.com/latest' class CheckForUpdates(QThread): update_found = pyqtSignal(object) + plugin_update_found = pyqtSignal(object) INTERVAL = 24*60*60 def __init__(self, parent): @@ -36,6 +38,12 @@ class CheckForUpdates(QThread): self.update_found.emit(version) except: traceback.print_exc() + try: + update_plugins = get_plugin_updates_available() + if update_plugins: + self.plugin_update_found.emit(update_plugins) + except: + traceback.print_exc() self.sleep(self.INTERVAL) class UpdateNotification(QDialog): @@ -93,6 +101,8 @@ class UpdateMixin(object): self.update_checker = CheckForUpdates(self) self.update_checker.update_found.connect(self.update_found, type=Qt.QueuedConnection) + self.update_checker.plugin_update_found.connect(self.plugin_update_found, + type=Qt.QueuedConnection) self.update_checker.start() def update_found(self, version, force=False): @@ -106,4 +116,18 @@ class UpdateMixin(object): parent=self) self._update_notification__.show() + def plugin_update_found(self, updates, icon_only=False): + # Change the plugin icon to indicate there are updates available + plugin = self.iactions.get('Plugin Updates', None) + if not plugin: + return + if updates: + plugin.qaction.setText(_('Plugin Updates')+'*') + plugin.qaction.setIcon(QIcon(I('plugins/plugin_updater_updates.png'))) + plugin.qaction.setToolTip(_('There are %d plugin updates available')%len(updates)) + else: + plugin.qaction.setText(_('Plugin Updates')) + plugin.qaction.setIcon(QIcon(I('plugins/plugin_updater.png'))) + plugin.qaction.setToolTip(_('Install and configure user plugins')) +