diff --git a/krita/data/kritarc b/krita/data/kritarc --- a/krita/data/kritarc +++ b/krita/data/kritarc @@ -475,4 +475,4 @@ enable_scripter=true enable_tenbrushes=true enable_tenscripts=true - +enable_plugin_importer=true diff --git a/plugins/python/CMakeLists.txt b/plugins/python/CMakeLists.txt --- a/plugins/python/CMakeLists.txt +++ b/plugins/python/CMakeLists.txt @@ -91,6 +91,7 @@ # install_pykrita_plugin(scriptdocker) install_pykrita_plugin(comics_project_management_tools) install_pykrita_plugin(krita_script_starter) +install_pykrita_plugin(plugin_importer) # if(PYTHON_VERSION_MAJOR VERSION_EQUAL 3) # install_pykrita_plugin(cmake_utils) @@ -102,6 +103,7 @@ hello/hello.action tenbrushes/tenbrushes.action tenscripts/tenscripts.action + plugin_importer/plugin_importer.action DESTINATION ${DATA_INSTALL_DIR}/krita/actions) install( diff --git a/plugins/python/plugin_importer/__init__.py b/plugins/python/plugin_importer/__init__.py new file mode 100644 --- /dev/null +++ b/plugins/python/plugin_importer/__init__.py @@ -0,0 +1,24 @@ +# Copyright (c) 2019 Rebecca Breu + +# This file is part of Krita. + +# Krita 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 3 of the License, or +# (at your option) any later version. + +# Krita 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 Krita. If not, see . + +import krita + +from .plugin_importer_extension import PluginImporterExtension + + +krita_instance = krita.Krita.instance() +krita_instance.addExtension(PluginImporterExtension(krita_instance)) diff --git a/plugins/python/plugin_importer/kritapykrita_plugin_importer.desktop b/plugins/python/plugin_importer/kritapykrita_plugin_importer.desktop new file mode 100644 --- /dev/null +++ b/plugins/python/plugin_importer/kritapykrita_plugin_importer.desktop @@ -0,0 +1,8 @@ +[Desktop Entry] +Type=Service +ServiceTypes=Krita/PythonPlugin +X-KDE-Library=plugin_importer +X-Python-2-Compatible=false +X-Krita-Manual=manual.html +Name=Python Plugin Importer +Comment=Imports Python plugins from zip files. \ No newline at end of file diff --git a/plugins/python/plugin_importer/manual.html b/plugins/python/plugin_importer/manual.html new file mode 100644 --- /dev/null +++ b/plugins/python/plugin_importer/manual.html @@ -0,0 +1,2 @@ +

Python Plugin Importer

+

This Plugin imports Python plugins from zip files.

