Start work on integrating plugin updater into calibre

This commit is contained in:
Kovid Goyal 2011-06-14 10:17:11 -06:00
commit 047bf3b24c
19 changed files with 964 additions and 10 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 641 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -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]
# }}}

View File

@ -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 <grant.drake@gmail.com>'
__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_()

View File

@ -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 <grant.drake@gmail.com>'
__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:
'''
<li><a href="http://www.mobileread.com/forums/showthread.php?t=121787">Book Sync</a><br />
<i>Add books to a list to be automatically sent to your device the next time it is connected.<br />
<span class="resize_1">Version: 1.1; Released: 02-22-2011; Calibre: 0.7.42; Author: kiwidude; <br />
Platforms: Windows, OSX, Linux; History: Yes;</span></i></li>
'''
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('<a href="http://www.foo.com/">Plugin Forum Thread</a>', 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?'), '<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.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, '<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)
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 <b>%s</b>') % 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 <b>{0}</b> successfully installed under <b>'
' {1} plugins</b>. 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 <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 _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('<div\s.*?>', '<div>', 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

View File

@ -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'):

View File

@ -112,6 +112,8 @@
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_7">
<item>
<widget class="QPushButton" name="button_plugin_add">
<property name="text">
@ -123,6 +125,19 @@
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="button_plugin_updates">
<property name="text">
<string>&amp;Plugin updates</string>
</property>
<property name="icon">
<iconset resource="../../../../resources/images.qrc">
<normaloff>:/images/plugins/plugin_updater.png</normaloff>:/images/plugins/plugin_updater.png</iconset>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<customwidgets>

View File

@ -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'))