Files
picard/picard/ui/options/plugins.py
2024-05-16 12:08:47 +02:00

733 lines
26 KiB
Python

# -*- coding: utf-8 -*-
#
# Picard, the next-generation MusicBrainz tagger
#
# Copyright (C) 2007 Lukáš Lalinský
# Copyright (C) 2009 Carlin Mangar
# Copyright (C) 2009, 2018-2023 Philipp Wolfer
# Copyright (C) 2011-2013 Michael Wiencek
# Copyright (C) 2013, 2015, 2018-2024 Laurent Monin
# Copyright (C) 2013, 2017 Sophist-UK
# Copyright (C) 2014 Shadab Zafar
# Copyright (C) 2015, 2017 Wieland Hoffmann
# Copyright (C) 2016-2018 Sambhav Kothari
# Copyright (C) 2017 Suhas
# Copyright (C) 2018 Vishal Choudhary
# Copyright (C) 2018 yagyanshbhatia
# Copyright (C) 2023 tuspar
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
from functools import partial
from html import escape
from operator import attrgetter
import os.path
import re
from PyQt6 import (
QtCore,
QtGui,
QtWidgets,
)
from PyQt6.QtWidgets import QTreeWidgetItemIterator
from picard import log
from picard.config import get_config
from picard.const import (
PLUGINS_API,
USER_PLUGIN_DIR,
)
from picard.extension_points.options_pages import register_options_page
from picard.i18n import (
N_,
gettext as _,
)
from picard.util import (
icontheme,
open_local_path,
reconnect,
)
from picard.ui import HashableTreeWidgetItem
from picard.ui.forms.ui_options_plugins import Ui_PluginsOptionsPage
from picard.ui.options import OptionsPage
from picard.ui.theme import theme
COLUMN_NAME, COLUMN_VERSION, COLUMN_ACTIONS = range(3)
class PluginActionButton(QtWidgets.QToolButton):
def __init__(self, icon=None, tooltip=None, retain_space=False,
switch_method=None, *args, **kwargs):
super().__init__(*args, **kwargs)
if tooltip is not None:
self.setToolTip(tooltip)
if icon is not None:
self.setIcon(self, icon)
if retain_space is True:
sp_retain = self.sizePolicy()
sp_retain.setRetainSizeWhenHidden(True)
self.setSizePolicy(sp_retain)
if switch_method is not None:
self.switch_method = switch_method
def mode(self, mode):
if self.switch_method is not None:
self.switch_method(self, mode)
def setIcon(self, icon):
super().setIcon(icon)
# Workaround for Qt sometimes not updating the icon.
# See https://tickets.metabrainz.org/browse/PICARD-1647
self.repaint()
class PluginTreeWidgetItem(HashableTreeWidgetItem):
def __init__(self, icons, *args, **kwargs):
super().__init__(*args, **kwargs)
self._icons = icons
self._sortData = {}
self.upgrade_to_version = None
self.new_version = None
self.is_enabled = False
self.is_installed = False
self.installed_font = None
self.enabled_font = None
self.available_font = None
self.buttons = {}
self.buttons_widget = QtWidgets.QWidget()
layout = QtWidgets.QHBoxLayout()
layout.setContentsMargins(0, 2, 5, 2)
layout.addStretch(1)
self.buttons_widget.setLayout(layout)
def add_button(name, method):
button = PluginActionButton(switch_method=method)
layout.addWidget(button)
self.buttons[name] = button
button.mode('hide')
add_button('update', self.show_update)
add_button('uninstall', self.show_uninstall)
add_button('enable', self.show_enable)
add_button('install', self.show_install)
self.treeWidget().setItemWidget(self, COLUMN_ACTIONS,
self.buttons_widget)
def show_install(self, button, mode):
if mode == 'hide':
button.hide()
else:
button.show()
button.setToolTip(_("Download and install plugin"))
button.setIcon(self._icons['download'])
def show_update(self, button, mode):
if mode == 'hide':
button.hide()
else:
button.show()
button.setToolTip(_("Download and upgrade plugin to version %s") % self.new_version.short_str())
button.setIcon(self._icons['update'])
def show_enable(self, button, mode):
if mode == 'enabled':
button.show()
button.setToolTip(_("Enabled"))
button.setIcon(self._icons['enabled'])
elif mode == 'disabled':
button.show()
button.setToolTip(_("Disabled"))
button.setIcon(self._icons['disabled'])
else:
button.hide()
def show_uninstall(self, button, mode):
if mode == 'hide':
button.hide()
else:
button.show()
button.setToolTip(_("Uninstall plugin"))
button.setIcon(self._icons['uninstall'])
def save_state(self):
return {
'is_enabled': self.is_enabled,
'upgrade_to_version': self.upgrade_to_version,
'new_version': self.new_version,
'is_installed': self.is_installed,
}
def restore_state(self, states):
self.upgrade_to_version = states['upgrade_to_version']
self.new_version = states['new_version']
self.is_enabled = states['is_enabled']
self.is_installed = states['is_installed']
def __lt__(self, other):
if not isinstance(other, PluginTreeWidgetItem):
return super().__lt__(other)
tree = self.treeWidget()
if not tree:
column = 0
else:
column = tree.sortColumn()
return self.sortData(column) < other.sortData(column)
def sortData(self, column):
return self._sortData.get(column, self.text(column))
def setSortData(self, column, data):
self._sortData[column] = data
@property
def plugin(self):
return self.data(COLUMN_NAME, QtCore.Qt.ItemDataRole.UserRole)
def enable(self, boolean, greyout=None):
if boolean is not None:
self.is_enabled = boolean
if self.is_enabled:
self.buttons['enable'].mode('enabled')
else:
self.buttons['enable'].mode('disabled')
if greyout is not None:
self.buttons['enable'].setEnabled(not greyout)
class PluginsOptionsPage(OptionsPage):
NAME = 'plugins'
TITLE = N_("Plugins")
PARENT = None
SORT_ORDER = 70
ACTIVE = True
HELP_URL = "/config/options_plugins.html"
def __init__(self, parent=None):
super().__init__(parent)
self.ui = Ui_PluginsOptionsPage()
self.ui.setupUi(self)
plugins = self.ui.plugins
# fix for PICARD-1226, QT bug (https://bugreports.qt.io/browse/QTBUG-22572) workaround
plugins.setStyleSheet('')
plugins.itemSelectionChanged.connect(self.change_details)
plugins.mimeTypes = self.mimeTypes
plugins.dropEvent = self.dropEvent
plugins.dragEnterEvent = self.dragEnterEvent
plugins.dragMoveEvent = self.dragMoveEvent
self.ui.install_plugin.clicked.connect(self.open_plugins)
self.ui.folder_open.clicked.connect(self.open_plugin_dir)
self.ui.reload_list_of_plugins.clicked.connect(self.reload_list_of_plugins)
self.manager = self.tagger.pluginmanager
self.manager.plugin_installed.connect(self.plugin_installed)
self.manager.plugin_updated.connect(self.plugin_updated)
self.manager.plugin_removed.connect(self.plugin_removed)
self.manager.plugin_errored.connect(self.plugin_loading_error)
self._preserve = {}
self._preserve_selected = None
self.icons = {
'download': self.create_icon('plugin-download'),
'update': self.create_icon('plugin-update'),
'uninstall': self.create_icon('plugin-uninstall'),
'enabled': self.create_icon('plugin-enabled'),
'disabled': self.create_icon('plugin-disabled'),
}
def create_icon(self, icon_name):
if theme.is_dark_theme:
icon_name += '-dark'
return icontheme.lookup(icon_name, icontheme.ICON_SIZE_MENU)
def items(self):
iterator = QTreeWidgetItemIterator(self.ui.plugins, QTreeWidgetItemIterator.IteratorFlag.All)
while iterator.value():
item = iterator.value()
iterator += 1
yield item
def find_item_by_plugin_name(self, plugin_name):
for item in self.items():
if plugin_name == item.plugin.module_name:
return item
return None
def selected_item(self):
try:
return self.ui.plugins.selectedItems()[COLUMN_NAME]
except IndexError:
return None
def save_state(self):
header = self.ui.plugins.header()
config = get_config()
config.persist['plugins_list_state'] = header.saveState()
config.persist['plugins_list_sort_section'] = header.sortIndicatorSection()
config.persist['plugins_list_sort_order'] = header.sortIndicatorOrder()
def set_current_item(self, item, scroll=False):
if scroll:
self.ui.plugins.scrollToItem(item)
self.ui.plugins.setCurrentItem(item)
self.refresh_details(item)
def restore_state(self):
header = self.ui.plugins.header()
config = get_config()
header.restoreState(config.persist['plugins_list_state'])
idx = config.persist['plugins_list_sort_section']
order = config.persist['plugins_list_sort_order']
header.setSortIndicator(idx, order)
self.ui.plugins.sortByColumn(idx, order)
@staticmethod
def is_plugin_enabled(plugin):
config = get_config()
return bool(plugin.module_name in config.setting['enabled_plugins'])
def available_plugins_name_version(self):
return {p.module_name: p.version for p in self.manager.available_plugins}
def installable_plugins(self):
if self.manager.available_plugins is not None:
installed_plugins = [plugin.module_name for plugin in
self.installed_plugins()]
for plugin in sorted(self.manager.available_plugins,
key=attrgetter('name')):
if plugin.module_name not in installed_plugins:
yield plugin
def installed_plugins(self):
return sorted(self.manager.plugins, key=attrgetter('name'))
def enabled_plugins(self):
return [item.plugin.module_name for item in self.items() if item.is_enabled]
def _populate(self):
self._user_interaction(False)
if self.manager.available_plugins is None:
available_plugins = {}
self.manager.query_available_plugins(self._reload)
else:
available_plugins = self.available_plugins_name_version()
self.ui.details.setText("")
self.ui.plugins.setSortingEnabled(False)
for plugin in self.installed_plugins():
new_version = None
if plugin.module_name in available_plugins:
latest = available_plugins[plugin.module_name]
if latest > plugin.version:
new_version = latest
self.update_plugin_item(None, plugin,
enabled=self.is_plugin_enabled(plugin),
new_version=new_version,
is_installed=True
)
for plugin in self.installable_plugins():
self.update_plugin_item(None, plugin, enabled=False,
is_installed=False)
self.ui.plugins.setSortingEnabled(True)
self._user_interaction(True)
header = self.ui.plugins.header()
header.setStretchLastSection(False)
header.setSectionResizeMode(QtWidgets.QHeaderView.ResizeMode.Fixed)
header.setSectionResizeMode(COLUMN_NAME, QtWidgets.QHeaderView.ResizeMode.Stretch)
header.setSectionResizeMode(COLUMN_VERSION, QtWidgets.QHeaderView.ResizeMode.ResizeToContents)
header.setSectionResizeMode(COLUMN_ACTIONS, QtWidgets.QHeaderView.ResizeMode.ResizeToContents)
def _remove_all(self):
for item in self.items():
idx = self.ui.plugins.indexOfTopLevelItem(item)
self.ui.plugins.takeTopLevelItem(idx)
def restore_defaults(self):
self._user_interaction(False)
self._remove_all()
super().restore_defaults()
self.set_current_item(self.ui.plugins.topLevelItem(0), scroll=True)
def load(self):
self._populate()
self.restore_state()
def _preserve_plugins_states(self):
self._preserve = {item.plugin.module_name: item.save_state() for item in self.items()}
item = self.selected_item()
if item:
self._preserve_selected = item.plugin.module_name
else:
self._preserve_selected = None
def _restore_plugins_states(self):
for item in self.items():
plugin = item.plugin
if plugin.module_name in self._preserve:
item.restore_state(self._preserve[plugin.module_name])
if self._preserve_selected == plugin.module_name:
self.set_current_item(item, scroll=True)
def _reload(self):
if self.deleted:
return
self._remove_all()
self._populate()
self._restore_plugins_states()
def _user_interaction(self, enabled):
self.ui.plugins.blockSignals(not enabled)
self.ui.plugins_container.setEnabled(enabled)
def reload_list_of_plugins(self):
self.ui.details.setText(_("Reloading list of available plugins…"))
self._user_interaction(False)
self._preserve_plugins_states()
self.manager.query_available_plugins(callback=self._reload)
def plugin_loading_error(self, plugin_name, error):
QtWidgets.QMessageBox.critical(
self,
_('Plugin "%(plugin)s"') % {'plugin': plugin_name},
_('An error occurred while loading the plugin "%(plugin)s":\n\n%(error)s') % {
'plugin': plugin_name,
'error': error,
})
def plugin_installed(self, plugin):
log.debug("Plugin %r installed", plugin.name)
if not plugin.compatible:
params = {'plugin': plugin.name}
QtWidgets.QMessageBox.warning(
self,
_('Plugin "%(plugin)s"') % params,
_('The plugin "%(plugin)s" is not compatible with this version of Picard.') % params
)
return
item = self.find_item_by_plugin_name(plugin.module_name)
if item:
self.update_plugin_item(item, plugin, make_current=True,
enabled=True, is_installed=True)
else:
self._reload()
item = self.find_item_by_plugin_name(plugin.module_name)
if item:
self.set_current_item(item, scroll=True)
def plugin_updated(self, plugin_name):
log.debug("Plugin %r updated", plugin_name)
item = self.find_item_by_plugin_name(plugin_name)
if item:
plugin = item.plugin
QtWidgets.QMessageBox.information(
self,
_('Plugin "%(plugin)s"') % {'plugin': plugin_name},
_('The plugin "%(plugin)s" will be upgraded to version %(version)s on next run of Picard.') % {
'plugin': plugin.name,
'version': item.new_version.short_str(),
})
item.upgrade_to_version = item.new_version
self.update_plugin_item(item, plugin, make_current=True)
def plugin_removed(self, plugin_name):
log.debug("Plugin %r removed", plugin_name)
item = self.find_item_by_plugin_name(plugin_name)
if item:
if self.manager.is_available(plugin_name):
self.update_plugin_item(item, None, make_current=True,
is_installed=False)
else: # Remove local plugin
self.ui.plugins.invisibleRootItem().removeChild(item)
def uninstall_plugin(self, item):
plugin = item.plugin
params = {'plugin': plugin.name}
buttonReply = QtWidgets.QMessageBox.question(
self,
_('Uninstall plugin "%(plugin)s"?') % params,
_('Do you really want to uninstall the plugin "%(plugin)s"?') % params,
QtWidgets.QMessageBox.StandardButton.Yes | QtWidgets.QMessageBox.StandardButton.No,
QtWidgets.QMessageBox.StandardButton.No
)
if buttonReply == QtWidgets.QMessageBox.StandardButton.Yes:
self.manager.remove_plugin(plugin.module_name, with_update=True)
def update_plugin_item(self, item, plugin,
make_current=False,
enabled=None,
new_version=None,
is_installed=None
):
if item is None:
item = PluginTreeWidgetItem(self.icons, self.ui.plugins)
if plugin is not None:
item.setData(COLUMN_NAME, QtCore.Qt.ItemDataRole.UserRole, plugin)
else:
plugin = item.plugin
if new_version is not None:
item.new_version = new_version
if is_installed is not None:
item.is_installed = is_installed
if enabled is None:
enabled = item.is_enabled
def update_text():
if item.new_version is not None:
version = "%s%s" % (plugin.version.short_str(),
item.new_version.short_str())
else:
version = plugin.version.short_str()
if item.installed_font is None:
item.installed_font = item.font(COLUMN_NAME)
if item.enabled_font is None:
item.enabled_font = QtGui.QFont(item.installed_font)
item.enabled_font.setBold(True)
if item.available_font is None:
item.available_font = QtGui.QFont(item.installed_font)
if item.is_enabled:
item.setFont(COLUMN_NAME, item.enabled_font)
else:
if item.is_installed:
item.setFont(COLUMN_NAME, item.installed_font)
else:
item.setFont(COLUMN_NAME, item.available_font)
item.setText(COLUMN_NAME, plugin.name)
item.setText(COLUMN_VERSION, version)
def toggle_enable():
item.enable(not item.is_enabled, greyout=not item.is_installed)
log.debug("Plugin %r enabled: %r", item.plugin.name, item.is_enabled)
update_text()
reconnect(item.buttons['enable'].clicked, toggle_enable)
install_enabled = not item.is_installed or bool(item.new_version)
if item.upgrade_to_version:
if item.upgrade_to_version != item.new_version:
# case when a new version is known after a plugin was marked for update
install_enabled = True
else:
install_enabled = False
if install_enabled:
if item.new_version is not None:
def download_and_update():
self.download_plugin(item, update=True)
reconnect(item.buttons['update'].clicked, download_and_update)
item.buttons['install'].mode('hide')
item.buttons['update'].mode('show')
else:
def download_and_install():
self.download_plugin(item)
reconnect(item.buttons['install'].clicked, download_and_install)
item.buttons['install'].mode('show')
item.buttons['update'].mode('hide')
if item.is_installed:
item.buttons['install'].mode('hide')
item.buttons['uninstall'].mode(
'show' if plugin.is_user_installed else 'hide')
item.enable(enabled, greyout=False)
def uninstall_processor():
self.uninstall_plugin(item)
reconnect(item.buttons['uninstall'].clicked, uninstall_processor)
else:
item.buttons['uninstall'].mode('hide')
item.enable(False)
item.buttons['enable'].mode('hide')
update_text()
if make_current:
self.set_current_item(item)
actions_sort_score = 2
if item.is_installed:
if item.is_enabled:
actions_sort_score = 0
else:
actions_sort_score = 1
item.setSortData(COLUMN_ACTIONS, actions_sort_score)
item.setSortData(COLUMN_NAME, plugin.name.lower())
def v2int(elem):
try:
return int(elem)
except ValueError:
return 0
item.setSortData(COLUMN_VERSION, plugin.version)
return item
def save(self):
config = get_config()
config.setting['enabled_plugins'] = self.enabled_plugins()
self.save_state()
def refresh_details(self, item):
plugin = item.plugin
text = []
if item.new_version is not None:
if item.upgrade_to_version:
label = _("Restart Picard to upgrade to new version")
else:
label = _("New version available")
version_str = item.new_version.short_str()
text.append("<b>{0}: {1}</b>".format(label, version_str))
if plugin.description:
text.append(plugin.description + "<hr width='90%'/>")
infos = [
(_("Name"), escape(plugin.name)),
(_("Authors"), self.link_authors(plugin.author)),
(_("License"), plugin.license),
(_("Files"), escape(plugin.files_list)),
(_("User Guide"), self.link_user_guide(plugin.user_guide_url)),
]
for label, value in infos:
if value:
text.append("<b>{0}:</b> {1}".format(label, value))
self.ui.details.setText("<p>{0}</p>".format("<br/>\n".join(text)))
@staticmethod
def link_authors(authors):
formatted_authors = []
re_author = re.compile(r"(?P<author>.*?)\s*<(?P<email>.*?@.*?)>")
for author in authors.split(','):
author = author.strip()
match = re_author.fullmatch(author)
if match:
author_str = '<a href="mailto:{email}">{author}</a>'.format(
email=escape(match['email']),
author=escape(match['author']),
)
formatted_authors.append(author_str)
else:
formatted_authors.append(escape(author))
return ', '.join(formatted_authors)
@staticmethod
def link_user_guide(user_guide):
if user_guide:
user_guide = '<a href="{url}">{url}</a>'.format(
url=escape(user_guide)
)
return user_guide
def change_details(self):
item = self.selected_item()
if item:
self.refresh_details(item)
def open_plugins(self):
files, _filter = QtWidgets.QFileDialog.getOpenFileNames(
self,
"",
QtCore.QDir.homePath(),
"Picard plugin (*.py *.pyc *.zip)"
)
if files:
for path in files:
self.manager.install_plugin(path)
def download_plugin(self, item, update=False):
plugin = item.plugin
self.tagger.webservice.get_url(
url=PLUGINS_API['urls']['download'],
handler=partial(self.download_handler, update, plugin=plugin),
parse_response_type=None,
priority=True,
important=True,
unencoded_queryargs={'id': plugin.module_name, 'version': plugin.version.short_str()},
)
def download_handler(self, update, response, reply, error, plugin):
if self.deleted:
return
if error:
params = {'plugin': plugin.module_name}
msgbox = QtWidgets.QMessageBox(self)
msgbox.setText(_('The plugin "%(plugin)s" could not be downloaded.') % params)
msgbox.setInformativeText(_("Please try again later."))
msgbox.setStandardButtons(QtWidgets.QMessageBox.StandardButton.Ok)
msgbox.setDefaultButton(QtWidgets.QMessageBox.StandardButton.Ok)
msgbox.exec()
log.error('Error occurred while trying to download the plugin: "%(plugin)s"', params)
return
self.manager.install_plugin(
None,
update=update,
plugin_name=plugin.module_name,
plugin_data=response,
)
@staticmethod
def open_plugin_dir():
open_local_path(USER_PLUGIN_DIR)
def mimeTypes(self):
return ['text/uri-list']
def dragEnterEvent(self, event):
event.setDropAction(QtCore.Qt.DropAction.CopyAction)
event.accept()
def dragMoveEvent(self, event):
event.setDropAction(QtCore.Qt.DropAction.CopyAction)
event.accept()
def dropEvent(self, event):
if event.proposedAction() == QtCore.Qt.DropAction.IgnoreAction:
event.acceptProposedAction()
return
for path in (os.path.normpath(u.toLocalFile()) for u in event.mimeData().urls()):
self.manager.install_plugin(path)
event.setDropAction(QtCore.Qt.DropAction.CopyAction)
event.accept()
register_options_page(PluginsOptionsPage)