diff --git a/plugins/python/plugin_importer/plugin_importer.action b/plugins/python/plugin_importer/plugin_importer.action new file mode 100644 --- /dev/null +++ b/plugins/python/plugin_importer/plugin_importer.action @@ -0,0 +1,19 @@ + + + + Python Plugin Importer + + + + Import Python Plugin... + + + + 0 + 0 + + false + + + + diff --git a/plugins/python/plugin_importer/plugin_importer.py b/plugins/python/plugin_importer/plugin_importer.py new file mode 100644 --- /dev/null +++ b/plugins/python/plugin_importer/plugin_importer.py @@ -0,0 +1,247 @@ +# Copyright (c) 2019 Rebecca Breu + +# This file is part of Krita. + +# Krita 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 3 of the License, or +# (at your option) any later version. + +# Krita 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 Krita. If not, see . + +"""This module provides the actual importing logic. See +`:class:PluginImporter` for more info. + +For easy command line testing, call this module like this: + > python plugin_importer.py foo.zip /output/path +""" + +from configparser import ConfigParser, Error as ConfigParserError +import os +import shutil +import sys +from tempfile import TemporaryDirectory +import zipfile +from xml.etree import ElementTree + + +class PluginImportError(Exception): + """Base class for all exceptions of this module.""" + pass + + +class NoPluginsFoundException(PluginImportError): + """No valid plugins can be found in the zip file.""" + pass + + +class PluginReadError(PluginImportError): + """Zip file can't be read or its content can't be parsed.""" + pass + + +class PluginImporter: + """Import a Krita Python Plugin from a zip file into the given + directory. + + The Importer makes barely any assumptions about the file structure + in the zip file. It will find one or more plugins with the + following strategy: + + 1. Find files with the ending `.desktop` and read the Python + module name from them + 2. Find directories that correspond to the Python module names + and that contain an `__init__.py` file + 3. Find files with ending `.action` that have matching + `` tags (these files are optional) + 4. Extract the desktop- and action-files and the Python module + directories into the corresponding pykrita and actions folders + + Usage: + + >>> importer = PluginImporter( + '/path/to/plugin.zip', + '/path/to/krita/resources/', + confirm_overwrite_callback) + >>> imported = importer.import_all() + + """ + + def __init__(self, zip_filename, resources_dir, + confirm_overwrite_callback): + + """Initialise the importer. + + :param zip_filename: Filename of the zip archive containing the + plugin(s) + :param resources_dir: The Krita resources directory into which + to extract the plugin(s) + :param confirm_overwrite_callback: A function that gets called + if a plugin already exists in the resources directory. It gets + called with a dictionary of information about the plugin and + should return whether the user wants to overwrite the plugin + (True) or not (False). + """ + + self.resources_dir = resources_dir + self.confirm_overwrite_callback = confirm_overwrite_callback + try: + self.archive = zipfile.ZipFile(zip_filename) + except(zipfile.BadZipFile, zipfile.LargeZipFile, OSError) as e: + raise PluginReadError(str(e)) + + self.desktop_filenames = [] + self.action_filenames = [] + for filename in self.archive.namelist(): + if filename.endswith('.desktop'): + self.desktop_filenames.append(filename) + if filename.endswith('.action'): + self.action_filenames.append(filename) + + @property + def destination_pykrita(self): + dest = os.path.join(self.resources_dir, 'pykrita') + if not os.path.exists(dest): + os.mkdir(dest) + return dest + + @property + def destination_actions(self): + dest = os.path.join(self.resources_dir, 'actions') + if not os.path.exists(dest): + os.mkdir(dest) + return dest + + def get_destination_module(self, plugin): + return os.path.join(self.destination_pykrita, plugin['name']) + + def get_destination_desktop(self, plugin): + return os.path.join( + self.destination_pykrita, '%s.desktop' % plugin['name']) + + def get_destination_actionfile(self, plugin): + return os.path.join( + self.destination_actions, '%s.action' % plugin['name']) + + def get_source_module(self, name): + namelist = self.archive.namelist() + for filename in namelist: + if filename.endswith('/%s/' % name): + # Sanity check: There should be an __init__.py inside + if ('%s__init__.py' % filename) in namelist: + return filename + + def get_source_actionfile(self, name): + for filename in self.action_filenames: + try: + root = ElementTree.fromstring( + self.archive.read(filename).decode('utf-8')) + except ElementTree.ParseError as e: + raise PluginReadError( + '%s: %s' % (i18n('Action file'), str(e))) + + for action in root.findall('./Actions/Action'): + if action.get('name') == name: + return filename + + def read_desktop_config(self, desktop_filename): + config = ConfigParser() + try: + config.read_string( + self.archive.read(desktop_filename).decode('utf-8')) + except ConfigParserError as e: + raise PluginReadError( + '%s: %s' % (i18n('Desktop file'), str(e))) + return config + + def get_plugin_info(self): + names = [] + for filename in self.desktop_filenames: + config = self.read_desktop_config(filename) + try: + name = config['Desktop Entry']['X-KDE-Library'] + ui_name = config['Desktop Entry']['Name'] + except KeyError as e: + raise PluginReadError( + 'Desktop file: Key %s not found' % str(e)) + module = self.get_source_module(name) + if module: + names.append({ + 'name': name, + 'ui_name': ui_name, + 'desktop': filename, + 'module': module, + 'action': self.get_source_actionfile(name) + }) + return names + + def extract_desktop(self, plugin): + with open(self.get_destination_desktop(plugin), 'wb') as f: + f.write(self.archive.read(plugin['desktop'])) + + def extract_module(self, plugin): + with TemporaryDirectory() as tmp_dir: + for name in self.archive.namelist(): + if name.startswith(plugin['module']): + self.archive.extract(name, tmp_dir) + module_dirname = os.path.join( + tmp_dir, *plugin['module'].split('/')) + try: + shutil.rmtree(self.get_destination_module(plugin)) + except FileNotFoundError: + pass + shutil.copytree(module_dirname, + self.get_destination_module(plugin)) + + def extract_actionfile(self, plugin): + with open(self.get_destination_actionfile(plugin), 'wb') as f: + f.write(self.archive.read(plugin['action'])) + + def extract_plugin(self, plugin): + # Check if the plugin already exists in the source directory: + if (os.path.exists(self.get_destination_desktop(plugin)) + or os.path.exists(self.get_destination_module(plugin))): + confirmed = self.confirm_overwrite_callback(plugin) + if not confirmed: + return False + + self.extract_desktop(plugin) + self.extract_module(plugin) + if plugin['action']: + self.extract_actionfile(plugin) + return True + + def import_all(self): + """Imports all plugins from the zip archive. + + Returns a list of imported plugins. + """ + + plugins = self.get_plugin_info() + if not plugins: + raise NoPluginsFoundException(i18n('No plugins found in archive')) + + imported = [] + for plugin in plugins: + success = self.extract_plugin(plugin) + if success: + imported.append(plugin) + + return imported + + +if __name__ == '__main__': + def callback(plugin): + print('Overwriting plugin:', plugin['ui_name']) + return True + + imported = PluginImporter( + sys.argv[1], sys.argv[2], callback).import_all() + for plugin in imported: + print('Imported plugin:', plugin['ui_name']) diff --git a/plugins/python/plugin_importer/plugin_importer_extension.py b/plugins/python/plugin_importer/plugin_importer_extension.py new file mode 100644 --- /dev/null +++ b/plugins/python/plugin_importer/plugin_importer_extension.py @@ -0,0 +1,106 @@ +# Copyright (c) 2019 Rebecca Breu + +# This file is part of Krita. + +# Krita 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 3 of the License, or +# (at your option) any later version. + +# Krita 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 Krita. If not, see . + +import os + +import krita + +from PyQt5.QtCore import QStandardPaths +from PyQt5.QtWidgets import QFileDialog, QMessageBox + +from .plugin_importer import PluginImporter, PluginImportError + + +class PluginImporterExtension(krita.Extension): + + def __init__(self, parent): + super().__init__(parent) + self.parent = parent + + def setup(self): + pass + + def createActions(self, window): + action = window.createAction( + 'plugin_importer', + i18n('Import Python Plugin...'), + 'tools/scripts') + action.triggered.connect(self.import_plugin) + + def confirm_overwrite(self, plugin): + reply = QMessageBox.question( + self.parent.activeWindow().qwindow(), + i18n('Overwrite Plugin'), + i18n('The plugin "%s" already exists. Overwrite it?') % ( + plugin['ui_name']), + QMessageBox.Yes | QMessageBox.No) + return reply == QMessageBox.Yes + + def get_success_text(self, plugins): + txt = [ + '

