diff options
author | Gina HΓ€uΓge <gina@octoprint.org> | 2022-10-26 11:41:22 +0300 |
---|---|---|
committer | Gina HΓ€uΓge <gina@octoprint.org> | 2022-10-26 11:41:22 +0300 |
commit | f993ab05af2d9f1a3f8df59577bfd845ed06cc32 (patch) | |
tree | 06ede91b647e0ce5266238e88430f602697ea926 | |
parent | cdde1bbc07f35798dce1d375fa539d4915d7cb2c (diff) | |
parent | 0224a02d908a6a88d7b9a25e5e9845fdf833d3d0 (diff) |
Merge branch 'maintenance' into devel
18 files changed, 772 insertions, 166 deletions
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c6f89e6d6..9eb7968ca 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -11,11 +11,11 @@ jobs: name: π¨ Build distribution runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 with: fetch-depth: 0 - name: π Set up Python 3.7 - uses: actions/setup-python@v1 + uses: actions/setup-python@v4 with: python-version: 3.7 - name: π Install build dependencies @@ -25,7 +25,7 @@ jobs: run: | python setup.py sdist bdist_wheel - name: β¬ Upload build result - uses: actions/upload-artifact@v1 + uses: actions/upload-artifact@v3 with: name: dist path: dist @@ -34,9 +34,9 @@ jobs: name: π§Ή Pre-commit runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: π Set up Python 3.7 - uses: actions/setup-python@v1 + uses: actions/setup-python@v4 with: python-version: 3.7 - name: π Set up dev dependencies @@ -53,9 +53,9 @@ jobs: python: ["3.7", "3.8", "3.9", "3.10"] runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: π Set up Python ${{ matrix.python }} - uses: actions/setup-python@v1 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python }} - name: π Set up test dependencies @@ -70,7 +70,7 @@ jobs: needs: build runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: π Set up test dependencies run: | npm -g install node-qunit-puppeteer@2.1.0 @@ -89,12 +89,12 @@ jobs: runs-on: ubuntu-latest steps: - name: β¬ Download build result - uses: actions/download-artifact@v1 + uses: actions/download-artifact@v3 with: name: dist path: dist - name: π Set up Python ${{ matrix.python }} - uses: actions/setup-python@v1 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python }} - name: π Install wheel @@ -113,15 +113,15 @@ jobs: continue-on-error: true steps: - name: β¬ Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: β¬ Download build result - uses: actions/download-artifact@v1 + uses: actions/download-artifact@v3 with: name: dist path: dist - name: π Set up Python 3.7 - uses: actions/setup-python@v1 + uses: actions/setup-python@v4 with: python-version: 3.7 - name: π Install wheel @@ -134,7 +134,7 @@ jobs: cp -r .github/fixtures/with_acl/* e2econfig - name: π Run Cypress - uses: cypress-io/github-action@v2 + uses: cypress-io/github-action@v4.2.0 with: working-directory: tests/cypress browser: chrome @@ -146,7 +146,7 @@ jobs: CYPRESS_DEPLOYSENTINEL_KEY: ${{ secrets.DEPLOYSENTINEL_APIKEY }} - name: β¬ Upload screenshots - uses: actions/upload-artifact@v1 + uses: actions/upload-artifact@v3 if: failure() with: name: cypress-screenshots @@ -170,7 +170,7 @@ jobs: runs-on: ubuntu-latest steps: - name: β¬ Download build result - uses: actions/download-artifact@v1 + uses: actions/download-artifact@v3 with: name: dist path: dist @@ -188,7 +188,7 @@ jobs: runs-on: ubuntu-latest steps: - name: β¬ Download build result - uses: actions/download-artifact@v1 + uses: actions/download-artifact@v3 with: name: dist path: dist diff --git a/.github/workflows/issue_automation.yml b/.github/workflows/issue_automation.yml index 13830c66a..ab645ab90 100644 --- a/.github/workflows/issue_automation.yml +++ b/.github/workflows/issue_automation.yml @@ -7,7 +7,7 @@ jobs: issue-automation: runs-on: ubuntu-latest steps: - - uses: actions/github-script@v5 + - uses: actions/github-script@v6 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | @@ -72,7 +72,7 @@ jobs: # with: # repo-token: ${{ secrets.GITHUB_TOKEN }} - - uses: actions/github-script@v5 + - uses: actions/github-script@v6 env: REMINDER: > Hi @${{ github.event.issue.user.login }}! diff --git a/.github/workflows/linkify_bundles.yml b/.github/workflows/linkify_bundles.yml index 9e0553891..18bc21a07 100644 --- a/.github/workflows/linkify_bundles.yml +++ b/.github/workflows/linkify_bundles.yml @@ -7,7 +7,7 @@ jobs: linkifyBundles: runs-on: ubuntu-latest steps: - - uses: actions/github-script@v2 + - uses: actions/github-script@v6 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | @@ -38,14 +38,14 @@ jobs: }); text += "\n*edited by @github-actions to add bundle viewer links*\n"; - github.issues.updateComment({ + github.rest.issues.updateComment({ owner: context.repo.owner, repo: context.repo.repo, comment_id: context.payload.comment.id, body: text }); } else if (botWasHere !== -1) { - github.issues.updateComment({ + github.rest.issues.updateComment({ owner: context.repo.owner, repo: context.repo.repo, comment_id: context.payload.comment.id, diff --git a/.github/workflows/nightly_merge.yml b/.github/workflows/nightly_merge.yml index 56821fbc0..e101eb722 100644 --- a/.github/workflows/nightly_merge.yml +++ b/.github/workflows/nightly_merge.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest steps: - name: β¬ Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: fetch-depth: 0 diff --git a/.github/workflows/pr_automation.yml b/.github/workflows/pr_automation.yml index cc411d121..a7f7ef7cf 100644 --- a/.github/workflows/pr_automation.yml +++ b/.github/workflows/pr_automation.yml @@ -12,7 +12,7 @@ jobs: with: repo-token: "${{ secrets.GITHUB_TOKEN }}" - - uses: actions/github-script@v5 + - uses: actions/github-script@v6 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | diff --git a/.github/workflows/test_install.yml b/.github/workflows/test_install.yml index 76d7b2e23..18f6d7b39 100644 --- a/.github/workflows/test_install.yml +++ b/.github/workflows/test_install.yml @@ -19,11 +19,11 @@ jobs: runs-on: ${{ matrix.os }} steps: - name: π Set up Python ${{ matrix.python }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python }} - name: β¬ Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: ref: ${{ matrix.branch }} fetch-depth: 0 @@ -60,7 +60,7 @@ jobs: with: repository: "OctoPrint/OctoPrint" - name: π Set up Python ${{ matrix.python }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python }} - name: π· Build and install latest release diff --git a/AUTHORS.md b/AUTHORS.md index 3fe4458b3..c491b2bc9 100644 --- a/AUTHORS.md +++ b/AUTHORS.md @@ -172,6 +172,7 @@ date of first contribution): * [Dawid Pieper](https://github.com/dawidpieper) * ["arrdem"](https://github.com/arrdem) * [Arkadiusz MiΕkiewicz](https://github.com/arekm) + * ["tempodat"](https://github.com/tempodat) * [Frederik Kemner](https://github.com/040medien) OctoPrint started off as a fork of [Cura](https://github.com/daid/Cura) by diff --git a/SECURITY.md b/SECURITY.md index 79b94cdd9..0722f9a3c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,11 +1,3 @@ # Security Policy -## Supported Versions - -Always update to the latest version of OctoPrint to keep up with security patches. - -## Reporting a Vulnerability - -Email to security@octoprint.org. - -For the sake of the userbase of OctoPrint please always disclose responsibly and with a 90+ day window. +OctoPrint's security policy can be found [here](https://octoprint.org/security/). diff --git a/src/octoprint/plugins/gcodeviewer/__init__.py b/src/octoprint/plugins/gcodeviewer/__init__.py index a72b1f860..a145c3887 100644 --- a/src/octoprint/plugins/gcodeviewer/__init__.py +++ b/src/octoprint/plugins/gcodeviewer/__init__.py @@ -1,15 +1,20 @@ __license__ = "GNU Affero General Public License http://www.gnu.org/licenses/agpl.html" __copyright__ = "Copyright (C) 2020 The OctoPrint Project - Released under terms of the AGPLv3 License" +import os + +import flask from flask_babel import gettext import octoprint.plugin +from octoprint.util.files import search_through_file class GcodeviewerPlugin( octoprint.plugin.AssetPlugin, octoprint.plugin.TemplatePlugin, octoprint.plugin.SettingsPlugin, + octoprint.plugin.BlueprintPlugin, ): def get_assets(self): js = [ @@ -70,6 +75,30 @@ class GcodeviewerPlugin( self._settings.set_int(["sizeThreshold"], config["sizeThreshold"]) self._settings.global_remove(["gcodeViewer"]) + @octoprint.plugin.BlueprintPlugin.route( + "/skipuntilcheck/<string:origin>/<path:filename>", methods=["GET"] + ) + def check_skip_until_presence(self, origin, filename): + try: + path = self._file_manager.path_on_disk(origin, filename) + except NotImplementedError: + # storage doesn't support path on disk + flask.abort(404) + + if not os.path.exists(path): + # path doesn't exist + flask.abort(404) + + skipUntilThis = self._settings.get(["skipUntilThis"]) + if not skipUntilThis: + # no skipUntilThis, no need to search, shortcut + return flask.jsonify(present=False) + + return flask.jsonify(present=search_through_file(path, skipUntilThis)) + + def is_blueprint_csrf_protected(self): + return True + __plugin_name__ = gettext("GCode Viewer") __plugin_author__ = "Gina HΓ€uΓge" diff --git a/src/octoprint/plugins/gcodeviewer/static/js/gcodeviewer.js b/src/octoprint/plugins/gcodeviewer/static/js/gcodeviewer.js index aaf38a58d..23d1c8552 100644 --- a/src/octoprint/plugins/gcodeviewer/static/js/gcodeviewer.js +++ b/src/octoprint/plugins/gcodeviewer/static/js/gcodeviewer.js @@ -16,14 +16,6 @@ $(function () { self.ui_progress_text = ko.pureComputed(function () { var text = ""; switch (self.ui_progress_type()) { - case "downloading": { - text = gettext("Downloading..."); - break; - } - case "splitting": { - text = gettext("Splitting lines..."); - break; - } case "parsing": { text = gettext("Parsing...") + @@ -396,6 +388,7 @@ $(function () { onProgress: self._onProgress, onModelLoaded: self._onModelLoaded, onLayerSelected: self._onLayerSelected, + onFileLoaded: self._onFileLoaded, bed: self._retrieveBedDimensions(), toolOffsets: self._retrieveToolOffsets(), invertAxes: self._retrieveAxesConfiguration(), @@ -517,53 +510,35 @@ $(function () { self.needsLoad = false; if (self.status === "idle" && self.errorCount < 3) { self.status = "request"; - self._onProgress("downloading"); - OctoPrint.files - .download("local", path) - .done(function (response, rstatus) { - if (rstatus === "success") { - self.showGCodeViewer(response, rstatus); - self.loadedFilepath = path; - self.loadedFileDate = date; - self.status = "idle"; - self.enableReload(true); - } - }) - .fail(function () { - self.status = "idle"; - self.errorCount++; - }); - } - }; - self.showGCodeViewer = function (response, rstatus) { - // Slice of the gcode - var findThis = self.settings.settings.plugins.gcodeviewer.skipUntilThis(); - if (findThis && findThis !== "") { - var indexPos = response.indexOf("\n" + findThis); - if (indexPos !== -1) { - // Slice and make sure we comment out any string left back after slicing - so if a user configures something like "G1" we dont end up with a snippet of gcode commands - // Yes it would be prettier to parse it line by line and remove the entire line, that is very slow and uses mem - this way we find the string, and remove it - response = ";" + response.slice(indexPos + findThis.length + 1); + self.cachedPath = path; + self.cachedDate = date; + var par = { + url: OctoPrint.files.downloadPath("local", path), + path: path, + skipUntil: self.settings.settings.plugins.gcodeviewer.skipUntilThis() + }; + + GCODE.renderer.clear(); + self._onProgress("parsing"); + GCODE.gCodeReader.loadFile(par); + + if (self.layerSlider !== undefined) { + self.layerSlider.slider("disable"); } - } - var par = { - target: { - result: response + if (self.layerCommandSlider !== undefined) { + self.layerCommandSlider.slider("disable"); } - }; - GCODE.renderer.clear(); - self._onProgress("splitting"); - GCODE.gCodeReader.loadFile(par); - - if (self.layerSlider !== undefined) { - self.layerSlider.slider("disable"); - } - if (self.layerCommandSlider !== undefined) { - self.layerCommandSlider.slider("disable"); } }; + self._onFileLoaded = function () { + self.loadedFilepath = self.cachedPath; + self.loadedFileDate = self.cachedDate; + self.status = "idle"; + self.enableReload(true); + }; + self.reload = function () { if (!self.enableReload()) return; self.loadFile(self.loadedFilepath, self.loadedFileDate); diff --git a/src/octoprint/plugins/gcodeviewer/static/js/viewer/reader.js b/src/octoprint/plugins/gcodeviewer/static/js/viewer/reader.js index 7afd71cc8..31df5d3ab 100644 --- a/src/octoprint/plugins/gcodeviewer/static/js/viewer/reader.js +++ b/src/octoprint/plugins/gcodeviewer/static/js/viewer/reader.js @@ -210,25 +210,6 @@ GCODE.gCodeReader = (function () { loadFile: function (reader) { this.clear(); - var totalSize = reader.target.result.length; - - /* - * Split by line ending - * - * Be aware that for windows line endings \r\n this leaves the \r attached to - * the lines. That will not influence our parser, but makes file position - * calculation way easier (line length + 1), so we just leave it in. - * - * This cannot cope with old MacOS \r line endings, but those should - * really not be used anymore and thus we'll happily ignore them here. - * - * Note: A simple string split uses up *much* less memory than regex. - */ - lines = reader.target.result.split("\n"); - - reader.target.result = null; - prepareGCode(totalSize); - var mustCompress = gCodeOptions["forceCompression"] || gCodeOptions["alwaysCompress"] || @@ -236,9 +217,11 @@ GCODE.gCodeReader = (function () { gCodeOptions["compressionSizeThreshold"] <= totalSize); GCODE.ui.worker.postMessage({ - cmd: "parseGCode", + cmd: "downloadAndParseGCode", msg: { - gcode: gcode, + url: reader.url, + path: reader.path, + skipUntil: reader.skipUntil, options: { firstReport: 5, toolOffsets: gCodeOptions["toolOffsets"], diff --git a/src/octoprint/plugins/gcodeviewer/static/js/viewer/ui.js b/src/octoprint/plugins/gcodeviewer/static/js/viewer/ui.js index 3e9672f22..9fa1494ae 100644 --- a/src/octoprint/plugins/gcodeviewer/static/js/viewer/ui.js +++ b/src/octoprint/plugins/gcodeviewer/static/js/viewer/ui.js @@ -13,7 +13,8 @@ GCODE.ui = (function () { bedDimensions: undefined, onProgress: undefined, onModelLoaded: undefined, - onLayerSelected: undefined + onLayerSelected: undefined, + onFileLoaded: undefined }; var setProgress = function (type, progress) { @@ -49,6 +50,9 @@ GCODE.ui = (function () { var data = e.data; switch (data.cmd) { case "returnModel": + if (uiOptions["onFileLoaded"]) { + uiOptions.onFileLoaded(); + } GCODE.ui.worker.postMessage({ cmd: "analyzeModel", msg: {} diff --git a/src/octoprint/plugins/gcodeviewer/static/js/viewer/worker.js b/src/octoprint/plugins/gcodeviewer/static/js/viewer/worker.js index dcdd0c69b..c1be5700c 100644 --- a/src/octoprint/plugins/gcodeviewer/static/js/viewer/worker.js +++ b/src/octoprint/plugins/gcodeviewer/static/js/viewer/worker.js @@ -4,7 +4,10 @@ * Time: 12:18 PM */ -var gcode; +// raw path suitable for fetch() +var url; +// path relative to Local +var path; var firstReport; var toolOffsets = [{x: 0, y: 0}]; var g90InfluencesExtruder = false; @@ -40,6 +43,8 @@ var emptyLayers = []; var percentageByLayer = []; var mustCompress = false; +var skipUntil = null; +var skipUntilPresent = false; importScripts("../lib/pako.js"); @@ -337,7 +342,73 @@ var analyzeModel = function () { sendAnalyzeDone(); }; -var doParse = function () { +var gCodeLineGenerator = async function* (fileURL) { + const utf8Decoder = new TextDecoder("utf-8"); + const response = await fetch(fileURL); + + // the download failed. + if (!response.ok) return; + + // create a reader object that will read the incoming data + const reader = response.body.getReader(); + + // we use these two variables to calculate the percentage. + var totalDownloadLength = response.headers.get("content-length"); + var currentDownloadLength = 0; + + // lets read a first data chunk + let {value: chunk, done: readerDone} = await reader.read(); + chunk = chunk ? utf8Decoder.decode(chunk) : ""; + + // some init + const re = /\n|\r|\r\n/gm; + let startIndex = 0; + let result; + + // now continue until all the downloaded data is processed. + for (;;) { + // cut at a new line + let result = re.exec(chunk); + if (!result) { + // there was not a complete line, what is up? + + if (readerDone) { + // we reached the end of the file. + break; + } + // lets read a new chunk + let remainder = chunk.substr(startIndex); + ({value: chunk, done: readerDone} = await reader.read()); + // concatenate with our leftovers from the previous chunk + chunk = remainder + (chunk ? utf8Decoder.decode(chunk) : ""); + // reset the indexes + startIndex = re.lastIndex = 0; + continue; + } + // we use re.lastIndex and not result.index so we include + // the actual terminator. + currentDownloadLength += re.lastIndex - startIndex; + // return the data to the caller + yield [ + chunk.substring(startIndex, result.index), + (100 * currentDownloadLength) / totalDownloadLength + ]; + + // move to after the line we just returned + startIndex = re.lastIndex; + } + if (startIndex < chunk.length) { + // last line didn't end in a newline char + currentDownloadLength += chunk.length - startIndex; + // return the data to the caller + yield [ + chunk.substr(startIndex), + (100 * currentDownloadLength) / totalDownloadLength + ]; + } +}; + +var doParse = async function () { var argChar, numSlice; var activeLayer = undefined; var sendLayer = undefined; @@ -376,10 +447,23 @@ var doParse = function () { // visualizer doesn't actually have a physical offset ;) var activeToolOffset = toolOffsets[0]; + // skipUntil preparations + var skipUntilFound = false; + var i, j, args; + // if skipUntil is set, get skipUntilPresent + skipUntilPresent = false; + if (skipUntil !== undefined && skipUntil !== "") { + result = await fetch("/plugin/gcodeviewer/skipuntilcheck/local/" + path); + if (result.ok) { + response = await result.json(); + skipUntilPresent = response.present; + } + } + model = []; - for (i = 0; i < gcode.length; i++) { + for await (let [line, percentage] of gCodeLineGenerator(url)) { x = undefined; y = undefined; z = undefined; @@ -388,8 +472,11 @@ var doParse = function () { center_j = undefined; direction = undefined; - var line = gcode[i].line; - var percentage = gcode[i].percentage; + // find the skipUntil if it is present + // we do not actually remove the line, it is parsed normally. + if (skipUntilPresent && !skipUntilFound && line.startsWith(skipUntil)) { + skipUntilFound = true; + } extrude = false; line = line.split(/[\(;]/)[0]; @@ -646,7 +733,9 @@ var doParse = function () { zLift = false; } - if (addToModel) { + // if skipUntilPresent is true, we will not add anything to + // the model until the skipUntil string is found. + if (addToModel && (!skipUntilPresent || skipUntilFound)) { if (!model[layer]) model[layer] = []; if (model[layer] instanceof Uint8Array) model[layer] = decompress(model[layer]); @@ -748,9 +837,9 @@ var doParse = function () { } if (typeof sendLayer !== "undefined") { - if (i - lastSend > gcode.length * 0.02 && sendMultiLayer.length !== 0) { - lastSend = i; - sendLayersToParent(sendMultiLayer, (i / gcode.length) * 100); + if (percentage - lastSend > 2 && sendMultiLayer.length !== 0) { + lastSend = percentage; + sendLayersToParent(sendMultiLayer, percentage); sendMultiLayer = []; sendMultiLayerZ = []; } @@ -770,8 +859,9 @@ var doParse = function () { sendLayersToParent(sendMultiLayer, 100); }; -var parseGCode = function (message) { - gcode = message.gcode; +var parseGCode = async function (message) { + url = message.url; + path = message.path; firstReport = message.options.firstReport; toolOffsets = message.options.toolOffsets; if (!toolOffsets || toolOffsets.length === 0) toolOffsets = [{x: 0, y: 0}]; @@ -780,9 +870,9 @@ var parseGCode = function (message) { g90InfluencesExtruder = message.options.g90InfluencesExtruder; boundingBox.minZ = min.z = message.options.bedZ; mustCompress = message.options.compress; + skipUntil = message.skipUntil; - doParse(); - gcode = []; + await doParse(); self.postMessage({ cmd: "returnModel", msg: {} @@ -827,7 +917,7 @@ onmessage = function (e) { var data = e.data; // for some reason firefox doesn't garbage collect when something inside closures is deleted, so we delete and recreate whole object eaech time switch (data.cmd) { - case "parseGCode": + case "downloadAndParseGCode": parseGCode(data.msg); break; case "setOption": diff --git a/src/octoprint/plugins/gcodeviewer/templates/gcodeviewer_settings.jinja2 b/src/octoprint/plugins/gcodeviewer/templates/gcodeviewer_settings.jinja2 index a83b4a90a..0fc5342f8 100644 --- a/src/octoprint/plugins/gcodeviewer/templates/gcodeviewer_settings.jinja2 +++ b/src/octoprint/plugins/gcodeviewer/templates/gcodeviewer_settings.jinja2 @@ -7,9 +7,6 @@ <span class="help-block"> {% trans %}If provided, the GCode Viewer will search for a line beginning with this string. If found, all GCode up to that point will be skipped. This can be used to skip "purge/nozzle wipe" GCode in the viewer. Note the search is case sensitive.{% endtrans %} </span> - <span class="help-block"> - {% trans %}<strong>Important!</strong> Make sure that any setup codes (especially things like <code>G20</code>/<code>G21</code>, <code>G90</code>/<code>G91</code>, <code>M82</code>/<code>M83</code>) are <strong>not</strong> skipped, or your files might not get rendered correctly! If things look broken with a skip configuration, you are skipping too much.{% endtrans %} - </span> </div> </div> <div class="control-group" title="{{ _('Maximum size for which the GCode Viewer autoloads the file for preview')|edq }}"> diff --git a/src/octoprint/plugins/pluginmanager/__init__.py b/src/octoprint/plugins/pluginmanager/__init__.py index 1063aa5d1..a32123038 100644 --- a/src/octoprint/plugins/pluginmanager/__init__.py +++ b/src/octoprint/plugins/pluginmanager/__init__.py @@ -33,7 +33,7 @@ from octoprint.server.util.flask import ( with_revalidation_checking, ) from octoprint.settings import valid_boolean_trues -from octoprint.util import deprecated, to_bytes +from octoprint.util import RepeatedTimer, deprecated, to_bytes from octoprint.util.net import download_file from octoprint.util.pip import ( OUTPUT_SUCCESS, @@ -193,6 +193,10 @@ class PluginManagerPlugin( self._install_task = None self._install_lock = threading.RLock() + self._queued_installs = [] + self._queued_installs_abort_timer = None + self._print_cancelled = False + def initialize(self): self._console_logger = logging.getLogger( "octoprint.plugins.pluginmanager.console" @@ -619,7 +623,42 @@ class PluginManagerPlugin( def on_event(self, event, payload): from octoprint.events import Events - if ( + if event == Events.PRINT_STARTED: + self._queued_installs_timer_stop() + self._print_cancelled = False + elif ( + event == Events.PRINT_DONE + and self._settings.global_get(["webcam", "timelapse", "type"]) == "off" + and len(self._queued_installs) > 0 + ): + self._queued_installs_timer_start() + elif event == Events.PRINT_FAILED and len(self._queued_installs) > 0: + self._send_result_notification( + "queued_installs", + { + "type": "queued_installs", + "print_failed": True, + "queued": self._queued_installs, + }, + ) + self._print_cancelled = True + elif ( + event == Events.MOVIE_DONE + and self._settings.global_get(["webcam", "timelapse", "type"]) != "off" + and len(self._queued_installs) > 0 + and not (self._printer.is_printing() or self._printer.is_paused()) + and not self._print_cancelled + ): + self._queued_installs_timer_start() + elif event == Events.USER_LOGGED_IN and len(self._queued_installs) > 0: + self._send_result_notification( + "queued_installs", + { + "type": "queued_installs", + "queued": self._queued_installs, + }, + ) + elif ( event != Events.CONNECTIVITY_CHANGED or not payload or not payload.get("new", False) @@ -627,6 +666,53 @@ class PluginManagerPlugin( return self._fetch_all_data(do_async=True) + def _queued_installs_timer_start(self): + if self._queued_installs_abort_timer is not None: + return + + self._logger.debug("Starting queued updates timer.") + + self._timeout_value = 60 + self._queued_installs_abort_timer = RepeatedTimer( + 1, self._queued_installs_timer_task + ) + self._queued_installs_abort_timer.start() + + def _queued_installs_timer_stop(self): + if self._queued_installs_abort_timer is not None: + self._queued_installs_abort_timer.cancel() + self._queued_installs_abort_timer = None + self._send_result_notification( + "queued_installs", + { + "type": "queued_installs", + "queued": self._queued_installs, + "timeout_value": -1, + }, + ) + + def _queued_installs_timer_task(self): + if self._timeout_value is None: + return + + self._timeout_value -= 1 + self._send_result_notification( + "queued_installs", + { + "type": "queued_installs", + "queued": self._queued_installs, + "timeout_value": self._timeout_value, + }, + ) + if self._timeout_value <= 0: + if self._queued_installs_abort_timer is not None: + self._queued_installs_abort_timer.cancel() + self._queued_installs_abort_timer = None + for plugin in self._queued_installs: + plugin.pop("command") + self.command_install(**plugin) + self._queued_installs = [] + ##~~ SimpleApiPlugin def get_api_commands(self): @@ -638,17 +724,48 @@ class PluginManagerPlugin( "cleanup": ["plugin"], "cleanup_all": [], "refresh_repository": [], + "clear_queued_plugin": ["plugin"], + "clear_queued_installs": [], } def on_api_command(self, command, data): if not Permissions.PLUGIN_PLUGINMANAGER_MANAGE.can(): abort(403) - if self._printer.is_printing() or self._printer.is_paused(): + if command == "clear_queued_plugin": + if data["plugin"] and data["plugin"] in self._queued_installs: + self._queued_installs.remove(data["plugin"]) + return ( + jsonify({"queued_installs": self._queued_installs}), + 202, + ) + + elif command == "clear_queued_installs": + self._queued_installs.clear() + return ( + jsonify({"queued_installs": self._queued_installs}), + 202, + ) + + elif self._printer.is_printing() or self._printer.is_paused(): # do not update while a print job is running - abort(409, description="Printer is currently printing or paused") + # store targets to be run later on print done event + if command == "install" and data not in self._queued_installs: + self._logger.debug(f"Queuing install of {data}") + self._queued_installs.append(data) + if len(self._queued_installs) > 0: + self._logger.debug(f"Queued installs: {self._queued_installs}") + return ( + jsonify({"queued_installs": self._queued_installs}), + 202, + ) + else: + abort( + 409, + description="Printer is currently printing or paused and install could not be queued", + ) - if command == "install": + elif command == "install": if not Permissions.PLUGIN_PLUGINMANAGER_INSTALL.can(): abort(403) url = data["url"] diff --git a/src/octoprint/plugins/pluginmanager/static/js/pluginmanager.js b/src/octoprint/plugins/pluginmanager/static/js/pluginmanager.js index 65efd2101..6afae8e33 100644 --- a/src/octoprint/plugins/pluginmanager/static/js/pluginmanager.js +++ b/src/octoprint/plugins/pluginmanager/static/js/pluginmanager.js @@ -358,7 +358,6 @@ $(function () { self.enableRepoInstall = function (data) { return ( - self.enableManagement() && self.pipAvailable() && !self.safeMode() && !self.throttled() && @@ -533,6 +532,107 @@ $(function () { } }; + self.multiInstallQueue = ko.observableArray([]); + self.queuedInstalls = ko.observableArray([]); + self.multiInstallRunning = ko.observable(false); + self.multiInstallInitialSize = ko.observable(0); + + self.multiInstallValid = function () { + return ( + self.loginState.hasPermission( + self.access.permissions.PLUGIN_PLUGINMANAGER_INSTALL + ) && + self.pipAvailable() && + !self.safeMode() && + !self.throttled() && + self.online() && + self.multiInstallQueue().length > 0 && + self.multiInstallQueue().every(self.isCompatible) + ); + }; + + self.repoInstallSelectedButtonText = function () { + return self.multiInstallQueue().some(self.installed) + ? "(Re)install selected" + : "Install selected"; + }; + + self.repoInstallSelectedConfirm = function () { + if (!self.multiInstallValid()) return; + + if (self.multiInstallQueue().length === 1) { + self.installFromRepository(self.multiInstallQueue()[0]); + return; + } + + var question = "<ul>"; + self.multiInstallQueue().forEach(function (plugin) { + var action = self.installed(plugin) + ? gettext("Reinstall") + : gettext("Install"); + + question += _.sprintf( + "<li>%(action)s <em><b>%(name)s@%(version)s</b></em></li>", + { + action: _.escape(action), + name: _.escape(plugin.title), + version: _.escape(plugin.github.latest_release.tag) + } + ); + }); + question += "</ul>"; + + showConfirmationDialog({ + title: gettext("Confirm installation of multiple plugins"), + message: gettext("Please confirm you want to perform these actions:"), + question: question, + cancel: gettext("Cancel"), + proceed: gettext("Install"), + proceedClass: "primary", + onproceed: self.startMultiInstall + }); + }; + + self.startMultiInstall = function () { + if (self.multiInstallRunning() || !self.multiInstallValid()) return; + + self.multiInstallRunning(true); + self.multiInstallInitialSize(self.multiInstallQueue().length); + + self._markWorking( + gettext("Installing multiple plugins"), + gettext("Starting installation of multiple plugins...") + ); + self.performMultiInstallJob(); + }; + + self.performMultiInstallJob = function () { + if (!self.multiInstallRunning() || self.multiInstallQueue().length === 0) + return; + + var plugin = self.multiInstallQueue.pop(); + + self.installFromRepository(plugin); + }; + + self.alertMultiInstallJobDone = function (response) { + if ( + !self.multiInstallRunning() || + response.action != "install" || + !response.result + ) + return; + + if (self.multiInstallQueue().length === 0) { + self.installUrl(""); + self.multiInstallQueue([]); + self.multiInstallRunning(false); + self._markDone(); + } else { + self.performMultiInstallJob(); + } + }; + self.performRepositorySearch = function () { var query = self.repositorySearchQuery(); if (query !== undefined && query.trim() !== "") { @@ -1050,10 +1150,6 @@ $(function () { return; } - if (!self.enableManagement()) { - return; - } - self.installPlugin( data.archive, data.title, @@ -1062,6 +1158,22 @@ $(function () { ); }; + self.removeFromQueue = function (plugin) { + var data = { + plugin: { + command: self.installed(plugin) ? "reinstall" : "install", + url: plugin.archive, + dependency_links: + plugin.follow_dependency_links || self.followDependencyLinks() + } + }; + OctoPrint.simpleApiCommand("pluginmanager", "clear_queued_plugin", data).done( + function (response) { + self.queuedInstalls(response.queued_installs); + } + ); + }; + self.installPlugin = function (url, name, reinstall, followDependencyLinks) { if ( !self.loginState.hasPermission( @@ -1071,10 +1183,6 @@ $(function () { return; } - if (!self.enableManagement()) { - return; - } - if (self.throttled()) { return; } @@ -1108,10 +1216,59 @@ $(function () { {url: _.escape(url), name: _.escape(name)} ); } + + if (self.multiInstallRunning()) { + workTitle = + _.sprintf("[%(index)d/%(total)d] ", { + index: + this.multiInstallInitialSize() - + self.multiInstallQueue().length, + total: this.multiInstallInitialSize() + }) + workTitle; + } + self._markWorking(workTitle, workText); var onSuccess = function (response) { self.installUrl(""); + if (response.hasOwnProperty("queued_installs")) { + self.queuedInstalls(response.queued_installs); + var text = + '<div class="row-fluid"><p>' + + gettext("The following plugins are queued to be installed.") + + "</p><ul><li>" + + _.map(response.queued_installs, function (info) { + var plugin = ko.utils.arrayFirst( + self.repositoryplugins.paginatedItems(), + function (item) { + return item.archive === info.url; + } + ); + return plugin.title; + }).join("</li><li>") + + "</li></ul></div>"; + if (typeof self.installQueuePopup !== "undefined") { + self.installQueuePopup.update({ + text: text + }); + if (self.installQueuePopup.state === "closed") { + self.installQueuePopup.open(); + } + } else { + self.installQueuePopup = new PNotify({ + title: gettext("Plugin installs queued"), + text: text, + type: "notice" + }); + } + if (self.multiInstallQueue().length > 0) { + self.performMultiInstallJob(); + } else { + self.multiInstallRunning(false); + self.workingDialog.modal("hide"); + self._markDone(); + } + } }, onError = function (jqXHR) { if (jqXHR.status === 409) { @@ -1429,19 +1586,58 @@ $(function () { ); }; + self.installButtonAction = function (data) { + if (self.enableRepoInstall(data)) { + if (!self.installQueued(data)) { + self.installFromRepository(data); + } else { + self.removeFromQueue(data); + } + } else { + return false; + } + }; + self.installButtonText = function (data) { - return self.isCompatible(data) - ? self.installed(data) - ? gettext("Reinstall") - : gettext("Install") - : data.disabled - ? gettext("Disabled") - : gettext("Incompatible"); + if (!self.isCompatible(data)) { + if (data.disabled) { + return gettext("Disabled"); + } else { + return gettext("Incompatible"); + } + } + + if (self.installQueued(data)) { + return gettext("Dequeue"); + } else if (self.installed(data)) { + return gettext("Reinstall"); + } else { + return gettext("Install"); + } }; self._processPluginManagementResult = function (response, action, plugin) { if (response.result) { - self._markDone(); + if (self.queuedInstalls().length > 0 && action === "install") { + var plugin_dequeue = ko.utils.arrayFirst( + self.queuedInstalls(), + function (item) { + return item.url === response.source; + } + ); + if (plugin_dequeue) { + self.queuedInstalls.remove(plugin_dequeue); + } + if (self.queuedInstalls().length === 0) { + self.multiInstallRunning(false); + self._markDone(); + } + } else if (self.multiInstallRunning() && action === "install") { + // A MultiInstall job has finished + self.alertMultiInstallJobDone(response); + } else { + self._markDone(); + } } else { self._markDone(response.reason, response.faq); } @@ -1541,7 +1737,7 @@ $(function () { "</p>"; type = "warning"; - if (self.restartCommandSpec) { + if (self.restartCommandSpec && !self.multiInstallRunning()) { var restartClicked = false; confirm = { confirm: true, @@ -1596,20 +1792,22 @@ $(function () { "</p>"; type = "warning"; - var refreshClicked = false; - confirm = { - confirm: true, - buttons: [ - { - text: gettext("Reload now"), - click: function () { - if (refreshClicked) return; - refreshClicked = true; - location.reload(true); + if (!self.multiInstallRunning()) { + var refreshClicked = false; + confirm = { + confirm: true, + buttons: [ + { + text: gettext("Reload now"), + click: function () { + if (refreshClicked) return; + refreshClicked = true; + location.reload(true); + } } - } - ] - }; + ] + }; + } } else if (self.logContents.action_reconnect) { text += "<p>" + @@ -2034,7 +2232,10 @@ $(function () { var messageType = data.type; - if (messageType === "loglines" && self.working()) { + if ( + messageType === "loglines" && + (self.working() || self.queuedInstalls().length > 0) + ) { _.each(data.loglines, function (line) { self.loglines.push(self._preprocessLine(line)); }); @@ -2054,9 +2255,210 @@ $(function () { self._processPluginManagementResult(data, action, name); self.requestPluginData(); + } else if (messageType === "queued_installs") { + if (data.hasOwnProperty("queued")) { + self.queuedInstalls(data.queued); + var queuedInstallsPopupOptions = { + title: gettext("Queued Installs"), + text: "", + type: "notice", + icon: false, + hide: false, + buttons: { + closer: false, + sticker: false + }, + history: { + history: false + } + }; + + if (data.print_failed && data.queued.length > 0) { + queuedInstallsPopupOptions.title = gettext( + "Queued Installs Paused" + ); + queuedInstallsPopupOptions.text = + '<div class="row-fluid"><p>' + + gettext("The following plugins are queued to be installed.") + + "</p><ul><li>" + + _.map(self.queuedInstalls(), function (info) { + var plugin = ko.utils.arrayFirst( + self.repositoryplugins.paginatedItems(), + function (item) { + return item.archive === info.url; + } + ); + return plugin.title; + }).join("</li><li>") + + "</li></ul></div>"; + queuedInstallsPopupOptions.confirm = { + confirm: true, + buttons: [ + { + text: gettext("Continue Installs"), + addClass: "btn-block btn-primary", + promptTrigger: true, + click: function (notice, value) { + notice.remove(); + notice + .get() + .trigger("pnotify.continue", [notice, value]); + } + }, + { + text: gettext("Cancel Installs"), + addClass: "btn-block btn-danger", + promptTrigger: true, + click: function (notice, value) { + notice.remove(); + notice + .get() + .trigger("pnotify.cancel", [notice, value]); + } + } + ] + }; + } else if ( + data.hasOwnProperty("timeout_value") && + data.timeout_value > 0 && + data.queued.length > 0 + ) { + var progress_percent = Math.floor( + (data.timeout_value / 60) * 100 + ); + var progress_class = + progress_percent < 25 + ? "progress-danger" + : progress_percent > 75 + ? "progress-success" + : "progress-warning"; + var countdownText = _.sprintf( + gettext("Installing in %(sec)i secs..."), + { + sec: data.timeout_value + } + ); + + queuedInstallsPopupOptions.title = gettext( + "Starting Queued Installs" + ); + queuedInstallsPopupOptions.text = + '<div class="row-fluid"><p>' + + gettext("The following plugins are going to be installed.") + + "</p><ul><li>" + + _.map(self.queuedInstalls(), function (info) { + var plugin = ko.utils.arrayFirst( + self.repositoryplugins.paginatedItems(), + function (item) { + return item.archive === info.url; + } + ); + return plugin.title; + }).join("</li><li>") + + '</li></ul></p></div><div class="progress progress-softwareupdate ' + + progress_class + + '"><div class="bar">' + + countdownText + + '</div><div class="progress-text" style="clip-path: inset(0 0 0 ' + + progress_percent + + "%);-webkit-clip-path: inset(0 0 0 " + + progress_percent + + '%);">' + + countdownText + + "</div></div>"; + queuedInstallsPopupOptions.confirm = { + confirm: true, + buttons: [ + { + text: gettext("Cancel Installs"), + addClass: "btn-block btn-danger", + promptTrigger: true, + click: function (notice, value) { + notice.remove(); + notice + .get() + .trigger("pnotify.cancel", [notice, value]); + } + }, + { + text: "", + addClass: "hidden" + } + ] + }; + } else if ( + data.hasOwnProperty("timeout_value") && + data.timeout_value === 0 && + data.queued.length > 0 + ) { + self.multiInstallRunning(true); + self._markWorking( + gettext("Installing queued plugins"), + gettext("Starting installation of multiple plugins...") + ); + self.queuedInstallsPopup.remove(); + self.queuedInstallsPopup = undefined; + return; + } else { + if (typeof self.queuedInstallsPopup !== "undefined") { + self.queuedInstallsPopup.remove(); + self.queuedInstallsPopup = undefined; + } + return; + } + + if (typeof self.queuedInstallsPopup !== "undefined") { + self.queuedInstallsPopup.update(queuedInstallsPopupOptions); + } else { + self.queuedInstallsPopup = new PNotify( + queuedInstallsPopupOptions + ); + self.queuedInstallsPopup.get().on("pnotify.cancel", function () { + self.queuedInstallsPopup = undefined; + self.cancelQueuedInstalls(); + }); + self.queuedInstallsPopup + .get() + .on("pnotify.continue", function () { + self.queuedInstallsPopup = undefined; + self.performQueuedInstalls(); + }); + } + } } }; + self.cancelQueuedInstalls = function () { + OctoPrint.simpleApiCommand("pluginmanager", "clear_queued_installs", {}).done( + function (response) { + self.queuedInstalls(response.queued_installs); + } + ); + }; + + self.installQueued = function (plugin) { + var plugin_queued = ko.utils.arrayFirst( + self.queuedInstalls(), + function (item) { + return item.url === plugin.archive; + } + ); + return typeof plugin_queued !== "undefined"; + }; + + self.performQueuedInstalls = function () { + self.queuedInstalls().forEach(function (plugin) { + var queued_plugin = ko.utils.arrayFirst( + self.repositoryplugins.paginatedItems(), + function (item) { + return plugin.url === item.archive; + } + ); + self.multiInstallQueue.push(queued_plugin); + }); + self.startMultiInstall(); + }; + self._forcedStdoutLine = /You are using pip version .*?, however version .*? is available\.|You should consider upgrading via the '.*?' command\./; self._preprocessLine = function (line) { diff --git a/src/octoprint/plugins/pluginmanager/templates/pluginmanager_settings.jinja2 b/src/octoprint/plugins/pluginmanager/templates/pluginmanager_settings.jinja2 index 49bcf6705..5486d4de2 100644 --- a/src/octoprint/plugins/pluginmanager/templates/pluginmanager_settings.jinja2 +++ b/src/octoprint/plugins/pluginmanager/templates/pluginmanager_settings.jinja2 @@ -1,6 +1,6 @@ {% macro pluginmanager_printing() %} <div class="alert" data-bind="visible: !enableManagement()"> - {{ _('Take note that all plugin management functionality is disabled while your printer is printing or paused.') }} + {{ _('Take note that most plugin management functionality is disabled while your printer is printing or paused.') }} </div> {% endmacro %} @@ -225,7 +225,6 @@ </div> <div class="modal-body"> <div class="full-sized-box"> - {{ pluginmanager_printing() }} {{ pluginmanager_nopip() }} {{ pluginmanager_safemode() }} {{ pluginmanager_throttled() }} @@ -260,14 +259,23 @@ </div> </div> </div> - + <div style="text-align: center"> + <div style="margin: 0 auto;"> + <span class="muted" style="margin-right: 8px;" data-bind="text: multiInstallQueue().length + ' selected'"></span> + <button class="btn btn-primary" data-bind="enable: multiInstallValid(), click: repoInstallSelectedConfirm, text: repoInstallSelectedButtonText()"></button> + </div> + </div> + <hr /> <div data-bind="visible: repositoryAvailable() && repositoryplugins.paginatedItems().length > 0"> <div id="settings_plugin_pluginmanager_repositorydialog_list" data-bind="foreach: repositoryplugins.paginatedItems"> <div class="entry"> <div class="row-fluid"> - <div class="span12"> + <label class="checkbox span1"> + <input type="checkbox" data-bind="value: $data, checked: $parent.multiInstallQueue"> + </label> + <div class="span11"> <div class="span3 pull-right"> - <button class="btn btn-primary btn-block" data-bind="enable: $root.enableRepoInstall($data), css: {disabled: !$root.enableRepoInstall($data)}, click: function() { if ($root.enableRepoInstall($data)) { $root.installFromRepository($data); } else { return false; } }, attr: {title: !$root.enableRepoInstall($data) ? gettext('Plugin install is disabled') : ''}"><span data-bind="text: $root.installButtonText($data)"></span></button> + <button class="btn btn-primary btn-block" data-bind="enable: $root.enableRepoInstall($data), css: {disabled: !$root.enableRepoInstall($data)}, click: $root.installButtonAction"><span data-bind="text: $root.installButtonText($data)"></span></button> <div data-bind="visible: $data.disabled !== undefined" style="text-align: center"><small><a data-bind="attr: {href: page}" target="_blank">{{ _('"Why?"') }}</a></small></div> </div> <div> diff --git a/src/octoprint/static/js/app/client/files.js b/src/octoprint/static/js/app/client/files.js index 7d5458f5c..9bd523e80 100644 --- a/src/octoprint/static/js/app/client/files.js +++ b/src/octoprint/static/js/app/client/files.js @@ -156,6 +156,14 @@ return this.base.download(downloadForEntry(location, path), opts); }; + OctoPrintFilesClient.prototype.downloadPath = function (location, path) { + var url = downloadForEntry(location, path); + if (!_.startsWith(url, "http://") && !_.startsWith(url, "https://")) { + url = this.base.getBaseUrl() + url; + } + return url; + }; + OctoPrintFilesClient.prototype.pathForEntry = function (entry) { if (!entry || !entry.hasOwnProperty("parent") || entry.parent == undefined) { return ""; |