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 00000000..cc9f84d4 Binary files /dev/null and b/src/octoprint/plugins/pluginmanager/static/img/repo_unavailable.png differ 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": []