From caef322b65181e3a593c4894ff155524b9afe6cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gina=20H=C3=A4u=C3=9Fge?= Date: Fri, 29 May 2015 16:31:43 +0200 Subject: [PATCH] The Plugin Manager is now bundled with OctoPrint --- CHANGELOG.md | 4 +- docs/features/plugins.rst | 14 +- setup.py | 3 +- .../plugins/pluginmanager/__init__.py | 524 ++++++++++++++++ .../static/css/pluginmanager.css | 1 + .../static/img/repo_unavailable.png | Bin 0 -> 24812 bytes .../pluginmanager/static/js/pluginmanager.js | 573 ++++++++++++++++++ .../static/less/pluginmanager.less | 81 +++ .../templates/pluginmanager_settings.jinja2 | 145 +++++ src/octoprint/settings.py | 2 +- 10 files changed, 1340 insertions(+), 7 deletions(-) create mode 100644 src/octoprint/plugins/pluginmanager/__init__.py create mode 100644 src/octoprint/plugins/pluginmanager/static/css/pluginmanager.css create mode 100644 src/octoprint/plugins/pluginmanager/static/img/repo_unavailable.png create mode 100644 src/octoprint/plugins/pluginmanager/static/js/pluginmanager.js create mode 100644 src/octoprint/plugins/pluginmanager/static/less/pluginmanager.less create mode 100644 src/octoprint/plugins/pluginmanager/templates/pluginmanager_settings.jinja2 diff --git a/CHANGELOG.md b/CHANGELOG.md index 8441f068..9cab60f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,9 @@ * You can now define a folder (default: `~/.octoprint/watched`) to be watched for newly added GCODE (or -- if slicing support is enabled -- STL) files to automatically add. * OctoPrint now has a [plugin system](http://docs.octoprint.org/en/devel/plugins/index.html) which allows extending its - core functionality. + core functionality. Plugins may be installed through the new Plugin Manager available in OctoPrint's settings. This + Plugin Manager also allows browsing and easy installation of plugins registered on the official + [OctoPrint Plugin Repository](http://plugins.octoprint.org). * New type of API key: [App Session Keys](http://docs.octoprint.org/en/devel/api/apps.html) for trusted applications * Printer Profiles: Printer properties like print volume, extruder offsets etc are now managed via Printer Profiles. A connection to a printer will always have a printer profile associated. diff --git a/docs/features/plugins.rst b/docs/features/plugins.rst index 20de2ba3..c8fb1fd5 100644 --- a/docs/features/plugins.rst +++ b/docs/features/plugins.rst @@ -26,11 +26,17 @@ and on the `OctoPrint organization Github page `_. Installing Plugins ================== -Plugins can be installed either by unpacking them into one of the configured plugin folders (regularly those are -``/plugins`` and ``/plugins`` [#f1]_ or by installing them as regular python -modules via ``pip`` [#f2]_. +Plugins can be installed through the bundled Plugin Manager, which allows installing plugins available in the +`OctoPrint Plugin Repository `_, from a web address or from an uploaded file archive. -Please refer to the documentation of the plugin for installations instructions. +Please refer to the documentation of the plugin for additional installations instructions. + +Manual Installation +------------------- + +If you don't want or can't use the Plugin Manager, plugins may also be installed manually either by copying and +unpacking them into one of the configured plugin folders (regularly those are ``/plugins`` and +``/plugins`` [#f1]_ or by installing them as regular python modules via ``pip`` [#f2]_. For a plugin available on the Python Package Index (PyPi), the process is as simple as issuing a diff --git a/setup.py b/setup.py index 10e010a7..1c314211 100644 --- a/setup.py +++ b/setup.py @@ -28,7 +28,8 @@ INSTALL_REQUIRES = [ "netifaces", "pylru", "rsa", - "pkginfo" + "pkginfo", + "requests" ] # Additional requirements for optional install options diff --git a/src/octoprint/plugins/pluginmanager/__init__.py b/src/octoprint/plugins/pluginmanager/__init__.py new file mode 100644 index 00000000..029c7010 --- /dev/null +++ b/src/octoprint/plugins/pluginmanager/__init__.py @@ -0,0 +1,524 @@ +# coding=utf-8 +from __future__ import absolute_import + +__author__ = "Gina Häußge " +__license__ = 'GNU Affero General Public License http://www.gnu.org/licenses/agpl.html' +__copyright__ = "Copyright (C) 2015 The OctoPrint Project - Released under terms of the AGPLv3 License" + + +import octoprint.plugin +import octoprint.plugin.core + +from octoprint.settings import valid_boolean_trues +from octoprint.server.util.flask import restricted_access +from octoprint.server import admin_permission + +from flask import jsonify, make_response + +import logging +import sarge +import sys +import requests +import re + +class PluginManagerPlugin(octoprint.plugin.SimpleApiPlugin, + octoprint.plugin.TemplatePlugin, + octoprint.plugin.AssetPlugin, + octoprint.plugin.SettingsPlugin, + octoprint.plugin.StartupPlugin, + octoprint.plugin.BlueprintPlugin): + + def __init__(self): + self._pending_enable = set() + self._pending_disable = set() + self._pending_install = set() + self._pending_uninstall = set() + + self._repository_available = False + self._repository_plugins = [] + + def initialize(self): + self._console_logger = logging.getLogger("octoprint.plugins.pluginmanager.console") + + ##~~ StartupPlugin + + def on_startup(self, host, port): + console_logging_handler = logging.handlers.RotatingFileHandler(self._settings.get_plugin_logfile_path(postfix="console"), maxBytes=2*1024*1024) + console_logging_handler.setFormatter(logging.Formatter("%(asctime)s %(message)s")) + console_logging_handler.setLevel(logging.DEBUG) + + self._console_logger.addHandler(console_logging_handler) + self._console_logger.setLevel(logging.DEBUG) + self._console_logger.propagate = False + + self._repository_available = self._refresh_repository() + + ##~~ SettingsPlugin + + def get_settings_defaults(self): + return dict( + repository="http://plugins.octoprint.org/plugins.json", + pip=None + ) + + ##~~ AssetPlugin + + def get_assets(self): + return dict( + js=["js/pluginmanager.js"], + css=["css/pluginmanager.css"], + less=["less/pluginmanager.less"] + ) + + ##~~ TemplatePlugin + + def get_template_configs(self): + return [ + dict(type="settings", template="pluginmanager_settings.jinja2", custom_bindings=True) + ] + + ##~~ BlueprintPlugin + + @octoprint.plugin.BlueprintPlugin.route("/upload_archive", methods=["POST"]) + @restricted_access + @admin_permission.require(403) + def upload_archive(self): + import flask + + input_name = "file" + input_upload_path = input_name + "." + self._settings.global_get(["server", "uploads", "pathSuffix"]) + input_upload_name = input_name + "." + self._settings.global_get(["server", "uploads", "nameSuffix"]) + + if input_upload_path not in flask.request.values or input_upload_name not in flask.request.values: + return flask.make_response("No file included", 400) + upload_path = flask.request.values[input_upload_path] + upload_name = flask.request.values[input_upload_name] + + import tempfile + import shutil + import os + + archive = tempfile.NamedTemporaryFile(delete=False, suffix="-{upload_name}".format(**locals())) + try: + archive.close() + shutil.copy(upload_path, archive.name) + return self.command_install(path=archive.name, force="force" in flask.request.values and flask.request.values["force"] in valid_boolean_trues) + finally: + try: + os.remove(archive.name) + except Exception as e: + self._logger.warn("Could not remove temporary file {path} again: {message}".format(path=archive.name, message=str(e))) + + ##~~ SimpleApiPlugin + + def get_api_commands(self): + return { + "install": ["url"], + "uninstall": ["plugin"], + "enable": ["plugin"], + "disable": ["plugin"], + "refresh_repository": [] + } + + def on_api_get(self, request): + if not admin_permission.can(): + return make_response("Insufficient rights", 403) + + plugins = self._plugin_manager.plugins + + result = [] + for name, plugin in plugins.items(): + result.append(self._to_external_representation(plugin)) + + if "refresh_repository" in request.values and request.values["refresh_repository"] in valid_boolean_trues: + self._refresh_repository() + + return jsonify(plugins=result, repository=dict(available=self._repository_available, plugins=self._repository_plugins), os=self._get_os(), octoprint=self._get_octoprint_version()) + + def on_api_command(self, command, data): + if not admin_permission.can(): + return make_response("Insufficient rights", 403) + + if command == "install": + url = data["url"] + plugin_name = data["plugin"] if "plugin" in data else None + return self.command_install(url=url, force="force" in data and data["force"] in valid_boolean_trues, reinstall=plugin_name) + + elif command == "uninstall": + plugin_name = data["plugin"] + if not plugin_name in self._plugin_manager.plugins: + return make_response("Unknown plugin: %s" % plugin_name, 404) + + plugin = self._plugin_manager.plugins[plugin_name] + return self.command_uninstall(plugin) + + elif command == "enable" or command == "disable": + plugin_name = data["plugin"] + if not plugin_name in self._plugin_manager.plugins: + return make_response("Unknown plugin: %s" % plugin_name, 404) + + plugin = self._plugin_manager.plugins[plugin_name] + return self.command_toggle(plugin, command) + + elif command == "refresh_repository": + self._repository_available = self._refresh_repository() + return jsonify(repository=dict(available=self._repository_available, plugins=self._repository_plugins)) + + def command_install(self, url=None, path=None, force=False, reinstall=None): + if url is not None: + pip_args = ["install", sarge.shell_quote(url)] + elif path is not None: + pip_args = ["install", path] + else: + raise ValueError("Either url or path must be provided") + + all_plugins_before = self._plugin_manager.find_plugins() + + try: + returncode, stdout, stderr = self._call_pip(pip_args) + except: + self._logger.exception("Could not install plugin from %s" % url) + return make_response("Could not install plugin from url, see the log for more details", 500) + else: + if force: + pip_args += ["--ignore-installed", "--force-reinstall", "--no-deps"] + try: + returncode, stdout, stderr = self._call_pip(pip_args) + except: + self._logger.exception("Could not install plugin from %s" % url) + return make_response("Could not install plugin from url, see the log for more details", 500) + + try: + last_line = filter(lambda x: x.strip() != "", stdout)[-1] + except IndexError: + result = dict(result=False, reason="Could not parse output from pip") + self._send_result_notification("install", result) + return jsonify(result) + + # The last line of a pip install command looks something like this: + # + # Successfully installed OctoPrint-Plugin-1.0 Dependency-One-0.1 Dependency-Two-9.3 + # + # So we'll first strip the first part, then split by whitespace and remove the version to get the name of + # the installed package. By then matching up the package names stored for all our entry point plugins against + # the list of installed packages from our pip install run, we'll find the plugin that was installed. + + last_line = last_line.strip() + if not last_line.startswith("Successfully installed "): + result = dict(result=False, reason="Pip did not report successful installation") + self._send_result_notification("install", result) + return jsonify(result) + + installed = map(lambda x: x.strip().rsplit("-", 1)[0], last_line[len("Successfully installed "):].split(" ")) + all_plugins_after = self._plugin_manager.find_plugins(existing=dict(), ignore_uninstalled=False) + for key, plugin in all_plugins_after.items(): + if plugin.origin is None or plugin.origin[0] != "entry_point": + continue + + if plugin.origin[3] in installed: + new_plugin_key = key + new_plugin = plugin + break + else: + return make_response("Could not find plugin that was installed", 500) + + self._plugin_manager.mark_plugin(new_plugin_key, uninstalled=False) + self._plugin_manager.reload_plugins() + + needs_restart = self._plugin_manager.is_restart_needing_plugin(new_plugin) or new_plugin_key in all_plugins_before or reinstall is not None + needs_refresh = new_plugin.implementation and isinstance(new_plugin.implementation, octoprint.plugin.ReloadNeedingPlugin) + + self._plugin_manager.log_all_plugins() + + result = dict(result=True, url=url, needs_restart=needs_restart, needs_refresh=needs_refresh, was_reinstalled=new_plugin_key in all_plugins_before or reinstall is not None, plugin=self._to_external_representation(new_plugin)) + self._send_result_notification("install", result) + return jsonify(result) + + def command_uninstall(self, plugin): + if plugin.key == "pluginmanager": + return make_response("Can't uninstall Plugin Manager", 400) + + if plugin.bundled: + return make_response("Bundled plugins cannot be uninstalled", 400) + + if plugin.origin[0] == "entry_point": + # plugin is installed through entry point, need to use pip to uninstall it + origin = plugin.origin[3] + if origin is None: + origin = plugin.origin[2] + + pip_args = ["uninstall", "--yes", origin] + try: + self._call_pip(pip_args) + except: + self._logger.exception(u"Could not uninstall plugin via pip") + return make_response("Could not uninstall plugin via pip, see the log for more details", 500) + + elif plugin.origin[0] == "folder": + import os + import shutil + full_path = os.path.realpath(plugin.location) + + if os.path.isdir(full_path): + # plugin is installed via a plugin folder, need to use rmtree to get rid of it + self._log_stdout(u"Deleting plugin from {folder}".format(folder=plugin.location)) + shutil.rmtree(full_path) + elif os.path.isfile(full_path): + self._log_stdout(u"Deleting plugin from {file}".format(file=plugin.location)) + os.remove(full_path) + + if full_path.endswith(".py"): + pyc_file = "{full_path}c".format(**locals()) + if os.path.isfile(pyc_file): + os.remove(pyc_file) + + needs_restart = self._plugin_manager.is_restart_needing_plugin(plugin) + needs_refresh = plugin.implementation and isinstance(plugin.implementation, octoprint.plugin.ReloadNeedingPlugin) + + self._plugin_manager.mark_plugin(plugin.key, uninstalled=True) + + if not needs_restart: + try: + self._plugin_manager.disable_plugin(plugin.key, plugin=plugin) + except octoprint.plugin.core.PluginLifecycleException as e: + self._logger.exception(u"Problem disabling plugin {name}".format(name=plugin.key)) + result = dict(result=False, uninstalled=True, disabled=False, unloaded=False, reason=e.reason) + self._send_result_notification("uninstall", result) + return jsonify(result) + + try: + self._plugin_manager.unload_plugin(plugin.key) + except octoprint.plugin.core.PluginLifecycleException as e: + self._logger.exception(u"Problem unloading plugin {name}".format(name=plugin.key)) + result = dict(result=False, uninstalled=True, disabled=True, unloaded=False, reason=e.reason) + self._send_result_notification("uninstall", result) + return jsonify(result) + + self._plugin_manager.reload_plugins() + + result = dict(result=True, needs_restart=needs_restart, needs_refresh=needs_refresh, plugin=self._to_external_representation(plugin)) + self._send_result_notification("uninstall", result) + return jsonify(result) + + def command_toggle(self, plugin, command): + if plugin.key == "pluginmanager": + return make_response("Can't enable/disable Plugin Manager", 400) + + needs_restart = self._plugin_manager.is_restart_needing_plugin(plugin) + needs_refresh = plugin.implementation and isinstance(plugin.implementation, octoprint.plugin.ReloadNeedingPlugin) + + pending = ((command == "disable" and plugin.key in self._pending_enable) or (command == "enable" and plugin.key in self._pending_disable)) + needs_restart_api = needs_restart and not pending + needs_refresh_api = needs_refresh and not pending + + try: + if command == "disable": + self._mark_plugin_disabled(plugin, needs_restart=needs_restart) + elif command == "enable": + self._mark_plugin_enabled(plugin, needs_restart=needs_restart) + except octoprint.plugin.core.PluginLifecycleException as e: + self._logger.exception(u"Problem toggling enabled state of {name}: {reason}".format(name=plugin.key, reason=e.reason)) + result = dict(result=False, reason=e.reason) + except octoprint.plugin.core.PluginNeedsRestart: + result = dict(result=True, needs_restart=True, needs_refresh=True, plugin=self._to_external_representation(plugin)) + else: + result = dict(result=True, needs_restart=needs_restart_api, needs_refresh=needs_refresh_api, plugin=self._to_external_representation(plugin)) + + self._send_result_notification(command, result) + return jsonify(result) + + def _send_result_notification(self, action, result): + notification = dict(type="result", action=action) + notification.update(result) + self._plugin_manager.send_plugin_message(self._identifier, notification) + + def _call_pip(self, args): + pip_command = self._settings.get(["pip"]) + if pip_command is None: + import os + python_command = sys.executable + binary_dir = os.path.dirname(python_command) + + pip_command = os.path.join(binary_dir, "pip") + if sys.platform == "win32": + # Windows is a bit special... first of all the file will be called pip.exe, not just pip, and secondly + # for a non-virtualenv install (e.g. global install) the pip binary will not be located in the + # same folder as python.exe, but in a subfolder Scripts, e.g. + # + # C:\Python2.7\ + # |- python.exe + # `- Scripts + # `- pip.exe + + # virtual env? + pip_command = os.path.join(binary_dir, "pip.exe") + + if not os.path.isfile(pip_command): + # nope, let's try the Scripts folder then + scripts_dir = os.path.join(binary_dir, "Scripts") + if os.path.isdir(scripts_dir): + pip_command = os.path.join(scripts_dir, "pip.exe") + + if not os.path.isfile(pip_command) or not os.access(pip_command, os.X_OK): + raise RuntimeError(u"No pip path configured and {pip_command} does not exist or is not executable, can't install".format(**locals())) + + command = [pip_command] + args + + self._logger.debug(u"Calling: {}".format(" ".join(command))) + + p = sarge.run(" ".join(command), shell=True, async=True, stdout=sarge.Capture(), stderr=sarge.Capture()) + p.wait_events() + + all_stdout = [] + all_stderr = [] + try: + while p.returncode is None: + line = p.stderr.readline(timeout=0.5) + if line: + self._log_stderr(line) + all_stderr.append(line) + + line = p.stdout.readline(timeout=0.5) + if line: + self._log_stdout(line) + all_stdout.append(line) + + p.commands[0].poll() + + finally: + p.close() + + stderr = p.stderr.text + if stderr: + split_lines = stderr.split("\n") + self._log_stderr(*split_lines) + all_stderr += split_lines + + stdout = p.stdout.text + if stdout: + split_lines = stdout.split("\n") + self._log_stdout(*split_lines) + all_stdout += split_lines + + return p.returncode, all_stdout, all_stderr + + def _log_stdout(self, *lines): + self._log(lines, prefix=">", stream="stdout") + + def _log_stderr(self, *lines): + self._log(lines, prefix="!", stream="stderr") + + def _log(self, lines, prefix=None, stream=None, strip=True): + if strip: + lines = map(lambda x: x.strip(), lines) + + self._plugin_manager.send_plugin_message(self._identifier, dict(type="loglines", loglines=[dict(line=line, stream=stream) for line in lines])) + for line in lines: + self._console_logger.debug(u"{prefix} {line}".format(**locals())) + + def _mark_plugin_enabled(self, plugin, needs_restart=False): + disabled_list = list(self._settings.global_get(["plugins", "_disabled"])) + if plugin.key in disabled_list: + disabled_list.remove(plugin.key) + self._settings.global_set(["plugins", "_disabled"], disabled_list) + self._settings.save(force=True) + + if not needs_restart: + self._plugin_manager.enable_plugin(plugin.key) + else: + if plugin.key in self._pending_disable: + self._pending_disable.remove(plugin.key) + elif not plugin.enabled and plugin.key not in self._pending_enable: + self._pending_enable.add(plugin.key) + + def _mark_plugin_disabled(self, plugin, needs_restart=False): + disabled_list = list(self._settings.global_get(["plugins", "_disabled"])) + if not plugin.key in disabled_list: + disabled_list.append(plugin.key) + self._settings.global_set(["plugins", "_disabled"], disabled_list) + self._settings.save(force=True) + + if not needs_restart: + self._plugin_manager.disable_plugin(plugin.key) + else: + if plugin.key in self._pending_enable: + self._pending_enable.remove(plugin.key) + elif plugin.enabled and plugin.key not in self._pending_disable: + self._pending_disable.add(plugin.key) + + def _refresh_repository(self): + import requests + repository_url = self._settings.get(["repository"]) + try: + r = requests.get(repository_url) + except Exception as e: + self._logger.warn("Could not fetch plugins from repository at {repository_url}: {message}".format(repository_url=repository_url, message=str(e))) + return False + + current_os = self._get_os() + octoprint_version = self._get_octoprint_version() + if "-" in octoprint_version: + octoprint_version = octoprint_version[:octoprint_version.find("-")] + + def map_repository_entry(entry): + result = dict(entry) + result["is_compatible"] = dict( + octoprint=True, + os=True + ) + + if "compatibility" in entry: + if "octoprint" in entry["compatibility"]: + import semantic_version + for octo_compat in entry["compatibility"]["octoprint"]: + s = semantic_version.Spec("=={}".format(octo_compat)) + if semantic_version.Version(octoprint_version) in s: + break + else: + result["is_compatible"]["octoprint"] = False + + if "os" in entry["compatibility"]: + result["is_compatible"]["os"] = current_os in entry["compatibility"]["os"] + + return result + + self._repository_plugins = map(map_repository_entry, r.json()) + return True + + def _get_os(self): + if sys.platform == "win32": + return "windows" + elif sys.platform == "linux2": + return "linux" + elif sys.platform == "darwin": + return "macos" + else: + return "unknown" + + def _get_octoprint_version(self): + from octoprint._version import get_versions + return get_versions()["version"] + + def _to_external_representation(self, plugin): + return dict( + key=plugin.key, + name=plugin.name, + description=plugin.description, + author=plugin.author, + version=plugin.version, + url=plugin.url, + license=plugin.license, + bundled=plugin.bundled, + enabled=plugin.enabled, + pending_enable=(not plugin.enabled and plugin.key in self._pending_enable), + pending_disable=(plugin.enabled and plugin.key in self._pending_disable), + pending_install=(plugin.key in self._pending_install), + pending_uninstall=(plugin.key in self._pending_uninstall) + ) + +__plugin_name__ = "Plugin Manager" +__plugin_author__ = "Gina Häußge" +__plugin_url__ = "https://github.com/foosel/OctoPrint/wiki/Plugin:-Plugin-Manager" +__plugin_description__ = "Allows installing and managing OctoPrint plugins" +__plugin_license__ = "AGPLv3" +__plugin_implementation__ = PluginManagerPlugin() diff --git a/src/octoprint/plugins/pluginmanager/static/css/pluginmanager.css b/src/octoprint/plugins/pluginmanager/static/css/pluginmanager.css new file mode 100644 index 00000000..9f1da7f7 --- /dev/null +++ b/src/octoprint/plugins/pluginmanager/static/css/pluginmanager.css @@ -0,0 +1 @@ +table th.settings_plugin_plugin_manager_plugins_name,table td.settings_plugin_plugin_manager_plugins_name{text-overflow:ellipsis;text-align:left}table th.settings_plugin_plugin_manager_plugins_actions,table td.settings_plugin_plugin_manager_plugins_actions{text-align:center;width:80px}table th.settings_plugin_plugin_manager_plugins_actions a,table td.settings_plugin_plugin_manager_plugins_actions a{text-decoration:none;color:#000}table th.settings_plugin_plugin_manager_plugins_actions a.disabled,table td.settings_plugin_plugin_manager_plugins_actions a.disabled{color:#ccc;cursor:default}#settings_plugin_pluginmanager_repositorydialog .slimScrollDiv{margin-bottom:20px}#settings_plugin_pluginmanager_repositorydialog h4{position:relative}#settings_plugin_pluginmanager_repositorydialog h4 a.dropdown-toggle{color:inherit;text-decoration:none}#settings_plugin_pluginmanager_repositorydialog .form-search{text-align:center;margin-bottom:5px!important}#settings_plugin_pluginmanager_repositorydialog .form-inline{padding:5px;padding-right:10px;margin-bottom:0}#settings_plugin_pluginmanager_repositorydialog .form-inline .help-block{margin-bottom:0;font-size:85%}#settings_plugin_pluginmanager_repositorydialog #settings_plugin_pluginmanager_repositorydialog_unavailable{overflow:hidden;width:100%;height:300px;background-image:url("../img/repo_unavailable.png");text-align:center;display:table}#settings_plugin_pluginmanager_repositorydialog #settings_plugin_pluginmanager_repositorydialog_unavailable div{display:table-cell;vertical-align:middle}#settings_plugin_pluginmanager_repositorydialog #settings_plugin_pluginmanager_repositorydialog_list{overflow:hidden;width:auto;height:300px}#settings_plugin_pluginmanager_repositorydialog #settings_plugin_pluginmanager_repositorydialog_list .entry{border-bottom:1px solid #ddd;padding:5px;padding-right:10px} \ No newline at end of file diff --git a/src/octoprint/plugins/pluginmanager/static/img/repo_unavailable.png b/src/octoprint/plugins/pluginmanager/static/img/repo_unavailable.png new file mode 100644 index 0000000000000000000000000000000000000000..cc9f84d4436c07384b9c16b6579d896b1586a471 GIT binary patch literal 24812 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 literal 0 HcmV?d00001 diff --git a/src/octoprint/plugins/pluginmanager/static/js/pluginmanager.js b/src/octoprint/plugins/pluginmanager/static/js/pluginmanager.js new file mode 100644 index 00000000..81b0cb92 --- /dev/null +++ b/src/octoprint/plugins/pluginmanager/static/js/pluginmanager.js @@ -0,0 +1,573 @@ +$(function() { + function PluginManagerViewModel(parameters) { + var self = this; + + self.loginState = parameters[0]; + self.settingsViewModel = parameters[1]; + + self.plugins = new ItemListHelper( + "plugin.pluginmanager.installedplugins", + { + "name": function (a, b) { + // sorts ascending + if (a["name"].toLocaleLowerCase() < b["name"].toLocaleLowerCase()) return -1; + if (a["name"].toLocaleLowerCase() > b["name"].toLocaleLowerCase()) return 1; + return 0; + } + }, + { + }, + "name", + [], + [], + 5 + ); + + self.repositoryplugins = new ItemListHelper( + "plugin.pluginmanager.repositoryplugins", + { + "title": function (a, b) { + // sorts ascending + if (a["title"].toLocaleLowerCase() < b["title"].toLocaleLowerCase()) return -1; + if (a["title"].toLocaleLowerCase() > b["title"].toLocaleLowerCase()) return 1; + return 0; + }, + "published": function (a, b) { + // sorts descending + if (a["published"].toLocaleLowerCase() > b["published"].toLocaleLowerCase()) return -1; + if (a["published"].toLocaleLowerCase() < b["published"].toLocaleLowerCase()) return 1; + return 0; + } + }, + { + "filter_installed": function(plugin) { + return !self.installed(plugin); + }, + "filter_incompatible": function(plugin) { + return plugin.is_compatible.octoprint && plugin.is_compatible.os; + } + }, + "title", + ["filter_installed", "filter_incompatible"], + [], + 0 + ); + + self.uploadElement = $("#settings_plugin_pluginmanager_repositorydialog_upload"); + self.uploadButton = $("#settings_plugin_pluginmanager_repositorydialog_upload_start"); + + self.repositoryAvailable = ko.observable(false); + + self.repositorySearchQuery = ko.observable(); + self.repositorySearchQuery.subscribe(function() { + self.performRepositorySearch(); + }); + + self.installUrl = ko.observable(); + self.uploadFilename = ko.observable(); + + self.loglines = ko.observableArray([]); + self.installedPlugins = ko.observableArray([]); + + self.working = ko.observable(false); + self.workingTitle = ko.observable(); + self.workingDialog = undefined; + self.workingOutput = undefined; + + self.invalidUrl = ko.computed(function() { + var url = self.installUrl(); + return url !== undefined && url.trim() != "" && !(_.startsWith(url.toLocaleLowerCase(), "http://") || _.startsWith(url.toLocaleLowerCase(), "https://")); + }); + + self.enableUrlInstall = ko.computed(function() { + var url = self.installUrl(); + return url !== undefined && url.trim() != "" && !self.invalidUrl(); + }); + + self.invalidArchive = ko.computed(function() { + var name = self.uploadFilename(); + return name !== undefined && !(_.endsWith(name.toLocaleLowerCase(), ".zip") || _.endsWith(name.toLocaleLowerCase(), ".tar.gz") || _.endsWith(name.toLocaleLowerCase(), ".tgz") || _.endsWith(name.toLocaleLowerCase(), ".tar")); + }); + + self.enableArchiveInstall = ko.computed(function() { + var name = self.uploadFilename(); + return name !== undefined && name.trim() != "" && !self.invalidArchive(); + }); + + self.uploadElement.fileupload({ + dataType: "json", + maxNumberOfFiles: 1, + autoUpload: false, + add: function(e, data) { + if (data.files.length == 0) { + return false; + } + + self.uploadFilename(data.files[0].name); + + self.uploadButton.unbind("click"); + self.uploadButton.bind("click", function() { + self._markWorking(gettext("Installing plugin..."), gettext("Installing plugin from uploaded archive...")); + data.submit(); + return false; + }); + }, + done: function(e, data) { + self._markDone(); + self.uploadButton.unbind("click"); + self.uploadFilename(""); + }, + fail: function(e, data) { + new PNotify({ + title: gettext("Something went wrong"), + text: gettext("Please consult octoprint.log for details"), + type: "error", + hide: false + }); + self._markDone(); + self.uploadButton.unbind("click"); + self.uploadFilename(""); + } + }); + + self.performRepositorySearch = function() { + var query = self.repositorySearchQuery(); + if (query !== undefined && query.trim() != "") { + self.repositoryplugins.changeSearchFunction(function(entry) { + return entry && (entry["title"].toLocaleLowerCase().indexOf(query) > -1 || entry["description"].toLocaleLowerCase().indexOf(query) > -1); + }); + } else { + self.repositoryplugins.resetSearch(); + } + }; + + self.fromResponse = function(data) { + self._fromPluginsResponse(data.plugins); + self._fromRepositoryResponse(data.repository) + }; + + self._fromPluginsResponse = function(data) { + var installedPlugins = []; + _.each(data, function(plugin) { + installedPlugins.push(plugin.key); + }); + self.installedPlugins(installedPlugins); + self.plugins.updateItems(data); + }; + + self._fromRepositoryResponse = function(data) { + self.repositoryAvailable(data.available); + if (data.available) { + self.repositoryplugins.updateItems(data.plugins); + } else { + self.repositoryplugins.updateItems([]); + } + }; + + self.requestData = function(includeRepo) { + if (!self.loginState.isAdmin()) { + return; + } + + $.ajax({ + url: API_BASEURL + "plugin/pluginmanager" + ((includeRepo) ? "?refresh_repository=true" : ""), + type: "GET", + dataType: "json", + success: self.fromResponse + }); + }; + + self.togglePlugin = function(data) { + if (!self.loginState.isAdmin()) { + return; + } + + if (data.key == "pluginmanager") return; + + var command = self._getToggleCommand(data); + + var payload = {plugin: data.key}; + self._postCommand(command, payload, function(response) { + self.requestData(); + }, function() { + new PNotify({ + title: gettext("Something went wrong"), + text: gettext("Please consult octoprint.log for details"), + type: "error", + hide: false + }) + }); + }; + + self.showRepository = function() { + self.repositoryDialog.modal("show"); + }; + + self.pluginDetails = function(data) { + window.open(data.page); + }; + + self.installFromRepository = function(data) { + if (!self.loginState.isAdmin()) { + return; + } + + if (self.installed(data)) { + self.installPlugin(data.archive, data.title, data.id); + } else { + self.installPlugin(data.archive, data.title); + } + }; + + self.installPlugin = function(url, name, reinstall) { + if (!self.loginState.isAdmin()) { + return; + } + + if (url === undefined) { + url = self.installUrl(); + } + if (!url) return; + + var workTitle, workText; + if (!reinstall) { + workTitle = gettext("Installing plugin..."); + if (name) { + workText = _.sprintf(gettext("Installing plugin \"%(name)s\" from %(url)s..."), {url: url, name: name}); + } else { + workText = _.sprintf(gettext("Installing plugin from %(url)s..."), {url: url}); + } + } else { + workTitle = gettext("Reinstalling plugin..."); + workText = _.sprintf(gettext("Reinstalling plugin \"%(name)s\" from %(url)s..."), {url: url, name: name}); + } + self._markWorking(workTitle, workText); + + var command = "install"; + var payload = {url: url}; + if (reinstall) { + payload["plugin"] = reinstall; + payload["force"] = true; + } + + self._postCommand(command, payload, function(response) { + self.requestData(); + self._markDone(); + self.installUrl(""); + }, function() { + new PNotify({ + title: gettext("Something went wrong"), + text: gettext("Please consult octoprint.log for details"), + type: "error", + hide: false + }); + self._markDone(); + }); + }; + + self.uninstallPlugin = function(data) { + if (!self.loginState.isAdmin()) { + return; + } + + if (data.bundled) return; + if (data.key == "pluginmanager") return; + + self._markWorking(gettext("Uninstalling plugin..."), _.sprintf(gettext("Uninstalling plugin \"%(name)s\""), {name: data.name})); + + var command = "uninstall"; + var payload = {plugin: data.key}; + self._postCommand(command, payload, function(response) { + self.requestData(); + self._markDone(); + }, function() { + new PNotify({ + title: gettext("Something went wrong"), + text: gettext("Please consult octoprint.log for details"), + type: "error", + hide: false + }); + self._markDone(); + }); + }; + + self.refreshRepository = function() { + if (!self.loginState.isAdmin()) { + return; + } + + self._postCommand("refresh_repository", {}, function(data) { + self._fromRepositoryResponse(data.repository); + }) + }; + + self.installed = function(data) { + return _.includes(self.installedPlugins(), data.id); + }; + + self.isCompatible = function(data) { + return data.is_compatible.octoprint && data.is_compatible.os; + }; + + self.installButtonText = function(data) { + return self.isCompatible(data) ? (self.installed(data) ? gettext("Reinstall") : gettext("Install")) : gettext("Incompatible"); + }; + + self._displayNotification = function(response, titleSuccess, textSuccess, textRestart, textReload, titleError, textError) { + if (response.result) { + if (response.needs_restart) { + new PNotify({ + title: titleSuccess, + text: textRestart, + hide: false + }); + } else if (response.needs_refresh) { + new PNotify({ + title: titleSuccess, + text: textReload, + confirm: { + confirm: true, + buttons: [{ + text: gettext("Reload now"), + click: function () { + location.reload(true); + } + }] + }, + buttons: { + closer: false, + sticker: false + }, + hide: false + }) + } else { + new PNotify({ + title: titleSuccess, + text: textSuccess, + type: "success", + hide: false + }) + } + } else { + new PNotify({ + title: titleError, + text: textError, + type: "error", + hide: false + }); + } + }; + + self._postCommand = function (command, data, successCallback, failureCallback, alwaysCallback, timeout) { + var payload = _.extend(data, {command: command}); + + var params = { + url: API_BASEURL + "plugin/pluginmanager", + type: "POST", + dataType: "json", + data: JSON.stringify(payload), + contentType: "application/json; charset=UTF-8", + success: function(response) { + if (successCallback) successCallback(response); + }, + error: function() { + if (failureCallback) failureCallback(); + }, + complete: function() { + if (alwaysCallback) alwaysCallback(); + } + }; + + if (timeout != undefined) { + params.timeout = timeout; + } + + $.ajax(params); + }; + + self._markWorking = function(title, line) { + self.working(true); + self.workingTitle(title); + + self.loglines.removeAll(); + self.loglines.push({line: line, stream: "message"}); + + self.workingDialog.modal("show"); + }; + + self._markDone = function() { + self.working(false); + self.loglines.push({line: gettext("Done!"), stream: "message"}); + self._scrollWorkingOutputToEnd(); + }; + + self._scrollWorkingOutputToEnd = function() { + self.workingOutput.scrollTop(self.workingOutput[0].scrollHeight - self.workingOutput.height()); + }; + + self._getToggleCommand = function(data) { + return ((!data.enabled || data.pending_disable) && !data.pending_enable) ? "enable" : "disable"; + }; + + self.toggleButtonCss = function(data) { + var icon = self._getToggleCommand(data) == "enable" ? "icon-circle-blank" : "icon-circle"; + var disabled = (data.key == "pluginmanager") ? " disabled" : ""; + + return icon + disabled; + }; + + self.toggleButtonTitle = function(data) { + return self._getToggleCommand(data) == "enable" ? gettext("Enable Plugin") : gettext("Disable Plugin"); + }; + + self.onBeforeBinding = function() { + self.settings = self.settingsViewModel.settings; + }; + + self.onUserLoggedIn = function(user) { + if (user.admin) { + self.requestData(); + } + }; + + self.onStartup = function() { + self.workingDialog = $("#settings_plugin_pluginmanager_workingdialog"); + self.workingOutput = $("#settings_plugin_pluginmanager_workingdialog_output"); + self.repositoryDialog = $("#settings_plugin_pluginmanager_repositorydialog"); + + $("#settings_plugin_pluginmanager_repositorydialog_list").slimScroll({ + height: "306px", + size: "5px", + distance: "0", + railVisible: true, + alwaysVisible: true, + scrollBy: "102px" + }); + }; + + self.onDataUpdaterPluginMessage = function(plugin, data) { + if (plugin != "pluginmanager") { + return; + } + + if (!self.loginState.isAdmin()) { + return; + } + + if (!data.hasOwnProperty("type")) { + return; + } + + var messageType = data.type; + + if (messageType == "loglines" && self.working()) { + _.each(data.loglines, function(line) { + self.loglines.push(line); + }); + self._scrollWorkingOutputToEnd(); + } else if (messageType == "result") { + var titleSuccess, textSuccess, textRestart, textReload, titleError, textError; + var action = data.action; + + var name = "Unknown"; + if (action == "install") { + if (data.hasOwnProperty("plugin")) { + name = data.plugin.name; + } + + if (data.was_reinstalled) { + titleSuccess = _.sprintf(gettext("Plugin \"%(name)s\" reinstalled"), {name: name}); + textSuccess = gettext("The plugin was reinstalled successfully"); + textRestart = gettext("The plugin was reinstalled successfully, however a restart of OctoPrint is needed for that to take effect."); + textReload = gettext("The plugin was reinstalled successfully, however a reload of the page is needed for that to take effect."); + } else { + titleSuccess = _.sprintf(gettext("Plugin \"%(name)s\" installed"), {name: name}); + textSuccess = gettext("The plugin was installed successfully"); + textRestart = gettext("The plugin was installed successfully, however a restart of OctoPrint is needed for that to take effect."); + textReload = gettext("The plugin was installed successfully, however a reload of the page is needed for that to take effect."); + } + + titleError = gettext("Something went wrong"); + var url = "unknown"; + if (data.hasOwnProperty("url")) { + url = data.url; + } + + if (data.hasOwnProperty("reason")) { + if (data.was_reinstalled) { + textError = _.sprintf(gettext("Reinstalling the plugin from URL \"%(url)s\" failed: %(reason)s"), {reason: data.reason, url: url}); + } else { + textError = _.sprintf(gettext("Installing the plugin from URL \"%(url)s\" failed: %(reason)s"), {reason: data.reason, url: url}); + } + } else { + if (data.was_reinstalled) { + textError = _.sprintf(gettext("Reinstalling the plugin from URL \"%(url)s\" failed, please see the log for details."), {url: url}); + } else { + textError = _.sprintf(gettext("Installing the plugin from URL \"%(url)s\" failed, please see the log for details."), {url: url}); + } + } + + } else if (action == "uninstall") { + if (data.hasOwnProperty("plugin")) { + name = data.plugin.name; + } + + titleSuccess = _.sprintf(gettext("Plugin \"%(name)s\" uninstalled"), {name: name}); + textSuccess = gettext("The plugin was uninstalled successfully"); + textRestart = gettext("The plugin was uninstalled successfully, however a restart of OctoPrint is needed for that to take effect."); + textReload = gettext("The plugin was uninstalled successfully, however a reload of the page is needed for that to take effect."); + + titleError = gettext("Something went wrong"); + if (data.hasOwnProperty("reason")) { + textError = _.sprintf(gettext("Uninstalling the plugin failed: %(reason)s"), {reason: data.reason}); + } else { + textError = gettext("Uninstalling the plugin failed, please see the log for details."); + } + + } else if (action == "enable") { + if (data.hasOwnProperty("plugin")) { + name = data.plugin.name; + } + + titleSuccess = _.sprintf(gettext("Plugin \"%(name)s\" enabled"), {name: name}); + textSuccess = gettext("The plugin was enabled successfully."); + textRestart = gettext("The plugin was enabled successfully, however a restart of OctoPrint is needed for that to take effect."); + textReload = gettext("The plugin was enabled successfully, however a reload of the page is needed for that to take effect."); + + titleError = gettext("Something went wrong"); + if (data.hasOwnProperty("reason")) { + textError = _.sprintf(gettext("Toggling the plugin failed: %(reason)s"), {reason: data.reason}); + } else { + textError = gettext("Toggling the plugin failed, please see the log for details."); + } + + } else if (action == "disable") { + if (data.hasOwnProperty("plugin")) { + name = data.plugin.name; + } + + titleSuccess = _.sprintf(gettext("Plugin \"%(name)s\" disabled"), {name: name}); + textSuccess = gettext("The plugin was disabled successfully."); + textRestart = gettext("The plugin was disabled successfully, however a restart of OctoPrint is needed for that to take effect."); + textReload = gettext("The plugin was disabled successfully, however a reload of the page is needed for that to take effect."); + + titleError = gettext("Something went wrong"); + if (data.hasOwnProperty("reason")) { + textError = _.sprintf(gettext("Toggling the plugin failed: %(reason)s"), {reason: data.reason}); + } else { + textError = gettext("Toggling the plugin failed, please see the log for details."); + } + + } else { + return; + } + + self._displayNotification(data, titleSuccess, textSuccess, textRestart, textReload, titleError, textError); + self.requestData(); + } + }; + } + + // view model class, parameters for constructor, container to bind to + ADDITIONAL_VIEWMODELS.push([PluginManagerViewModel, ["loginStateViewModel", "settingsViewModel"], "#settings_plugin_pluginmanager"]); +}); diff --git a/src/octoprint/plugins/pluginmanager/static/less/pluginmanager.less b/src/octoprint/plugins/pluginmanager/static/less/pluginmanager.less new file mode 100644 index 00000000..5d8babd5 --- /dev/null +++ b/src/octoprint/plugins/pluginmanager/static/less/pluginmanager.less @@ -0,0 +1,81 @@ +table { + th, td { + &.settings_plugin_plugin_manager_plugins_name { + text-overflow: ellipsis; + text-align: left; + } + + &.settings_plugin_plugin_manager_plugins_actions { + text-align: center; + width: 80px; + + a { + text-decoration: none; + color: #000; + + &.disabled { + color: #ccc; + cursor: default; + } + } + } + } +} + +#settings_plugin_pluginmanager_repositorydialog { + .slimScrollDiv { + margin-bottom: 20px; + } + + h4 { + position: relative; + + a.dropdown-toggle { + color: inherit; + text-decoration: none; + } + } + + .form-search { + text-align: center; + margin-bottom: 5px !important; + } + + .form-inline { + padding: 5px; + padding-right: 10px; + margin-bottom: 0; + + .help-block { + margin-bottom: 0; + font-size: 85%; + } + } + + #settings_plugin_pluginmanager_repositorydialog_unavailable { + overflow: hidden; + width: 100%; + height: 300px; + background-image: url("../img/repo_unavailable.png"); + text-align: center; + display: table; + + div { + display: table-cell; + vertical-align: middle; + } + } + + #settings_plugin_pluginmanager_repositorydialog_list { + overflow: hidden; + width: auto; + height: 300px; + + .entry { + border-bottom: 1px solid #ddd; + padding: 5px; + padding-right: 10px; + } + } +} + diff --git a/src/octoprint/plugins/pluginmanager/templates/pluginmanager_settings.jinja2 b/src/octoprint/plugins/pluginmanager/templates/pluginmanager_settings.jinja2 new file mode 100644 index 00000000..2f436ad8 --- /dev/null +++ b/src/octoprint/plugins/pluginmanager/templates/pluginmanager_settings.jinja2 @@ -0,0 +1,145 @@ +

{{ _('Installed Plugins') }}

+ + + + + + + + + + + + + + +
{{ _('Name') }}{{ _('Actions') }}
+
()
+
 
+
+ {{ _('Homepage') }} + + +   +
+
+  |  +
+ + + + + + + diff --git a/src/octoprint/settings.py b/src/octoprint/settings.py index cc97ad1e..8a4ad5c5 100644 --- a/src/octoprint/settings.py +++ b/src/octoprint/settings.py @@ -183,7 +183,7 @@ default_settings = { "settings": [ "section_printer", "serial", "printerprofiles", "temperatures", "terminalfilters", "gcodescripts", "section_features", "features", "webcam", "accesscontrol", "api", - "section_octoprint", "folders", "appearance", "logs" + "section_octoprint", "folders", "appearance", "logs", "plugin_pluginmanager" ], "usersettings": ["access", "interface"], "generic": []