', + i18n('The following plugins were imported:'), + '

', + '
    ' + ] + for plugin in plugins: + txt.append('
  • %s
  • ' % plugin['ui_name']) + + txt.append('
') + txt.append('

') + txt.append(i18n( + 'Please restart Krita and activate the plugins in ' + 'Settings -> Configure Krita -> ' + 'Python Plugin Manager.')) + txt.append('

') + return ('\n').join(txt) + + def get_resources_dir(self): + return QStandardPaths.writableLocation( + QStandardPaths.AppDataLocation) + + def import_plugin(self): + zipfile = QFileDialog.getOpenFileName( + self.parent.activeWindow().qwindow(), + i18n('Import Plugin'), + os.path.expanduser('~'), + '%s (*.zip)' % i18n('Zip Archives'), + )[0] + + if not zipfile: + return + + try: + imported = PluginImporter( + zipfile, + self.get_resources_dir(), + self.confirm_overwrite + ).import_all() + except PluginImportError as e: + msg = '

%s

%s
' % ( + i18n('Error during import:'), str(e)) + QMessageBox.warning( + self.parent.activeWindow().qwindow(), + i18n('Error'), + msg) + return + + if imported: + QMessageBox.information( + self.parent.activeWindow().qwindow(), + i18n('Import successful'), + self.get_success_text(imported))