diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..da51e46f --- /dev/null +++ b/.gitattributes @@ -0,0 +1,15 @@ +# ignore file from exports + +# git files +.gitattributes export-ignore +.gitignore export-ignore +# github files +ISSUE_TEMPLATE.md export-ignore +CODE_OF_CONDUCT.md export-ignore +README.md export-ignore +# pycharm files +.editorconfig export-ignore +# all files within the Extras folder +Extras export-ignore + +octoprint_octolapse/_version.py export-subst diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 00000000..a126e9e0 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,20 @@ +name: Create Plugin ZIP +on: + push: + tags: + - 'v*' # Wird ausgeführt, wenn du ein neues Tag wie "v1.0" erstellst + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Check out Repository + uses: actions/checkout@v3 + + - name: Erstelle ZIP-Archiv + run: zip -r master.zip Octolapse/builds + + - name: Release mit ZIP-Datei + uses: softprops/action-gh-release@v1 + with: + files: master.zip diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml new file mode 100644 index 00000000..489de0ef --- /dev/null +++ b/.github/workflows/master.yml @@ -0,0 +1,21 @@ +name: Create master.zip +on: + push: + branches: + - master # Change to 'master' if your branch is named master + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v3 + + - name: Create ZIP Archive + run: zip -r master.zip . -x "*.git*" + + - name: Upload ZIP as Artifact + uses: actions/upload-artifact@v4 + with: + name: master-zip + path: master.zip diff --git a/Extras/Wiki/assets/img/putty/apt-get_dist_upgrade.jpg b/Extras/Wiki/assets/img/putty/apt-get_dist_upgrade.jpg new file mode 100644 index 00000000..7ca9beb3 Binary files /dev/null and b/Extras/Wiki/assets/img/putty/apt-get_dist_upgrade.jpg differ diff --git a/Extras/Wiki/assets/img/putty/apt-get_update.jpg b/Extras/Wiki/assets/img/putty/apt-get_update.jpg new file mode 100644 index 00000000..7ca4aa18 Binary files /dev/null and b/Extras/Wiki/assets/img/putty/apt-get_update.jpg differ diff --git a/Extras/Wiki/assets/img/putty/change_password.jpg b/Extras/Wiki/assets/img/putty/change_password.jpg new file mode 100644 index 00000000..ac5aec62 Binary files /dev/null and b/Extras/Wiki/assets/img/putty/change_password.jpg differ diff --git a/Extras/Wiki/assets/img/putty/connect_via_putty.jpg b/Extras/Wiki/assets/img/putty/connect_via_putty.jpg new file mode 100644 index 00000000..f432bccd Binary files /dev/null and b/Extras/Wiki/assets/img/putty/connect_via_putty.jpg differ diff --git a/Extras/Wiki/assets/img/putty/edit_modules.jpg b/Extras/Wiki/assets/img/putty/edit_modules.jpg new file mode 100644 index 00000000..491367f7 Binary files /dev/null and b/Extras/Wiki/assets/img/putty/edit_modules.jpg differ diff --git a/Extras/Wiki/assets/img/putty/modules_add_picam_driver.jpg b/Extras/Wiki/assets/img/putty/modules_add_picam_driver.jpg new file mode 100644 index 00000000..47f351bc Binary files /dev/null and b/Extras/Wiki/assets/img/putty/modules_add_picam_driver.jpg differ diff --git a/Extras/Wiki/assets/img/putty/modules_without_picam_driver.jpg b/Extras/Wiki/assets/img/putty/modules_without_picam_driver.jpg new file mode 100644 index 00000000..51cf1840 Binary files /dev/null and b/Extras/Wiki/assets/img/putty/modules_without_picam_driver.jpg differ diff --git a/Extras/Wiki/assets/img/putty/octopi.txt_camera_auto.jpg b/Extras/Wiki/assets/img/putty/octopi.txt_camera_auto.jpg new file mode 100644 index 00000000..9d29994e Binary files /dev/null and b/Extras/Wiki/assets/img/putty/octopi.txt_camera_auto.jpg differ diff --git a/Extras/Wiki/assets/img/putty/octopi.txt_camera_usb.jpg b/Extras/Wiki/assets/img/putty/octopi.txt_camera_usb.jpg new file mode 100644 index 00000000..faf66d06 Binary files /dev/null and b/Extras/Wiki/assets/img/putty/octopi.txt_camera_usb.jpg differ diff --git a/Extras/Wiki/assets/img/putty/octopi.txt_disabled_image_preferences.jpg b/Extras/Wiki/assets/img/putty/octopi.txt_disabled_image_preferences.jpg new file mode 100644 index 00000000..d35f1f97 Binary files /dev/null and b/Extras/Wiki/assets/img/putty/octopi.txt_disabled_image_preferences.jpg differ diff --git a/Extras/Wiki/assets/img/putty/octopi.txt_enable_image_preferences.jpg b/Extras/Wiki/assets/img/putty/octopi.txt_enable_image_preferences.jpg new file mode 100644 index 00000000..ece3eb57 Binary files /dev/null and b/Extras/Wiki/assets/img/putty/octopi.txt_enable_image_preferences.jpg differ diff --git a/Extras/Wiki/assets/img/putty/open_octopi.txt.jpg b/Extras/Wiki/assets/img/putty/open_octopi.txt.jpg new file mode 100644 index 00000000..a4a8a87f Binary files /dev/null and b/Extras/Wiki/assets/img/putty/open_octopi.txt.jpg differ diff --git a/Extras/Wiki/assets/img/putty/reboot.jpg b/Extras/Wiki/assets/img/putty/reboot.jpg new file mode 100644 index 00000000..30fc7246 Binary files /dev/null and b/Extras/Wiki/assets/img/putty/reboot.jpg differ diff --git a/Extras/Wiki/assets/img/putty/rpi-update.jpg b/Extras/Wiki/assets/img/putty/rpi-update.jpg new file mode 100644 index 00000000..ab3f0f60 Binary files /dev/null and b/Extras/Wiki/assets/img/putty/rpi-update.jpg differ diff --git a/Extras/Wiki/assets/img/putty/sign_in.jpg b/Extras/Wiki/assets/img/putty/sign_in.jpg new file mode 100644 index 00000000..3b0159d8 Binary files /dev/null and b/Extras/Wiki/assets/img/putty/sign_in.jpg differ diff --git a/Extras/Wiki/assets/img/putty/would_you_like_to_proceed.jpg b/Extras/Wiki/assets/img/putty/would_you_like_to_proceed.jpg new file mode 100644 index 00000000..484b9efb Binary files /dev/null and b/Extras/Wiki/assets/img/putty/would_you_like_to_proceed.jpg differ diff --git a/Extras/Wiki/assets/img/settings/close_octolapse_settings.jpg b/Extras/Wiki/assets/img/settings/close_octolapse_settings.jpg new file mode 100644 index 00000000..baf1bb89 Binary files /dev/null and b/Extras/Wiki/assets/img/settings/close_octolapse_settings.jpg differ diff --git a/Extras/Wiki/assets/img/settings/profiles/camera/add_edit_camera_profile.jpg b/Extras/Wiki/assets/img/settings/profiles/camera/add_edit_camera_profile.jpg new file mode 100644 index 00000000..420268d5 Binary files /dev/null and b/Extras/Wiki/assets/img/settings/profiles/camera/add_edit_camera_profile.jpg differ diff --git a/Extras/Wiki/assets/img/settings/profiles/camera/add_new_camera_profile.jpg b/Extras/Wiki/assets/img/settings/profiles/camera/add_new_camera_profile.jpg new file mode 100644 index 00000000..fd649154 Binary files /dev/null and b/Extras/Wiki/assets/img/settings/profiles/camera/add_new_camera_profile.jpg differ diff --git a/Extras/Wiki/assets/img/settings/profiles/camera/camera_type_and_addresses.jpg b/Extras/Wiki/assets/img/settings/profiles/camera/camera_type_and_addresses.jpg new file mode 100644 index 00000000..91bdc65e Binary files /dev/null and b/Extras/Wiki/assets/img/settings/profiles/camera/camera_type_and_addresses.jpg differ diff --git a/Extras/Wiki/assets/img/settings/profiles/camera/customize_camera_profile.jpg b/Extras/Wiki/assets/img/settings/profiles/camera/customize_camera_profile.jpg new file mode 100644 index 00000000..57c21b33 Binary files /dev/null and b/Extras/Wiki/assets/img/settings/profiles/camera/customize_camera_profile.jpg differ diff --git a/Extras/Wiki/assets/img/settings/profiles/camera/edit_camera_profile.jpg b/Extras/Wiki/assets/img/settings/profiles/camera/edit_camera_profile.jpg new file mode 100644 index 00000000..cfe7d447 Binary files /dev/null and b/Extras/Wiki/assets/img/settings/profiles/camera/edit_camera_profile.jpg differ diff --git a/Extras/Wiki/assets/img/settings/profiles/camera/enable_custom_image_preferences.jpg b/Extras/Wiki/assets/img/settings/profiles/camera/enable_custom_image_preferences.jpg new file mode 100644 index 00000000..829a1852 Binary files /dev/null and b/Extras/Wiki/assets/img/settings/profiles/camera/enable_custom_image_preferences.jpg differ diff --git a/Extras/Wiki/assets/img/settings/profiles/camera/enable_custom_image_preferences_failed.jpg b/Extras/Wiki/assets/img/settings/profiles/camera/enable_custom_image_preferences_failed.jpg new file mode 100644 index 00000000..daa93d2a Binary files /dev/null and b/Extras/Wiki/assets/img/settings/profiles/camera/enable_custom_image_preferences_failed.jpg differ diff --git a/Extras/Wiki/assets/img/settings/profiles/camera/general_options.jpg b/Extras/Wiki/assets/img/settings/profiles/camera/general_options.jpg new file mode 100644 index 00000000..02134342 Binary files /dev/null and b/Extras/Wiki/assets/img/settings/profiles/camera/general_options.jpg differ diff --git a/Extras/Wiki/assets/img/settings/profiles/camera/image_preferences/raspicam/1080p_30fps.jpg b/Extras/Wiki/assets/img/settings/profiles/camera/image_preferences/raspicam/1080p_30fps.jpg new file mode 100644 index 00000000..1e2f6f1a Binary files /dev/null and b/Extras/Wiki/assets/img/settings/profiles/camera/image_preferences/raspicam/1080p_30fps.jpg differ diff --git a/Extras/Wiki/assets/img/settings/profiles/camera/image_preferences/raspicam/balance.jpg b/Extras/Wiki/assets/img/settings/profiles/camera/image_preferences/raspicam/balance.jpg new file mode 100644 index 00000000..91e52d0e Binary files /dev/null and b/Extras/Wiki/assets/img/settings/profiles/camera/image_preferences/raspicam/balance.jpg differ diff --git a/Extras/Wiki/assets/img/settings/profiles/camera/image_preferences/raspicam/camera_stream.jpg b/Extras/Wiki/assets/img/settings/profiles/camera/image_preferences/raspicam/camera_stream.jpg new file mode 100644 index 00000000..befade19 Binary files /dev/null and b/Extras/Wiki/assets/img/settings/profiles/camera/image_preferences/raspicam/camera_stream.jpg differ diff --git a/Extras/Wiki/assets/img/settings/profiles/camera/image_preferences/raspicam/camera_stream_failed.jpg b/Extras/Wiki/assets/img/settings/profiles/camera/image_preferences/raspicam/camera_stream_failed.jpg new file mode 100644 index 00000000..8cdeb68a Binary files /dev/null and b/Extras/Wiki/assets/img/settings/profiles/camera/image_preferences/raspicam/camera_stream_failed.jpg differ diff --git a/Extras/Wiki/assets/img/settings/profiles/camera/image_preferences/raspicam/codec_controls.jpg b/Extras/Wiki/assets/img/settings/profiles/camera/image_preferences/raspicam/codec_controls.jpg new file mode 100644 index 00000000..92857ab5 Binary files /dev/null and b/Extras/Wiki/assets/img/settings/profiles/camera/image_preferences/raspicam/codec_controls.jpg differ diff --git a/Extras/Wiki/assets/img/settings/profiles/camera/image_preferences/raspicam/custom_two_column_layout.jpg b/Extras/Wiki/assets/img/settings/profiles/camera/image_preferences/raspicam/custom_two_column_layout.jpg new file mode 100644 index 00000000..ca208efa Binary files /dev/null and b/Extras/Wiki/assets/img/settings/profiles/camera/image_preferences/raspicam/custom_two_column_layout.jpg differ diff --git a/Extras/Wiki/assets/img/settings/profiles/camera/image_preferences/raspicam/effects.jpg b/Extras/Wiki/assets/img/settings/profiles/camera/image_preferences/raspicam/effects.jpg new file mode 100644 index 00000000..5ad1bd9a Binary files /dev/null and b/Extras/Wiki/assets/img/settings/profiles/camera/image_preferences/raspicam/effects.jpg differ diff --git a/Extras/Wiki/assets/img/settings/profiles/camera/image_preferences/raspicam/exposure.jpg b/Extras/Wiki/assets/img/settings/profiles/camera/image_preferences/raspicam/exposure.jpg new file mode 100644 index 00000000..9c7d0c5d Binary files /dev/null and b/Extras/Wiki/assets/img/settings/profiles/camera/image_preferences/raspicam/exposure.jpg differ diff --git a/Extras/Wiki/assets/img/settings/profiles/camera/image_preferences/raspicam/exposure_manual_low.jpg b/Extras/Wiki/assets/img/settings/profiles/camera/image_preferences/raspicam/exposure_manual_low.jpg new file mode 100644 index 00000000..7f265828 Binary files /dev/null and b/Extras/Wiki/assets/img/settings/profiles/camera/image_preferences/raspicam/exposure_manual_low.jpg differ diff --git a/Extras/Wiki/assets/img/settings/profiles/camera/image_preferences/raspicam/flip_rotate_image.jpg b/Extras/Wiki/assets/img/settings/profiles/camera/image_preferences/raspicam/flip_rotate_image.jpg new file mode 100644 index 00000000..9d7b7ccf Binary files /dev/null and b/Extras/Wiki/assets/img/settings/profiles/camera/image_preferences/raspicam/flip_rotate_image.jpg differ diff --git a/Extras/Wiki/assets/img/settings/profiles/camera/image_preferences/raspicam/general_settings.jpg b/Extras/Wiki/assets/img/settings/profiles/camera/image_preferences/raspicam/general_settings.jpg new file mode 100644 index 00000000..e696bab5 Binary files /dev/null and b/Extras/Wiki/assets/img/settings/profiles/camera/image_preferences/raspicam/general_settings.jpg differ diff --git a/Extras/Wiki/assets/img/settings/profiles/camera/image_preferences/raspicam/misc.jpg b/Extras/Wiki/assets/img/settings/profiles/camera/image_preferences/raspicam/misc.jpg new file mode 100644 index 00000000..5c19f5f3 Binary files /dev/null and b/Extras/Wiki/assets/img/settings/profiles/camera/image_preferences/raspicam/misc.jpg differ diff --git a/Extras/Wiki/assets/img/settings/profiles/camera/import_profile_not_selected.jpg b/Extras/Wiki/assets/img/settings/profiles/camera/import_profile_not_selected.jpg new file mode 100644 index 00000000..03f9e6ae Binary files /dev/null and b/Extras/Wiki/assets/img/settings/profiles/camera/import_profile_not_selected.jpg differ diff --git a/Extras/Wiki/assets/img/settings/profiles/camera/rename_and_save_new_camera_profile.jpg b/Extras/Wiki/assets/img/settings/profiles/camera/rename_and_save_new_camera_profile.jpg new file mode 100644 index 00000000..4dd438a9 Binary files /dev/null and b/Extras/Wiki/assets/img/settings/profiles/camera/rename_and_save_new_camera_profile.jpg differ diff --git a/Extras/Wiki/assets/img/settings/profiles/camera/shortcut_to_camera_profiles_edit.jpg b/Extras/Wiki/assets/img/settings/profiles/camera/shortcut_to_camera_profiles_edit.jpg new file mode 100644 index 00000000..039bfaa5 Binary files /dev/null and b/Extras/Wiki/assets/img/settings/profiles/camera/shortcut_to_camera_profiles_edit.jpg differ diff --git a/Extras/Wiki/assets/img/settings/profiles/camera/test_camera_settings.jpg b/Extras/Wiki/assets/img/settings/profiles/camera/test_camera_settings.jpg new file mode 100644 index 00000000..b6111b84 Binary files /dev/null and b/Extras/Wiki/assets/img/settings/profiles/camera/test_camera_settings.jpg differ diff --git a/Extras/Wiki/assets/img/settings/profiles/camera/test_camera_settings_fail.jpg b/Extras/Wiki/assets/img/settings/profiles/camera/test_camera_settings_fail.jpg new file mode 100644 index 00000000..31b5b54b Binary files /dev/null and b/Extras/Wiki/assets/img/settings/profiles/camera/test_camera_settings_fail.jpg differ diff --git a/Extras/Wiki/assets/img/settings/profiles/camera/test_camera_settings_success.jpg b/Extras/Wiki/assets/img/settings/profiles/camera/test_camera_settings_success.jpg new file mode 100644 index 00000000..7e7fe63d Binary files /dev/null and b/Extras/Wiki/assets/img/settings/profiles/camera/test_camera_settings_success.jpg differ diff --git a/Extras/Wiki/assets/img/settings/profiles/camera/webcam_stream_address_absolute.jpg b/Extras/Wiki/assets/img/settings/profiles/camera/webcam_stream_address_absolute.jpg new file mode 100644 index 00000000..072b09c8 Binary files /dev/null and b/Extras/Wiki/assets/img/settings/profiles/camera/webcam_stream_address_absolute.jpg differ diff --git a/Extras/Wiki/assets/img/settings/profiles/camera/webcam_stream_address_relative.jpg b/Extras/Wiki/assets/img/settings/profiles/camera/webcam_stream_address_relative.jpg new file mode 100644 index 00000000..972987ff Binary files /dev/null and b/Extras/Wiki/assets/img/settings/profiles/camera/webcam_stream_address_relative.jpg differ diff --git a/Extras/Wiki/assets/img/settings/profiles/printer/slicer_type/automatic/default_extruder.jpg b/Extras/Wiki/assets/img/settings/profiles/printer/slicer_type/automatic/default_extruder.jpg new file mode 100644 index 00000000..9dc8627f Binary files /dev/null and b/Extras/Wiki/assets/img/settings/profiles/printer/slicer_type/automatic/default_extruder.jpg differ diff --git a/Extras/Wiki/assets/img/settings/profiles/printer/slicer_type/automatic/slicer_type_dropdown_automatic.jpg b/Extras/Wiki/assets/img/settings/profiles/printer/slicer_type/automatic/slicer_type_dropdown_automatic.jpg new file mode 100644 index 00000000..6eeeabfa Binary files /dev/null and b/Extras/Wiki/assets/img/settings/profiles/printer/slicer_type/automatic/slicer_type_dropdown_automatic.jpg differ diff --git a/Extras/Wiki/assets/img/slicers/Slic3r/enable_verbose_gcode.jpg b/Extras/Wiki/assets/img/slicers/Slic3r/enable_verbose_gcode.jpg new file mode 100644 index 00000000..7f709f4a Binary files /dev/null and b/Extras/Wiki/assets/img/slicers/Slic3r/enable_verbose_gcode.jpg differ diff --git a/Extras/Wiki/assets/img/slicers/cura/cura_help_about.jpg b/Extras/Wiki/assets/img/slicers/cura/cura_help_about.jpg new file mode 100644 index 00000000..797492f3 Binary files /dev/null and b/Extras/Wiki/assets/img/slicers/cura/cura_help_about.jpg differ diff --git a/Extras/Wiki/assets/img/slicers/cura/cura_manage_printer.jpg b/Extras/Wiki/assets/img/slicers/cura/cura_manage_printer.jpg new file mode 100644 index 00000000..b7503544 Binary files /dev/null and b/Extras/Wiki/assets/img/slicers/cura/cura_manage_printer.jpg differ diff --git a/Extras/Wiki/assets/img/slicers/cura/cura_manage_printers.jpg b/Extras/Wiki/assets/img/slicers/cura/cura_manage_printers.jpg new file mode 100644 index 00000000..bfd20282 Binary files /dev/null and b/Extras/Wiki/assets/img/slicers/cura/cura_manage_printers.jpg differ diff --git a/Extras/Wiki/assets/img/slicers/cura/cura_version.jpg b/Extras/Wiki/assets/img/slicers/cura/cura_version.jpg new file mode 100644 index 00000000..b3e2f42f Binary files /dev/null and b/Extras/Wiki/assets/img/slicers/cura/cura_version.jpg differ diff --git a/Extras/Wiki/assets/img/slicers/cura/start_gcode.jpg b/Extras/Wiki/assets/img/slicers/cura/start_gcode.jpg new file mode 100644 index 00000000..3966e917 Binary files /dev/null and b/Extras/Wiki/assets/img/slicers/cura/start_gcode.jpg differ diff --git a/Extras/Wiki/assets/img/slicers/cura/start_gcode_cura_script_added.jpg b/Extras/Wiki/assets/img/slicers/cura/start_gcode_cura_script_added.jpg new file mode 100644 index 00000000..ac4f8abf Binary files /dev/null and b/Extras/Wiki/assets/img/slicers/cura/start_gcode_cura_script_added.jpg differ diff --git a/Extras/Wiki/assets/img/tab/image_preferences_edit.jpg b/Extras/Wiki/assets/img/tab/image_preferences_edit.jpg new file mode 100644 index 00000000..1dbb868d Binary files /dev/null and b/Extras/Wiki/assets/img/tab/image_preferences_edit.jpg differ diff --git a/Extras/Wiki/assets/img/tab/octolapse_tab_expanded.jpg b/Extras/Wiki/assets/img/tab/octolapse_tab_expanded.jpg new file mode 100644 index 00000000..e10809b1 Binary files /dev/null and b/Extras/Wiki/assets/img/tab/octolapse_tab_expanded.jpg differ diff --git a/Extras/plugin_repository/_plugins/octolapse.md b/Extras/plugin_repository/_plugins/octolapse.md deleted file mode 100644 index 0a9d8468..00000000 --- a/Extras/plugin_repository/_plugins/octolapse.md +++ /dev/null @@ -1,187 +0,0 @@ ---- -layout: plugin - -id: octolapse -title: Octolapse -description: Create a stabilized timelapse of your 3D prints. Highly customizable, loads of presets, lots of fun. Requires OctoPrint 1.3.9 or higher. -author: Brad Hochgesang -license: AGPL-3.0 -date: 2018-11-15 - -homepage: https://formerlurker.github.io/Octolapse/ -source: https://github.com/FormerLurker/Octolapse/ -archive: https://github.com/FormerLurker/Octolapse/archive/v0.3.4.zip - -tags: -- timelapse - -featuredimage: /assets/img/plugins/octolapse/tab_mini.png - -compatibility: - - octoprint: - - 1.3.9 - - os: - - linux - - windows - - macos - - freebsd ---- - - -# Octolapse - -
-
-
-
- Create a stabilized timelapse of your 3D prints. Highly customizable, loads of presets, lots of fun.
-
-
-
-
-
-
-
-
-
-
-
- Error loading the stream at: " + options.src + " Check the 'Stream Address Template' setting in your camera profile. No stream url was provided. Check the 'Stream Address Template' setting. No stream url was provided. Check the 'Stream Address Template' setting. Loading webcam stream at: " + options.src + " Loading webcam stream at: " + self.src + " Error loading the stream at: " + src + " Check the 'Stream Address Template' setting in your camera profile. No stream url was provided. Check the 'Stream Address Template' setting. "),l+=" tags around block-level tags.
+ text = showdown.subParser('hashHTMLBlocks')(text, options, globals);
+ text = showdown.subParser('paragraphs')(text, options, globals);
+
+ text = globals.converter._dispatch('blockGamut.after', text, options, globals);
+
+ return text;
+});
+
+showdown.subParser('blockQuotes', function (text, options, globals) {
+ 'use strict';
+
+ text = globals.converter._dispatch('blockQuotes.before', text, options, globals);
+
+ // add a couple extra lines after the text and endtext mark
+ text = text + '\n\n';
+
+ var rgx = /(^ {0,3}>[ \t]?.+\n(.+\n)*\n*)+/gm;
+
+ if (options.splitAdjacentBlockquotes) {
+ rgx = /^ {0,3}>[\s\S]*?(?:\n\n)/gm;
+ }
+
+ text = text.replace(rgx, function (bq) {
+ // attacklab: hack around Konqueror 3.5.4 bug:
+ // "----------bug".replace(/^-/g,"") == "bug"
+ bq = bq.replace(/^[ \t]*>[ \t]?/gm, ''); // trim one level of quoting
+
+ // attacklab: clean up hack
+ bq = bq.replace(/¨0/g, '');
+
+ bq = bq.replace(/^[ \t]+$/gm, ''); // trim whitespace-only lines
+ bq = showdown.subParser('githubCodeBlocks')(bq, options, globals);
+ bq = showdown.subParser('blockGamut')(bq, options, globals); // recurse
+
+ bq = bq.replace(/(^|\n)/g, '$1 ');
+ // These leading spaces screw with Just type tags
+
+ for (var i = 0; i < end; i++) {
+ var str = grafs[i];
+ // if this is an HTML marker, copy it
+ if (str.search(/¨(K|G)(\d+)\1/g) >= 0) {
+ grafsOut.push(str);
+
+ // test for presence of characters to prevent empty lines being parsed
+ // as paragraphs (resulting in undesired extra empty paragraphs)
+ } else if (str.search(/\S/) >= 0) {
+ str = showdown.subParser('spanGamut')(str, options, globals);
+ str = str.replace(/^([ \t]*)/g, ' ');
+ str += ' It is highly recommended that you take the time to configure these settings since they can make a big difference in the quality of your timelapse, and reduce the required snapshot delay. Custom image preferences are only available for USB web-cameras running on mjpg_streamer (the raspberry pi camera module is not currently supported). Consider using a custom 'Before Print Start Script' or try using a web camera running on mjpg_streamer. Custom image preferences are only available when using the 'Webcam' Camera Type setting above. It may be possible to use the 'Before Print Start Script' script above to configure your camera.
+## Getting Started
+
+Be sure to [read the getting started guide](https://github.com/FormerLurker/Octolapse/wiki/V0.4---Getting-Started) on the [Octolapse Wiki](https://github.com/FormerLurker/Octolapse/wiki). This will save you a lot of hassle and allow you to unlock some of the best features of Octolapse.
+
+
+
+
+ View the Getting Started Guide Companion Video
-
-
- A timelapse of a double spiral vase made with Octolapse
+
+
+
+ Automatic Slicer Settings Configuration
-
-
-
-## Octolapse was designed with print quality in mind
-* **Continuous tracking** of the X,Y, and Z axes. Octolapse knows where your extruder and bed will be at all times.
-* **Extruder state detection** enables Octolapse to choose a good time take a snapshot, minimizing defects.
-* Choose from dozens of existing **presets,** or use a **custom configuration** to maximize quality for your specific application.
-* Customizable **Z-Hop and retract detection** to reduce stringing, maximizing quality.
-* **Configurable stabilizations** allow you complete control of the X and Y position of each snapshot. You can choose a position as close to or as far away from your part as you wish.
-* **Minimal impact on print time and print quality.** Octolapse normally takes between one and three seconds to take a snapshot. It even reports exactly how much time it's using after each snapshot!
-* Use **High Quality Mode** to prevent snapshots over exterior perimeters using the new fully customizable **Feature Detection**.
-* Allow or prevent snapshots in certain areas with **snapshot position restrictions.** This can be used to prevent snapshots over critical areas of your print or a delicate part.
-* If you have a multi-material printer, you can even **restrict snapshots movements to your wipe tower**, virtually eliminating any quality considerations!
-* Octolapse can **calculate intersections** between the printer path and any snapshot position restriction, allowing a snapshot even in the middle of an extrusion.
-* **Stop the timelapse whenever you want!** If things go awry, you can prevent Octolapse from taking any further snapshots for the rest of the print. It will wait to render any snapshots that were already taken until after the print has finished.
-* Use **test mode** to try out your timelapse settings without heating your bed or nozzle, turning on any fans, or extruding any filament. This saves time and plastic. It's also very useful for development and testing.
-## Octolapse was also designed to make great timelapses
-* **Synchronize your timelapses** with OctoPrint's built in plugin, keeping all of your videos in one place.
-* Choose between **fixed frame rate** or **fixed length rendering.** Octolapse will automatically adjust the frame rate to match any desired length. You can even set a minimum or maximum acceptable frame rate.
-* Add **pre or post roll frames** (not videos yet, sorry) to your snapshot preventing an abrupt start or finish.
-* Choose from several output formats including **MP4 (libxvid and H.264), AVI, FLV, VOB, GIF, and MPEG.** *More coming soon, suggestions welcome.* _Note: *OctoPrint 1.3.10 is required to synchronize some formats with the built-in timelapse plugin.*_
-* **Control the bitrate** of your video.
-* Add a **Custom Watermark** to your video.
-* Add **Text Overlays** to your video using replacement tokens. Control the position, color, font and more!
-* Control the number of **rendering threads** to reduce rendering time.
-## To make good timelapses, you need good snapshots
-* Slow camera or low frame rate? Octolapse allows you to **set a snapshot delay** before taking a snapshot to allow your camera enough time to get a clear image.
-* **Control your camera settings** when using [mjegstreamer](https://sourceforge.net/projects/mjpg-streamer/) including: contrast, brightness, focus, white balance, pan, tilt, zoom, and much more. You can apply your custom settings before each print.
-* **Rotate, flip, and transpose your snapshots**
-* Use custom camera scripts to **trigger a DSLR**, post-process images, configure your camera, turn lighting on and off, control **GPIO**, or practically anything you can think of! Five script types are available.
-* Use **Multiple cameras** to create multiple timelapses.
-* Send gcode to your printer when it's time to take a snapshot via the new **Gcode Camera Type**. Supports for the **M240** gcode!
-## Simplified Setup and Better Compatibility
-* Select your **slicer type** to simplify printer setup by making the setting names and units match your slicer! Cura, Simplify 3d and Slic3r PE are fully supported.
-* Enhanced and **simplified error reporting** and informational panels.
-* All settings include links to the **Octolapse Wiki**, so you can get to the documentation quickly if you have questions.
-* Octolapse now works with **Themeify**.
-
-# Installation
-
-[Read the installation instructions.](https://github.com/FormerLurker/Octolapse/wiki/Installation)
-
-After installation, [checkout the project's wiki pages](https://github.com/FormerLurker/Octolapse/wiki). It never hurts to know more about what you're doing!
-
-# Usage
-[Learn how to start your first print here.](https://github.com/FormerLurker/Octolapse/wiki/Usage) It's a good idea to read about the settings before using Octolapse. I also recommend you [watch this brief tutorial](https://youtu.be/sDyg9lMqMG8), which explains how to configure your printer profile for use with the new **High Quality** snapshot profile.
-
-# Known Printer Support
-
-Octolapse is still in Beta but has been confirmed to work on several printers:
-
-* Genuine Prusa - Mk2, Mk2S, Mk2 w Multi Material, Mk3
-* Anet A8 - Beta - User Submitted
-* Anycube I3 Mega - Beta - User Submitted
-* CR-10 - Beta - User Submitted
-* Dagoma Neva - Beta - User Submitted
-* Irapid Black - Beta - User Submitted
-* Monoprice Maker Select v2/Wanhao Duplicator i3 - Beta - User Submitted - Requires OctoPrint 1.3.7rc3 or above
-
-Please note that some settings may need to be adjusted depending on your slicer settings.
+
+
+### Smart Triggers
+
+The new smart triggers read your entire Gcode file before starting a print, giving them a lot more information to make better decisions. They automatically detect print features and prefer to take snapshots over infill, wipe towers, or interior perimeters, and they avoid taking snapshots (where possible) over exterior perimeters. They also try to start snapshots as close to the stabilization point as possible, reducing travel. This all adds up to improvements in quality and a reduction in print time. Plus, you will get to see a preview of your timelapse **before** your print starts. Octolapse will inform you of potential problems *and* will help you to fix them. If you don't like what you see, you can cancel the print and change your settings saving you time and effort.
+
+
+
+
+ Smart Trigger Snapshot Plan Preview
-
-
- A user generated compilation created by WildRose Builds. Support this channel and please subscribe!
+
+Octolapse now has its own [profile repository](https://github.com/FormerLurker/Octolapse-Profiles)! Access a library of pre-configured profiles and download, customize, and share your settings with the world! You can export and import individual profiles or all of your Octolapse settings. If newer settings are available from the repository, Octolapse will notify you and can automatically update any of the pre-configured profiles.
+
+
+
+### Enhanced Rendering Capabilities
+
+Now you can see detailed rendering progress and retry or alter renderings that never finished. I've added beta support for the H.265 codec (most Raspberry Pis don't have the memory for this though). I added new rendering overlay tokens, text outlining, and a default font for simplified setup.
+
+
-
+### Browse Videos and Image Archives
+
+I've finally added native file browsers to Octolapse! Now you can download, sort, or delete timelapses, all without leaving the Octolapse tab. If you enable the new **archive images** feature, you can also download snapshot images or re-render them using different settings. I've even added the ability to upload images into Octolapse!
+
+
+
+### Better Camera Controls
+
+Octolapse already included the ability to control camera settings like focus, exposure, and white balance. In the new version, Octolapse detects your camera's capabilities and renders a control page dynamically. Octolapse even has enhanced custom control pages for some cameras (like the Raspberry Pi cameras and many Logitech models). You can now watch your image change in real time as you adjust the settings. You can even stabilize your extruder to make adjusting the focus a snap! **Requires mjpg-streamer. Not compatible with [*Octoprint Anywhere*](https://plugins.octoprint.org/plugins/anywhere/) or [*The Spaghetti Detective*](https://plugins.octoprint.org/plugins/thespaghettidetective/)**.
+
+
+
+### Integrated Help System
+
+Octolapse now provides documentation for every single setting, and it's all built-in. Get your questions answered quickly by clicking on the help links (blue question marks). Many error popups now also have a help button explaining what the problem is and how to fix it. I've also added [versioneer](https://github.com/warner/python-versioneer), including a link within the Octolapse tab that points directly to the release notes or commit specific to the version you have installed.
+
+
+ Help for Common Print Start Issues
+
+ Automatic Detection of Print Quality Issues, With Help
+
+ Integrated Help Links on the Octolapse Tab
+ 
+ Integrated Help Links Within Each Profile
+
+ Additional Camera Scripts and Tests
+
+Octolapse now captures snapshots more quickly and performs all image manipulations on a background thread. This reduces print time and improves quality.
+
+### G2/G3 (Arc) Support
+Octolapse now supports Arc commands. Now you can use the [Arc Welder](https://github.com/FormerLurker/ArcWelderPlugin) plugin with Octolapse.
+
+## Support Octolapse Development
+
+Please consider supporting my work by becoming a [patron](https://www.patreon.com/join/FormerLurker), a [Github Sponsor](https://github.com/sponsors/FormerLurker), or by sending me beer money via [PayPal](https://paypal.me/formerlurker). Almost all of the donations go towards offsetting the cost of development, which is substantial. Plus it always makes my day! If you cannot afford to leave a tip or just don't want to, that is fine too! Octolapse is [free and open source](https://raw.githubusercontent.com/FormerLurker/Octolapse/master/LICENSE) after all.
+
+## More Octolapses
+
+
+
+
+ A user-generated compilation created by WildRose Builds. Support this channel, and please subscribe!
-
-
- The obligatory benchy
+
+
+
+
+
+
## History of Octolapse
-I got the idea for Octolapse when I attempted to manually make a [stabilized timelapse](https://youtu.be/xZlP4vpAKNc) by hand editing my GCode files. To accomplish this I used the excellent and simple [GCode System Commands](https://github.com/kantlivelong/OctoPrint-GCodesystemCommands) plugin. The timelapse worked great, but it required a lot of effort which I didn't want to put in every time. I received several requests for instructions on how to create a stabilized timelapse, so I decided to give plugin development a go. I've never done one before (or programmed python or knockout or anything open source), but figured I could contribute something good to the community. This is my "thank you" to all of the makers out there who have contributed your time and effort!
-## Report Problems
-If you think you have found a bug in Octolapse, please create an issue on the official github.com page [here](https://github.com/FormerLurker/Octolapse/issues/new). In order to have your issue handled properly and quickly, please completely
+I got the idea for Octolapse when I attempted to manually make a [stabilized timelapse](https://youtu.be/xZlP4vpAKNc) by hand-editing my GCode files. To accomplish this I used the excellent and simple [GCode System Commands](https://github.com/kantlivelong/OctoPrint-GCodesystemCommands) plugin. The timelapse worked great, but it required a lot of effort which I didn't want to put in every time. I received several requests for instructions on how to create a stabilized timelapse, so I decided to give plugin development a go. At that time I had never written any OctoPrint plugins (or programmed Python or Knockout or anything open source), but figured I could contribute something good to the community.
+
+On Jauary 20, 2018, I released the alpha version of Octolapse, and on March 24, 2018, I released the plugin on the [OctoPrint Plugin Repository](https://plugins.octoprint.org). Octolapse grew up pretty quickly, and on November 15, 2018, I released V0.3.4. Over a year and a half and 640+ commits later, I finally completed V0.4.0, which is a major rewrite of the software and includes all of the features discussed above. It took a long time to get here.
+
+Octolapse is my "thank you" to all of the makers out there who have contributed time and effort to this hobby. I hope that Octolapse can spread information about the 3D printing hobby by attracting a few new users.
+
+## Report Problems and Suggest Improvements
+
+If you think you have found a bug in Octolapse, please [see this guide for reporting issues](https://github.com/FormerLurker/Octolapse/wiki/V0.4---Reporting-An-Issue). Maybe you have an idea for a cool new feature? Find out how to let me know about your idea [here](https://github.com/FormerLurker/Octolapse/wiki/V0.4---Request-A-New-Feature).
+
+# License
-## License
View the [Octolapse license](https://github.com/FormerLurker/Octolapse/blob/master/LICENSE).
-_Copyright (C) 2017 Brad Hochgesang - FormerLurker@pm.me_
+_Copyright (C) 2023 Brad Hochgesang - FormerLurker@pm.me_
diff --git a/builds/build_2025_06_19.zip b/builds/build_2025_06_19.zip
new file mode 100644
index 00000000..56cb745f
Binary files /dev/null and b/builds/build_2025_06_19.zip differ
diff --git a/builds/readme b/builds/readme
new file mode 100644
index 00000000..f997190e
--- /dev/null
+++ b/builds/readme
@@ -0,0 +1 @@
+here we create some builds
diff --git a/octoprint_octolapse/__init__.py b/octoprint_octolapse/__init__.py
index d0522b49..da92c021 100644
--- a/octoprint_octolapse/__init__.py
+++ b/octoprint_octolapse/__init__.py
@@ -1,7 +1,7 @@
# coding=utf-8
##################################################################################
# Octolapse - A plugin for OctoPrint used for making stabilized timelapse videos.
-# Copyright (C) 2017 Brad Hochgesang
+# Copyright (C) 2023 Brad Hochgesang
##################################################################################
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published
@@ -23,10 +23,6 @@
from __future__ import absolute_import
from __future__ import unicode_literals
-# uncomment to enable faulthandler. Also need to uncomment faulthandler in plugin_requries in setup.py
-#import faulthandler
-#faulthandler.enable()
-# enable faulthandler for c extension
# Create the root logger. Note that it MUST be created before any imports that use the
# plugin_octolapse.log.LoggingConfigurator, since it is a singleton and we want to be
# the first to create it so that the name is correct.
@@ -36,62 +32,87 @@
# so that we can
logger = logging_configurator.get_logger("__init__")
# be sure to configure the logger after we import all of our modules
-from werkzeug.utils import secure_filename
+import tornado
+import errno
+from octoprint.server import util, app
+from octoprint.server.util.tornado import LargeResponseHandler, RequestlessExceptionLoggingMixin, CorsSupportMixin
import sys
import base64
import json
import os
-import shutil
-import flask
-import math
+# remove unused imports
+#from flask import request, send_file, jsonify, Response, stream_with_context, send_from_directory, current_app, Flask
+from flask import request, jsonify
import threading
import uuid
-import six
+# remove unused imports
+# import six
import time
-from six.moves import queue
+# Remove python 2 support
+# from six.moves import queue
+import queue as queue
from tempfile import mkdtemp
-# import python 3 specific modules
-if (sys.version_info) > (3,0):
- import faulthandler
- faulthandler.enable()
-
from distutils.version import LooseVersion
from io import BytesIO
-
import octoprint.plugin
-import octoprint.server
import octoprint.filemanager
from octoprint.events import Events
-from octoprint.server import admin_permission
from octoprint.server.util.flask import restricted_access
-
-import octoprint_octolapse.stabilization_preprocessing
+# remove unused import
+# import octoprint_octolapse.stabilization_preprocessing
import octoprint_octolapse.camera as camera
import octoprint_octolapse.render as render
import octoprint_octolapse.snapshot as snapshot
import octoprint_octolapse.utility as utility
-from octoprint_octolapse.position import Position
-from octoprint_octolapse.gcode import SnapshotGcodeGenerator
-from octoprint_octolapse.gcode_parser import Commands
-from octoprint_octolapse.render import TimelapseRenderJob, RenderingCallbackArgs
+import octoprint_octolapse.error_messages as error_messages
+from octoprint_octolapse.migration import migrate_files, get_version_from_settings_index
+# remove unused import
+# from octoprint_octolapse.position import Position
+from octoprint_octolapse.stabilization_gcode import SnapshotGcodeGenerator
+from octoprint_octolapse.gcode_commands import Commands
+# remove unused import
+# from octoprint_octolapse.render import TimelapseRenderJob, RenderingCallbackArgs
from octoprint_octolapse.settings import OctolapseSettings, PrinterProfile, StabilizationProfile, TriggerProfile, \
- CameraProfile, RenderingProfile, DebugProfile, SlicerSettings, CuraSettings, OtherSlicerSettings, \
- Simplify3dSettings, Slic3rPeSettings, SettingsJsonEncoder, MjpgStreamer
-from octoprint_octolapse.timelapse import Timelapse, TimelapseState
+ CameraProfile, RenderingProfile, LoggingProfile, SlicerSettings, CuraSettings, OtherSlicerSettings, \
+ Simplify3dSettings, Slic3rPeSettings, SettingsJsonEncoder, MjpgStreamer, MainSettings
+from octoprint_octolapse.timelapse import Timelapse, TimelapseState, TimelapseStartException
from octoprint_octolapse.stabilization_preprocessing import StabilizationPreprocessingThread
from octoprint_octolapse.messenger_worker import MessengerWorker, PluginMessage
from octoprint_octolapse.settings_external import ExternalSettings, ExternalSettingsError
-
-try:
- # noinspection PyCompatibility
- from urlparse import urlparse
-except ImportError:
- # noinspection PyUnresolvedReferences
- from urllib.parse import urlparse
-
+from octoprint_octolapse.render import RenderError, RenderingProcessor, RenderingCallbackArgs, RenderJobInfo
+#import octoprint_octolapse_setuptools as octoprint_octolapse_setuptools
+#import octoprint_octolapse_setuptools.github_release as github_release
+# remove python 2 compatibility
+#try:
+# # noinspection PyCompatibility
+# from urlparse import urlparse as urlparse
+#except ImportError:
+# # noinspection PyUnresolvedReferences
+# from urllib.parse import urlparse as urlparse
+from urllib.parse import urlparse as urlparse
# configure all imported loggers
logging_configurator.configure_loggers()
+
+def configure_debug_mode():
+ # Conditional imports for OctoPrint or Python debug mode
+ # detect debug mode
+ if hasattr(sys, 'gettotalrefcount') or "--debug" in sys.argv:
+ logger.info("Debug mode detected.")
+ # import python 3 specific debug modules
+ logger.info("Python %s detected.", sys.version)
+ if sys.version_info > (3, 0):
+ import faulthandler
+ faulthandler.enable()
+ logger.info("Faulthandler enabled.")
+ else:
+ logger.info("Release mode detected.")
+
+
+# configure debug mode
+configure_debug_mode()
+
+
class OctolapsePlugin(
octoprint.plugin.SettingsPlugin,
octoprint.plugin.AssetPlugin,
@@ -106,11 +127,19 @@ class OctolapsePlugin(
PREPROCESSING_CANCEL_TIMEOUT_SECONDS = 5
PREPROCESSING_NOTIFICATION_PERIOD_SECONDS = 1
+ if LooseVersion(octoprint.server.VERSION) >= LooseVersion("1.4"):
+ import octoprint.access.permissions as permissions
+ admin_permission = permissions.Permissions.ADMIN
+ else:
+ import flask_principal
+ admin_permission = flask_principal.Permission(flask_principal.RoleNeed('admin'))
+
def __init__(self):
+ super(OctolapsePlugin, self).__init__()
self._octolapse_settings = None # type: OctolapseSettings
self._timelapse = None # type: Timelapse
self.gcode_preprocessor = None
- self._preprocessing_progress_queue = None
+ self._stabilization_preprocessor_thread = None
self._preprocessing_cancel_event = threading.Event()
self._plugin_message_queue = queue.Queue()
@@ -121,510 +150,767 @@ def __init__(self):
self.saved_timelapse_settings = None
self.saved_snapshot_plans = None
self.saved_parsed_command = None
+ self.saved_preprocessing_quality_issues = ""
+ self.saved_missed_snapshots = 0
+ self.snapshot_plan_preview_autoclose = False
+ self.snapshot_plan_preview_close_time = 0
+ self.autoclose_snapshot_preview_thread_lock = threading.Lock()
# automatic update thread
self.automatic_update_thread = None
self.automatic_updates_notification_thread = None
self.automatic_update_lock = threading.RLock()
self.automatic_update_cancel = threading.Event()
# contains a list of all profiles with available updates
- self.automatic_updates_available = None
# holds a list of all available profiles on the server for the current version
self.available_profiles = None
+ # rendering processor and task queue
+ self._rendering_task_queue = queue.Queue(maxsize=0)
+ self._rendering_processor = None
def get_sorting_key(self, context=None):
return 1
- def get_current_debug_profile_function(self):
+ def get_current_logging_profile_function(self):
if self._octolapse_settings is not None:
- return self._octolapse_settings.profiles.current_debug_profile
+ return self._octolapse_settings.profiles.current_logging_profile
def get_current_octolapse_settings(self):
# returns a guaranteed up-to-date settings object
return self._octolapse_settings
- # Blueprint Plugin Mixin Requests
- @octoprint.plugin.BlueprintPlugin.route("/downloadTimelapse/
{0}".format(payload.get_rendering_path())
+ msg += "After rendering is completed, the finished video will be available both within the stock timelapse " \
+ "tab, and within the Octolapse tab. "
- message = "{0}{1}{2}".format(job_message, msg, will_sync_message)
- # send a message to the client
- self.send_render_start_message(message)
+ self.send_render_start_message(msg, job)
- def on_render_success(self, payload):
- """Called after all rendering and synchronization attempts are complete."""
+ def on_render_success(self, payload, job):
+ """Called after all rendering is complete."""
assert (isinstance(payload, RenderingCallbackArgs))
- message = "Rendering completed and was successful."
if payload.BeforeRenderError or payload.AfterRenderError:
- pre_post_render_message = "Rendering completed and was successful, but there were some script errors: "
+ pre_post_render_message = "Rendering completed and was successful for the '{0}' camera, but there were " \
+ "some script errors: ".format(payload.CameraName)
if payload.BeforeRenderError:
- pre_post_render_message += " The before script failed with the following error:" \
- " {0}".format(payload.BeforeRenderError)
+ pre_post_render_message += "{0}Before Render Script - {1}".format(os.linesep, payload.BeforeRenderError.message)
if payload.AfterRenderError:
- pre_post_render_message += " The after script failed with the following error:" \
- " {0}".format(payload.AfterRenderError)
+ pre_post_render_message += "{0}After Render Script - {1}".format(os.linesep, payload.AfterRenderError.message)
self.send_post_render_failed_message(pre_post_render_message)
- if payload.Synchronize:
- # If we are synchronizing with the Octoprint timelapse plugin, we will send a tailored message
- message = "Octolapse has completed rendering a timelapse for camera '{0}'. Your video is now available " \
- "within the default timelapse plugin tab as '{1}'. Octolapse ".format(
- payload.CameraName,
- payload.get_synchronization_filename()
- )
-
- else:
+ if payload.RenderingEnabled:
# This timelapse won't be moved into the octoprint timelapse plugin folder.
- message = "Octolapse has completed rendering a timelapse for camera '{0}'. Due to your rendering " \
- "settings, the timelapse was not synchronized with the OctoPrint plugin. You should be able" \
- " to find your video within your octoprint server here:
'{1}'".format(
- payload.CameraName,
- payload.get_rendering_path()
- )
-
- self.send_render_success_message(message, payload.Synchronize)
+ message = "Octolapse has completed rendering a timelapse for camera '{0}'. Your video can be found by " \
+ "clicking 'Videos and Images' within the Octolapse tab.".format(payload.CameraName)
+ if payload.ArchivePath and os.path.isfile(payload.ArchivePath):
+ message += " An archive of your snapshots can be found within the 'Saved Snapshots' tab of the " \
+ "'Videos and Images' dialog."
+ else:
+ message = (
+ "Octolapse has completed creating an archive of your snapshots for camera '{0}'. Your archive "
+ "is available within the 'Saved Snapshots' tab of the 'Videos and Images' dialog."
+ .format(payload.CameraName)
+ )
+ self.send_render_success_message(message, job)
+
+ output_file_name = "{0}.{1}".format(payload.RenderingFilename, payload.RenderingExtension)
+ gcode_filename = "{0}.{1}".format(payload.GcodeFilename, payload.GcodeFileExtension)
+ # Todo: Make sure this path is correct
+ output_file_path = payload.get_rendering_path()
+
+ # Notify the plugin that a timelapse has been added, if it exists
+ if os.path.isfile(output_file_path):
+ file_info = utility.get_file_info(output_file_path)
+ file_info["type"] = utility.FILE_TYPE_TIMELAPSE_OCTOLAPSE
+ self.send_files_changed_message(
+ file_info,
+ 'added',
+ None
+ )
+ # Notify anyone who cares that Octolapse has finished rendering a movie
+ self.send_movie_done_event(gcode_filename, output_file_path, output_file_name)
+
+ archive_path = payload.ArchivePath
+ # we need to make sure the archive path exists again, since some plugin might have deleted it.
+ if archive_path and os.path.isfile(archive_path):
+ # Notify the plugin that an archive was created
+ file_info = utility.get_file_info(archive_path)
+ file_info["type"] = utility.FILE_TYPE_SNAPSHOT_ARCHIVE
+ self.send_files_changed_message(
+ file_info,
+ 'added',
+ None
+ )
+ # Notify anyone who cares that Octolapse has finished creating a snapshot archive
+ self.send_snapshot_archive_done_event(gcode_filename, archive_path, os.path.basename(archive_path))
+
+ def on_render_progress(self, payload, job):
+ self.send_render_progress_message(payload, job)
+
+ def send_movie_done_event(self, gcode_filename, movie_path, movie_basename):
+ event = Events.PLUGIN_OCTOLAPSE_MOVIE_DONE
+ custom_payload = dict(
+ gcode=gcode_filename,
+ movie=movie_path,
+ movie_basename=movie_basename
+ )
+ self._event_bus.fire(event, payload=custom_payload)
+
+ def send_snapshot_archive_done_event(self, gcode_filename, archive_path, archive_basename):
+ event = Events.PLUGIN_OCTOLAPSE_SNAPSHOT_ARCHIVE_DONE
+ custom_payload = dict(
+ gcode=gcode_filename,
+ archive=archive_path,
+ archive_basename=archive_basename
+ )
+ self._event_bus.fire(event, payload=custom_payload)
- def on_render_error(self, payload, error):
- """Called after all rendering and synchronization attempts are complete."""
+ def on_render_error(self, payload, error, job):
+ """Called after all rendering is complete."""
if payload:
assert (isinstance(payload, RenderingCallbackArgs))
if payload.BeforeRenderError or payload.AfterRenderError:
@@ -2561,14 +3649,13 @@ def on_render_error(self, payload, error):
if payload.AfterRenderError:
pre_post_render_message += " The after script failed with the following error:" \
" {0}".format(payload.AfterRenderError)
- self.send_render_failed_message(pre_post_render_message)
-
- if error != None:
+ self.send_render_failed_message(pre_post_render_message, job)
+ if error is not None:
message = "Rendering failed for camera '{0}'. {1}".format(payload.CameraName, error)
- self.send_render_failed_message(message)
+ self.send_render_failed_message(message, job)
else:
message = "Rendering failed. {0}".format(error)
- self.send_render_failed_message(message)
+ self.send_render_failed_message(message, job)
def on_render_end(self):
self.send_render_end_message()
@@ -2599,96 +3686,141 @@ def get_assets(self):
"js/octolapse.profiles.camera.js",
"js/octolapse.profiles.camera.webcam.js",
"js/octolapse.profiles.camera.webcam.mjpg_streamer.js",
- "js/octolapse.profiles.debug.js",
+ "js/octolapse.profiles.logging.js",
"js/octolapse.status.js",
"js/octolapse.status.snapshotplan.js",
"js/octolapse.status.snapshotplan_preview.js",
"js/octolapse.help.js",
"js/octolapse.profiles.library.js",
- "js/webcams/mjpg_streamer/raspi_cam_v2.js"
+ "js/webcams/mjpg_streamer/raspi_cam_v2.js",
+ "js/octolapse.dialog.js",
+ "js/octolapse.dialog.renderings.unfinished.js",
+ "js/octolapse.dialog.renderings.in_process.js",
+ "js/octolapse.file_browser.js",
+ "js/octolapse.helpers.js",
+ "js/octolapse.dialog.timelapse_files.js"
],
css=["css/jquery.minicolors.css", "css/octolapse.css"],
less=["less/octolapse.less"]
)
- # ~~ software update hook
- def get_update_information(self):
- # get the checkout type from the software updater
- prerelease_channel = None
- is_prerelease = False
- # get this for reference. Eventually I'll have to use it!
- # is the software update set to prerelease?
-
- if self._settings.global_get(["plugins", "softwareupdate", "checks", "octoprint", "prerelease"]):
- # If it's a prerelease, look at the channel and configure the proper branch for Octolapse
- prerelease_channel = self._settings.global_get(
- ["plugins", "softwareupdate", "checks", "octoprint", "prerelease_channel"]
+ octolapse_update_info = dict(
+ displayName="Octolapse",
+ # version check: github repository
+ type="github_release",
+ user="FormerLurker",
+ repo="Octolapse",
+ pip="https://github.com/FormerLurker/Octolapse/archive/{target_version}.zip",
+ stable_branch=dict(branch="master", commitish=["master"], name="Stable"),
+ release_compare='custom',
+ prerelease_branches=[
+ dict(
+ branch="rc/maintenance",
+ commitish=["master", "rc/maintenance"], # maintenance RCs
+ name="Maintenance RCs"
+ ),
+ dict(
+ branch="rc/devel",
+ commitish=["master", "rc/maintenance", "rc/devel"], # devel & maintenance RCs
+ name="Devel RCs"
)
- if prerelease_channel == "rc/maintenance":
- is_prerelease = True
- prerelease_channel = "rc/maintenance"
- elif prerelease_channel == "rc/devel":
- is_prerelease = True
- prerelease_channel = "rc/devel"
-
- octolapse_info = dict(
- displayName="Octolapse",
- displayVersion=self._plugin_version,
- # version check: github repository
- type="github_release",
- user="FormerLurker",
- repo="Octolapse",
- current=self._plugin_version,
- prerelease=is_prerelease,
- pip="https://github.com/FormerLurker/Octolapse/archive/{target_version}.zip",
- stable_branch=dict(branch="master", commitish=["master"], name="Stable"),
- release_compare='semantic_version',
- prerelease_branches=[
- dict(
- branch="rc/maintenance",
- commitish=["rc/maintenance"], # maintenance RCs
- name="Maintenance RCs"
- ),
- dict(
- branch="rc/devel",
- commitish=["rc/maintenance", "rc/devel"], # devel & maintenance RCs
- name="Devel RCs"
+ ],
+ )
+
+ def get_release_info(self):
+ # Starting with V1.5.0 prerelease branches are supported!
+ if LooseVersion(octoprint.server.VERSION) < LooseVersion("1.5.0"):
+ # get the checkout type from the software updater
+ prerelease_channel = None
+ is_prerelease = False
+ # get this for reference. Eventually I'll have to use it!
+ # is the software update set to prerelease?
+ if self._settings.global_get(["plugins", "softwareupdate", "checks", "octoprint", "prerelease"]):
+ # If it's a prerelease, look at the channel and configure the proper branch for Arc Welder
+ prerelease_channel = self._settings.global_get(
+ ["plugins", "softwareupdate", "checks", "octoprint", "prerelease_channel"]
)
- ],
- )
-
- if prerelease_channel is not None:
- octolapse_info["prerelease_channel"] = prerelease_channel
- # return the update config
+ if prerelease_channel == "rc/maintenance":
+ is_prerelease = True
+ prerelease_channel = "rc/maintenance"
+ elif prerelease_channel == "rc/devel":
+ is_prerelease = True
+ prerelease_channel = "rc/devel"
+ OctolapsePlugin.octolapse_update_info["prerelease"] = is_prerelease
+ if prerelease_channel is not None:
+ OctolapsePlugin.octolapse_update_info["prerelease_channel"] = prerelease_channel
+
+ OctolapsePlugin.octolapse_update_info["displayVersion"] = self._plugin_version
+ OctolapsePlugin.octolapse_update_info["current"] = self._plugin_version
return dict(
- octolapse=octolapse_info
+ octolapse=OctolapsePlugin.octolapse_update_info
)
+ # ~~ software update hook
+ def get_update_information(self):
+ return self.get_release_info()
+
# noinspection PyUnusedLocal
def get_timelapse_extensions(self, *args, **kwargs):
allowed_extensions = ["mpg", "mpeg", "mp4", "m4v", "mkv", "gif", "avi", "flv", "vob"]
-
if sys.version_info < (3,0):
return [i.encode('ascii', 'replace') for i in allowed_extensions]
return allowed_extensions
-
def bodysize_hook(self, current_max_body_sizes, *args, **kwargs):
- max_upload_size_mb = 5 # 5mb bytes
- return [("POST", "/importSettings", 1024*1024*max_upload_size_mb)]
-
-# If you want your plugin to be registered within OctoPrin#t under a different
-# name than what you defined in setup.py
-# ("OctoPrint-PluginSkeleton"), you may define that here. Same goes for the
-# other metadata derived from setup.py that
-# can be overwritten via __plugin_xyz__ control properties. See the
-# documentation for that.
+ max_settings_upload_mb = 5
+ # TODO - Add max_snapshot_upload_mb setting to config.yaml
+ max_snapshot_upload_mb = 1024 # 1GB
+ return [
+ ("POST", "/importSettings", 1024*1024*max_settings_upload_mb),
+ ("POST", "/importSnapshots", 1024*1024*max_snapshot_upload_mb)
+
+ ]
+
+ def register_custom_events(*args, **kwargs):
+ return ["movie_done", "snapshot_archive_done"]
+
+ def register_custom_routes(self, server_routes, *args, **kwargs):
+ # version specific permission validator
+ if LooseVersion(octoprint.server.VERSION) >= LooseVersion("1.4"):
+ admin_validation_chain = [
+ util.tornado.access_validation_factory(app, util.flask.admin_validator),
+ ]
+ else:
+ # the concept of granular permissions does not exist in this version of Octoprint. Fallback to the
+ # admin role
+ def admin_permission_validator(flask_request):
+ user = util.flask.get_flask_user_from_request(flask_request)
+ if user is None or not user.is_authenticated() or not user.is_admin():
+ raise tornado.web.HTTPError(403)
+ permission_validator = admin_permission_validator
+ admin_validation_chain = [util.tornado.access_validation_factory(app, permission_validator), ]
+
+
+ return [
+ (
+ r"/downloadFile",
+ OctolapseLargeResponseHandler,
+ dict(
+ request_callback=self.download_file_request,
+ as_attachment=True,
+ access_validation=util.tornado.validation_chain(*admin_validation_chain)
+ )
+ ),
+ (
+ r"/getSnapshot",
+ OctolapseLargeResponseHandler,
+ dict(
+ request_callback=self.get_snapshot_request,
+ as_attachment=False
+ )
+ )
+ ]
__plugin_name__ = "Octolapse"
-__plugin_pythoncompat__ = ">=2.7,<4"
-
+__plugin_pythoncompat__ = ">=3.7,<4"
def __plugin_load__():
global __plugin_implementation__
@@ -2701,5 +3833,62 @@ def __plugin_load__():
"octoprint.comm.protocol.gcode.sending": (__plugin_implementation__.on_gcode_sending, -1),
"octoprint.comm.protocol.gcode.received": (__plugin_implementation__.on_gcode_received, -1),
"octoprint.timelapse.extensions": __plugin_implementation__.get_timelapse_extensions,
- "octoprint.server.http.bodysize": __plugin_implementation__.bodysize_hook
+ "octoprint.server.http.bodysize": __plugin_implementation__.bodysize_hook,
+ "octoprint.events.register_custom_events": __plugin_implementation__.register_custom_events,
+ "octoprint.server.http.routes": __plugin_implementation__.register_custom_routes
}
+
+
+class OctolapseLargeResponseHandler(LargeResponseHandler):
+ def initialize(
+ self, request_callback, as_attachment=False, access_validation=None, default_filename=None,
+ on_before_request=None, on_after_request=None
+ ):
+ super().initialize(
+ '', default_filename=default_filename, as_attachment=as_attachment, allow_client_caching=False,
+ access_validation=access_validation, path_validation=None, etag_generator=None,
+ name_generator=self.name_generator, mime_type_guesser=None)
+ self.download_file_name = None
+ self._before_request_callback = on_before_request
+ self._request_callback = request_callback
+ self._after_request_callback = on_after_request
+ self.after_request_internal = None
+ self.after_request_internal_args = None
+
+ def name_generator(self, path):
+ if self.download_file_name is not None:
+ return self.download_file_name
+
+ def prepare(self):
+ if self._before_request_callback:
+ self._before_request_callback()
+
+ def get(self, include_body=True):
+ if self._access_validation is not None:
+ self._access_validation(self.request)
+
+ if "cookie" in self.request.arguments:
+ self.set_cookie(self.request.arguments["cookie"][0], "true", path="/")
+ full_path = self._request_callback(self)
+ self.root = utility.get_directory_from_full_path(full_path)
+
+ # if the file does not exist, return a 404
+ if not os.path.isfile(full_path):
+ raise tornado.web.HTTPError(404)
+
+ # return the file
+ return tornado.web.StaticFileHandler.get(self, full_path, include_body=include_body)
+
+ def on_finish(self):
+ if self.after_request_internal:
+ self.after_request_internal(**self.after_request_internal_args)
+
+ if self._after_request_callback:
+ self._after_request_callback()
+
+
+
+from ._version import get_versions
+__version__ = get_versions()['version']
+__git_version__ = get_versions()['full-revisionid']
+del get_versions
diff --git a/octoprint_octolapse/_version.py b/octoprint_octolapse/_version.py
new file mode 100644
index 00000000..aec13b46
--- /dev/null
+++ b/octoprint_octolapse/_version.py
@@ -0,0 +1,520 @@
+
+# This file helps to compute a version number in source trees obtained from
+# git-archive tarball (such as those provided by githubs download-from-tag
+# feature). Distribution tarballs (built by setup.py sdist) and build
+# directories (produced by setup.py build) will contain a much shorter file
+# that just contains the computed version number.
+
+# This file is released into the public domain. Generated by
+# versioneer-0.18 (https://github.com/warner/python-versioneer)
+
+"""Git implementation of _version.py."""
+
+import errno
+import os
+import re
+import subprocess
+import sys
+
+
+def get_keywords():
+ """Get the keywords needed to look up the version information."""
+ # these strings will be replaced by git during git-archive.
+ # setup.py/versioneer.py will grep for the variable names, so they must
+ # each be defined on a line of their own. _version.py will just call
+ # get_keywords().
+ git_refnames = "$Format:%d$"
+ git_full = "$Format:%H$"
+ git_date = "$Format:%ci$"
+ keywords = {"refnames": git_refnames, "full": git_full, "date": git_date}
+ return keywords
+
+
+class VersioneerConfig:
+ """Container for Versioneer configuration parameters."""
+
+
+def get_config():
+ """Create, populate and return the VersioneerConfig() object."""
+ # these strings are filled in when 'setup.py versioneer' creates
+ # _version.py
+ cfg = VersioneerConfig()
+ cfg.VCS = "git"
+ cfg.style = "pep440"
+ cfg.tag_prefix = ""
+ cfg.parentdir_prefix = ""
+ cfg.versionfile_source = "octoprint_octolapse/_version.py"
+ cfg.verbose = False
+ return cfg
+
+
+class NotThisMethod(Exception):
+ """Exception raised if a method is not valid for the current scenario."""
+
+
+LONG_VERSION_PY = {}
+HANDLERS = {}
+
+
+def register_vcs_handler(vcs, method): # decorator
+ """Decorator to mark a method as the handler for a particular VCS."""
+ def decorate(f):
+ """Store f in HANDLERS[vcs][method]."""
+ if vcs not in HANDLERS:
+ HANDLERS[vcs] = {}
+ HANDLERS[vcs][method] = f
+ return f
+ return decorate
+
+
+def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False,
+ env=None):
+ """Call the given command(s)."""
+ assert isinstance(commands, list)
+ p = None
+ for c in commands:
+ try:
+ dispcmd = str([c] + args)
+ # remember shell=False, so use git.cmd on windows, not just git
+ p = subprocess.Popen([c] + args, cwd=cwd, env=env,
+ stdout=subprocess.PIPE,
+ stderr=(subprocess.PIPE if hide_stderr
+ else None))
+ break
+ except EnvironmentError:
+ e = sys.exc_info()[1]
+ if e.errno == errno.ENOENT:
+ continue
+ if verbose:
+ print("unable to run %s" % dispcmd)
+ print(e)
+ return None, None
+ else:
+ if verbose:
+ print("unable to find command, tried %s" % (commands,))
+ return None, None
+ stdout = p.communicate()[0].strip()
+ if sys.version_info[0] >= 3:
+ stdout = stdout.decode()
+ if p.returncode != 0:
+ if verbose:
+ print("unable to run %s (error)" % dispcmd)
+ print("stdout was %s" % stdout)
+ return None, p.returncode
+ return stdout, p.returncode
+
+
+def versions_from_parentdir(parentdir_prefix, root, verbose):
+ """Try to determine the version from the parent directory name.
+
+ Source tarballs conventionally unpack into a directory that includes both
+ the project name and a version string. We will also support searching up
+ two directory levels for an appropriately named parent directory
+ """
+ rootdirs = []
+
+ for i in range(3):
+ dirname = os.path.basename(root)
+ if dirname.startswith(parentdir_prefix):
+ return {"version": dirname[len(parentdir_prefix):],
+ "full-revisionid": None,
+ "dirty": False, "error": None, "date": None}
+ else:
+ rootdirs.append(root)
+ root = os.path.dirname(root) # up a level
+
+ if verbose:
+ print("Tried directories %s but none started with prefix %s" %
+ (str(rootdirs), parentdir_prefix))
+ raise NotThisMethod("rootdir doesn't start with parentdir_prefix")
+
+
+@register_vcs_handler("git", "get_keywords")
+def git_get_keywords(versionfile_abs):
+ """Extract version information from the given file."""
+ # the code embedded in _version.py can just fetch the value of these
+ # keywords. When used from setup.py, we don't want to import _version.py,
+ # so we do it with a regexp instead. This function is not used from
+ # _version.py.
+ keywords = {}
+ try:
+ f = open(versionfile_abs, "r")
+ for line in f.readlines():
+ if line.strip().startswith("git_refnames ="):
+ mo = re.search(r'=\s*"(.*)"', line)
+ if mo:
+ keywords["refnames"] = mo.group(1)
+ if line.strip().startswith("git_full ="):
+ mo = re.search(r'=\s*"(.*)"', line)
+ if mo:
+ keywords["full"] = mo.group(1)
+ if line.strip().startswith("git_date ="):
+ mo = re.search(r'=\s*"(.*)"', line)
+ if mo:
+ keywords["date"] = mo.group(1)
+ f.close()
+ except EnvironmentError:
+ pass
+ return keywords
+
+
+@register_vcs_handler("git", "keywords")
+def git_versions_from_keywords(keywords, tag_prefix, verbose):
+ """Get version information from git keywords."""
+ if not keywords:
+ raise NotThisMethod("no keywords at all, weird")
+ date = keywords.get("date")
+ if date is not None:
+ # git-2.2.0 added "%cI", which expands to an ISO-8601 -compliant
+ # datestamp. However we prefer "%ci" (which expands to an "ISO-8601
+ # -like" string, which we must then edit to make compliant), because
+ # it's been around since git-1.5.3, and it's too difficult to
+ # discover which version we're using, or to work around using an
+ # older one.
+ date = date.strip().replace(" ", "T", 1).replace(" ", "", 1)
+ refnames = keywords["refnames"].strip()
+ if refnames.startswith("$Format"):
+ if verbose:
+ print("keywords are unexpanded, not using")
+ raise NotThisMethod("unexpanded keywords, not a git-archive tarball")
+ refs = set([r.strip() for r in refnames.strip("()").split(",")])
+ # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of
+ # just "foo-1.0". If we see a "tag: " prefix, prefer those.
+ TAG = "tag: "
+ tags = set([r[len(TAG):] for r in refs if r.startswith(TAG)])
+ if not tags:
+ # Either we're using git < 1.8.3, or there really are no tags. We use
+ # a heuristic: assume all version tags have a digit. The old git %d
+ # expansion behaves like git log --decorate=short and strips out the
+ # refs/heads/ and refs/tags/ prefixes that would let us distinguish
+ # between branches and tags. By ignoring refnames without digits, we
+ # filter out many common branch names like "release" and
+ # "stabilization", as well as "HEAD" and "master".
+ tags = set([r for r in refs if re.search(r'\d', r)])
+ if verbose:
+ print("discarding '%s', no digits" % ",".join(refs - tags))
+ if verbose:
+ print("likely tags: %s" % ",".join(sorted(tags)))
+ for ref in sorted(tags):
+ # sorting will prefer e.g. "2.0" over "2.0rc1"
+ if ref.startswith(tag_prefix):
+ r = ref[len(tag_prefix):]
+ if verbose:
+ print("picking %s" % r)
+ return {"version": r,
+ "full-revisionid": keywords["full"].strip(),
+ "dirty": False, "error": None,
+ "date": date}
+ # no suitable tags, so version is "0+unknown", but full hex is still there
+ if verbose:
+ print("no suitable tags, using unknown + full revision id")
+ return {"version": "0+unknown",
+ "full-revisionid": keywords["full"].strip(),
+ "dirty": False, "error": "no suitable tags", "date": None}
+
+
+@register_vcs_handler("git", "pieces_from_vcs")
+def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command):
+ """Get version from 'git describe' in the root of the source tree.
+
+ This only gets called if the git-archive 'subst' keywords were *not*
+ expanded, and _version.py hasn't already been rewritten with a short
+ version string, meaning we're inside a checked out source tree.
+ """
+ GITS = ["git"]
+ if sys.platform == "win32":
+ GITS = ["git.cmd", "git.exe"]
+
+ out, rc = run_command(GITS, ["rev-parse", "--git-dir"], cwd=root,
+ hide_stderr=True)
+ if rc != 0:
+ if verbose:
+ print("Directory %s not under git control" % root)
+ raise NotThisMethod("'git rev-parse --git-dir' returned error")
+
+ # if there is a tag matching tag_prefix, this yields TAG-NUM-gHEX[-dirty]
+ # if there isn't one, this yields HEX[-dirty] (no NUM)
+ describe_out, rc = run_command(GITS, ["describe", "--tags", "--dirty",
+ "--always", "--long",
+ "--match", "%s*" % tag_prefix],
+ cwd=root)
+ # --long was added in git-1.5.5
+ if describe_out is None:
+ raise NotThisMethod("'git describe' failed")
+ describe_out = describe_out.strip()
+ full_out, rc = run_command(GITS, ["rev-parse", "HEAD"], cwd=root)
+ if full_out is None:
+ raise NotThisMethod("'git rev-parse' failed")
+ full_out = full_out.strip()
+
+ pieces = {}
+ pieces["long"] = full_out
+ pieces["short"] = full_out[:7] # maybe improved later
+ pieces["error"] = None
+
+ # parse describe_out. It will be like TAG-NUM-gHEX[-dirty] or HEX[-dirty]
+ # TAG might have hyphens.
+ git_describe = describe_out
+
+ # look for -dirty suffix
+ dirty = git_describe.endswith("-dirty")
+ pieces["dirty"] = dirty
+ if dirty:
+ git_describe = git_describe[:git_describe.rindex("-dirty")]
+
+ # now we have TAG-NUM-gHEX or HEX
+
+ if "-" in git_describe:
+ # TAG-NUM-gHEX
+ mo = re.search(r'^(.+)-(\d+)-g([0-9a-f]+)$', git_describe)
+ if not mo:
+ # unparseable. Maybe git-describe is misbehaving?
+ pieces["error"] = ("unable to parse git-describe output: '%s'"
+ % describe_out)
+ return pieces
+
+ # tag
+ full_tag = mo.group(1)
+ if not full_tag.startswith(tag_prefix):
+ if verbose:
+ fmt = "tag '%s' doesn't start with prefix '%s'"
+ print(fmt % (full_tag, tag_prefix))
+ pieces["error"] = ("tag '%s' doesn't start with prefix '%s'"
+ % (full_tag, tag_prefix))
+ return pieces
+ pieces["closest-tag"] = full_tag[len(tag_prefix):]
+
+ # distance: number of commits since tag
+ pieces["distance"] = int(mo.group(2))
+
+ # commit: short hex revision ID
+ pieces["short"] = mo.group(3)
+
+ else:
+ # HEX: no tags
+ pieces["closest-tag"] = None
+ count_out, rc = run_command(GITS, ["rev-list", "HEAD", "--count"],
+ cwd=root)
+ pieces["distance"] = int(count_out) # total number of commits
+
+ # commit date: see ISO-8601 comment in git_versions_from_keywords()
+ date = run_command(GITS, ["show", "-s", "--format=%ci", "HEAD"],
+ cwd=root)[0].strip()
+ pieces["date"] = date.strip().replace(" ", "T", 1).replace(" ", "", 1)
+
+ return pieces
+
+
+def plus_or_dot(pieces):
+ """Return a + if we don't already have one, else return a ."""
+ if "+" in pieces.get("closest-tag", ""):
+ return "."
+ return "+"
+
+
+def render_pep440(pieces):
+ """Build up version string, with post-release "local version identifier".
+
+ Our goal: TAG[+DISTANCE.gHEX[.dirty]] . Note that if you
+ get a tagged build and then dirty it, you'll get TAG+0.gHEX.dirty
+
+ Exceptions:
+ 1: no tags. git_describe was just HEX. 0+untagged.DISTANCE.gHEX[.dirty]
+ """
+ if pieces["closest-tag"]:
+ rendered = pieces["closest-tag"]
+ if pieces["distance"] or pieces["dirty"]:
+ rendered += plus_or_dot(pieces)
+ rendered += "%d.g%s" % (pieces["distance"], pieces["short"])
+ if pieces["dirty"]:
+ rendered += ".dirty"
+ else:
+ # exception #1
+ rendered = "0+untagged.%d.g%s" % (pieces["distance"],
+ pieces["short"])
+ if pieces["dirty"]:
+ rendered += ".dirty"
+ return rendered
+
+
+def render_pep440_pre(pieces):
+ """TAG[.post.devDISTANCE] -- No -dirty.
+
+ Exceptions:
+ 1: no tags. 0.post.devDISTANCE
+ """
+ if pieces["closest-tag"]:
+ rendered = pieces["closest-tag"]
+ if pieces["distance"]:
+ rendered += ".post.dev%d" % pieces["distance"]
+ else:
+ # exception #1
+ rendered = "0.post.dev%d" % pieces["distance"]
+ return rendered
+
+
+def render_pep440_post(pieces):
+ """TAG[.postDISTANCE[.dev0]+gHEX] .
+
+ The ".dev0" means dirty. Note that .dev0 sorts backwards
+ (a dirty tree will appear "older" than the corresponding clean one),
+ but you shouldn't be releasing software with -dirty anyways.
+
+ Exceptions:
+ 1: no tags. 0.postDISTANCE[.dev0]
+ """
+ if pieces["closest-tag"]:
+ rendered = pieces["closest-tag"]
+ if pieces["distance"] or pieces["dirty"]:
+ rendered += ".post%d" % pieces["distance"]
+ if pieces["dirty"]:
+ rendered += ".dev0"
+ rendered += plus_or_dot(pieces)
+ rendered += "g%s" % pieces["short"]
+ else:
+ # exception #1
+ rendered = "0.post%d" % pieces["distance"]
+ if pieces["dirty"]:
+ rendered += ".dev0"
+ rendered += "+g%s" % pieces["short"]
+ return rendered
+
+
+def render_pep440_old(pieces):
+ """TAG[.postDISTANCE[.dev0]] .
+
+ The ".dev0" means dirty.
+
+ Eexceptions:
+ 1: no tags. 0.postDISTANCE[.dev0]
+ """
+ if pieces["closest-tag"]:
+ rendered = pieces["closest-tag"]
+ if pieces["distance"] or pieces["dirty"]:
+ rendered += ".post%d" % pieces["distance"]
+ if pieces["dirty"]:
+ rendered += ".dev0"
+ else:
+ # exception #1
+ rendered = "0.post%d" % pieces["distance"]
+ if pieces["dirty"]:
+ rendered += ".dev0"
+ return rendered
+
+
+def render_git_describe(pieces):
+ """TAG[-DISTANCE-gHEX][-dirty].
+
+ Like 'git describe --tags --dirty --always'.
+
+ Exceptions:
+ 1: no tags. HEX[-dirty] (note: no 'g' prefix)
+ """
+ if pieces["closest-tag"]:
+ rendered = pieces["closest-tag"]
+ if pieces["distance"]:
+ rendered += "-%d-g%s" % (pieces["distance"], pieces["short"])
+ else:
+ # exception #1
+ rendered = pieces["short"]
+ if pieces["dirty"]:
+ rendered += "-dirty"
+ return rendered
+
+
+def render_git_describe_long(pieces):
+ """TAG-DISTANCE-gHEX[-dirty].
+
+ Like 'git describe --tags --dirty --always -long'.
+ The distance/hash is unconditional.
+
+ Exceptions:
+ 1: no tags. HEX[-dirty] (note: no 'g' prefix)
+ """
+ if pieces["closest-tag"]:
+ rendered = pieces["closest-tag"]
+ rendered += "-%d-g%s" % (pieces["distance"], pieces["short"])
+ else:
+ # exception #1
+ rendered = pieces["short"]
+ if pieces["dirty"]:
+ rendered += "-dirty"
+ return rendered
+
+
+def render(pieces, style):
+ """Render the given version pieces into the requested style."""
+ if pieces["error"]:
+ return {"version": "unknown",
+ "full-revisionid": pieces.get("long"),
+ "dirty": None,
+ "error": pieces["error"],
+ "date": None}
+
+ if not style or style == "default":
+ style = "pep440" # the default
+
+ if style == "pep440":
+ rendered = render_pep440(pieces)
+ elif style == "pep440-pre":
+ rendered = render_pep440_pre(pieces)
+ elif style == "pep440-post":
+ rendered = render_pep440_post(pieces)
+ elif style == "pep440-old":
+ rendered = render_pep440_old(pieces)
+ elif style == "git-describe":
+ rendered = render_git_describe(pieces)
+ elif style == "git-describe-long":
+ rendered = render_git_describe_long(pieces)
+ else:
+ raise ValueError("unknown style '%s'" % style)
+
+ return {"version": rendered, "full-revisionid": pieces["long"],
+ "dirty": pieces["dirty"], "error": None,
+ "date": pieces.get("date")}
+
+
+def get_versions():
+ """Get version information or return default if unable to do so."""
+ # I am in _version.py, which lives at ROOT/VERSIONFILE_SOURCE. If we have
+ # __file__, we can work backwards from there to the root. Some
+ # py2exe/bbfreeze/non-CPython implementations don't do __file__, in which
+ # case we can only use expanded keywords.
+
+ cfg = get_config()
+ verbose = cfg.verbose
+
+ try:
+ return git_versions_from_keywords(get_keywords(), cfg.tag_prefix,
+ verbose)
+ except NotThisMethod:
+ pass
+
+ try:
+ root = os.path.realpath(__file__)
+ # versionfile_source is the relative path from the top of the source
+ # tree (where the .git directory might live) to this file. Invert
+ # this to find the root from __file__.
+ for i in cfg.versionfile_source.split('/'):
+ root = os.path.dirname(root)
+ except NameError:
+ return {"version": "0+unknown", "full-revisionid": None,
+ "dirty": None,
+ "error": "unable to find root of source tree",
+ "date": None}
+
+ try:
+ pieces = git_pieces_from_vcs(cfg.tag_prefix, root, verbose)
+ return render(pieces, cfg.style)
+ except NotThisMethod:
+ pass
+
+ try:
+ if cfg.parentdir_prefix:
+ return versions_from_parentdir(cfg.parentdir_prefix, root, verbose)
+ except NotThisMethod:
+ pass
+
+ return {"version": "0+unknown", "full-revisionid": None,
+ "dirty": None,
+ "error": "unable to compute version", "date": None}
diff --git a/octoprint_octolapse/camera.py b/octoprint_octolapse/camera.py
index bebf3730..811cb195 100644
--- a/octoprint_octolapse/camera.py
+++ b/octoprint_octolapse/camera.py
@@ -1,7 +1,7 @@
# coding=utf-8
##################################################################################
# Octolapse - A plugin for OctoPrint used for making stabilized timelapse videos.
-# Copyright (C) 2019 Brad Hochgesang
+# Copyright (C) 2023 Brad Hochgesang
##################################################################################
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published
@@ -22,18 +22,25 @@
##################################################################################
from __future__ import unicode_literals
import json
-import six
+# remove unused imports
+# import six
import octoprint_octolapse.utility as utility
+import octoprint_octolapse.script as script
from threading import Thread
import time
+import uuid
+import shutil
# This file is subject to the terms and conditions defined in
# file called 'LICENSE', which is part of this source code package.
import requests
import os
+import errno
# Todo: Do we need to add this to setup.py?
-from requests.auth import HTTPBasicAuth
+# remove unused import
+# from requests.auth import HTTPBasicAuth
from requests.exceptions import SSLError
from octoprint_octolapse.settings import CameraProfile, MjpgStreamerControl, MjpgStreamer
+from tempfile import mkdtemp
# create the module level logger
from octoprint_octolapse.log import LoggingConfigurator
@@ -73,7 +80,9 @@ def _load_camera_types(data_path):
server_types = camera_files["server_types"]
# loop through each server type
- for key, server_type in six.iteritems(server_types):
+ # remove python 2 compatibility
+ # for key, server_type in six.iteritems(server_types):
+ for key, server_type in server_types.items():
camera_types["server_types"][key] = {'cameras': {}}
# load all of the individual camera type info files for the current server
for file_name in server_type["file_names"]:
@@ -120,7 +129,9 @@ def get_webcam_type(data_path, server_type, data):
control_setting.update(control)
controls[control_setting.id] = control_setting
elif isinstance(data, dict):
- for key, control in six.iteritems(data):
+ # remove python 2 compatibility
+ # for key, control in six.iteritems(data):
+ for key, control in data.items():
control_setting = MjpgStreamerControl()
control_setting.update(control)
controls[key] = control_setting
@@ -136,7 +147,9 @@ def get_webcam_type(data_path, server_type, data):
cameras = CameraControl.camera_types["server_types"][server_type]["cameras"]
# iterate the cameras dictionary for the given server type if possible
- for key, camera in six.iteritems(cameras):
+ # remove python 2 compatibility
+ # for key, camera in six.iteritems(cameras):
+ for key, camera in cameras.items():
# see if the current camera type matches the given data
if CameraControl._is_webcam_type(server_type, camera, data):
return {
@@ -166,45 +179,126 @@ def _is_webcam_type(server_type, camera, data):
return False
@staticmethod
- def apply_camera_settings(cameras, timeout_seconds=5):
+ def apply_camera_settings(cameras, retries=3, backoff_factor=0.1, no_wait=False):
errors = []
# make sure the supplied cameras are enabled and either webcams or script cameras
cameras = [x for x in cameras if x.camera_type in [
- CameraProfile.camera_type_webcam,
- CameraProfile.camera_type_script
+ CameraProfile.camera_type_webcam
]]
# create the threads
threads = []
for current_camera in cameras:
thread = None
- if current_camera.camera_type == CameraProfile.camera_type_webcam:
- if current_camera.webcam_settings.server_type == MjpgStreamer.server_type:
- thread = MjpgStreamerSettingsThread(profile=current_camera)
- elif current_camera.camera_type == CameraProfile.camera_type_script:
- thread = CameraSettingScriptThread(current_camera)
+
+ if current_camera.webcam_settings.server_type == MjpgStreamer.server_type:
+ thread = MjpgStreamerSettingsThread(profile=current_camera, retries=retries, backoff_factor=backoff_factor)
+ thread.daemon = True
if thread:
threads.append(thread)
+ # start the threads
+ for thread in threads:
+ thread.start()
+
+ if not no_wait:
+ # join the threads, but timeout in a reasonable way
+ for thread in threads:
+ thread.join(requests.packages.urllib3.util.retry.Retry.BACKOFF_MAX)
+ if not thread.success:
+ for error in thread.errors:
+ errors.append(error)
+ return len(errors) == 0, CameraControl._get_errors_string(errors)
+ return True, ""
+
+ @staticmethod
+ def run_on_print_start_script(cameras):
+ # build the arg list
+ args_list = []
+ # remove python 2 compatibility
+ # for key, camera in six.iteritems(cameras):
+ for key, camera in cameras.items():
+ script_path = camera.on_print_start_script.strip()
+ if not script_path or not camera.enabled:
+ # skip any cameras where there is no on print start script or the camera is disabled
+ continue
+ script_args = [camera.name]
+ script_type = 'on print start'
+
+ args = {
+ 'camera': camera,
+ 'script_path': script_path,
+ 'script_args': script_args,
+ 'script_type': script_type
+ }
+ args_list.append(args)
+
+ if len(args_list) > 0:
+ return CameraControl._run_camera_scripts(args_list)
+
+ return True, None
+
+ @staticmethod
+ def run_on_print_end_script(cameras):
+ # build the arg list
+ args_list = []
+ # remove python 2 compatibility
+ # for key, camera in six.iteritems(cameras):
+ for key, camera in cameras.items():
+ script_path = camera.on_print_end_script.strip()
+ if not script_path or not camera.enabled:
+ # skip any cameras where there is no on print start script or the camera is disabled
+ continue
+ script_args = [camera.name]
+ script_type = 'on print end'
+
+ args = {
+ 'camera': camera,
+ 'script_path': script_path,
+ 'script_args': script_args,
+ 'script_type': script_type
+ }
+ args_list.append(args)
+
+ if len(args_list) > 0:
+ return CameraControl._run_camera_scripts(args_list)
+ return True, None
+
+ @staticmethod
+ def _run_camera_scripts(args_list, timeout_seconds=5):
+ errors = []
+
+ # create the threads
+ threads = []
+ for args in args_list:
+ thread = CameraSettingScriptThread(
+ args['camera'],
+ args['script_path'],
+ args['script_args'],
+ args['script_type']
+ )
+ thread.daemon = True
+ threads.append(thread)
+
# start the threads
for thread in threads:
thread.start()
start_time = time.time()
- timeout_time = start_time + 5
+ timeout_time = start_time + timeout_seconds
# join the threads, but timeout in a reasonable way
for thread in threads:
timeout_sec = timeout_time - time.time()
if timeout_sec < 0:
timeout_sec = 0.001
thread.join(timeout_sec)
- if not thread.success:
- for error in thread.errors:
- errors.append(error)
+ if thread.error:
+ errors.append(thread.error)
return len(errors) == 0, CameraControl._get_errors_string(errors)
+
@staticmethod
def _get_errors_string(errors):
if len(errors) == 0:
@@ -223,7 +317,6 @@ def _get_errors_string(errors):
unknown_errors = "{} unknown errors, check plugin.octolapse.log for details.".format(unknown_errors_count)
return "{}{}".format(camera_errors, unknown_errors)
-
@staticmethod
def apply_webcam_setting(
server_type,
@@ -232,19 +325,18 @@ def apply_webcam_setting(
address,
username,
password,
- ignore_ssl_error,
- timeout_seconds=5
+ ignore_ssl_error
):
if server_type == 'mjpg-streamer':
thread = MjpgStreamerSettingThread(
setting,
- camera_name=camera_name,
- address=address,
- username=username,
- password=password,
- ignore_ssl_error=ignore_ssl_error,
- timeout_seconds=timeout_seconds
+ address,
+ username,
+ password,
+ ignore_ssl_error,
+ camera_name
)
+ thread.daemon = True
thread.start()
thread.join()
return thread.success, thread.errors
@@ -261,7 +353,8 @@ def get_webcam_settings(
username,
password,
ignore_ssl_error,
- timeout_seconds=5
+ retries=3,
+ backoff_factor=0.1
):
try:
errors = []
@@ -274,8 +367,10 @@ def get_webcam_settings(
password=password,
ignore_ssl_error=ignore_ssl_error,
camera_name=camera_name,
- timeout_seconds=timeout_seconds
+ retries=retries,
+ backoff_factor=backoff_factor
)
+ thread.daemon = True
thread.start()
thread.join()
if thread.errors:
@@ -332,14 +427,24 @@ def get_webcam_server_type_from_request(r):
def _test_mjpg_streamer_control(camera_profile, timeout_seconds=2):
url = camera_profile.webcam_settings.address + "?action=command&id=-1"
try:
+ s = requests.Session()
+ s.verify = not camera_profile.webcam_settings.ignore_ssl_error
if len(camera_profile.webcam_settings.username) > 0:
- r = requests.get(url, auth=HTTPBasicAuth(camera_profile.webcam_settings.username,
- camera_profile.webcam_settings.password),
- verify=not camera_profile.webcam_settings.ignore_ssl_error,
- timeout=float(timeout_seconds))
- else:
- r = requests.get(
- url, verify=not camera_profile.webcam_settings.ignore_ssl_error, timeout=float(timeout_seconds))
+ s.auth = (
+ camera_profile.webcam_settings.username,
+ camera_profile.webcam_settings.password
+ )
+
+ r = utility.requests_retry_session(session=s).request("GET", url, timeout=timeout_seconds)
+
+ #if len(camera_profile.webcam_settings.username) > 0:
+ # r = requests.get(url, auth=HTTPBasicAuth(camera_profile.webcam_settings.username,
+ # camera_profile.webcam_settings.password),
+ # verify=not camera_profile.webcam_settings.ignore_ssl_error,
+ # timeout=float(timeout_seconds))
+ #else:
+ # r = requests.get(
+ # url, verify=not camera_profile.webcam_settings.ignore_ssl_error, timeout=float(timeout_seconds))
webcam_server_type = CameraControl.get_webcam_server_type_from_request(r)
if webcam_server_type != "mjpg-streamer":
@@ -350,15 +455,14 @@ def _test_mjpg_streamer_control(camera_profile, timeout_seconds=2):
raise CameraError('unknown-server-type', message)
if r.status_code == 501:
message = "The server denied access to the mjpg-streamer control.htm for the '{0}' camera profile. Please see this link to correct this " \
+ "href=\"https://github.com/FormerLurker/Octolapse/wiki/V0.4---Enabling-Camera-Controls\" target = \"_blank\">Please see this link to correct this " \
"error., or disable the 'Enable And Apply Preferences at Startup' and " \
"'Enable And Apply Preferences Before Print' options.".format(camera_profile.name)
logger.error(message)
- raise CameraError("mjpg_streamer-control-error", message)
+ raise CameraError("mjpg_streamer_control_error", message)
if r.status_code != requests.codes.ok:
message = "Status code received ({0}) was not OK. Double check your webcam 'Base Addresss' address and " \
- "your 'Snapshot Address Template'. Or, disable the 'Enable And Apply Preferences at Startup' " \
+ "your 'Snapshot Address'. Or, disable the 'Enable And Apply Preferences at Startup' " \
"and 'Enable And Apply Preferences Before Print' options for the {1} camera " \
"profile and try again.".format(r.status_code, camera_profile.name)
logger.error(message)
@@ -392,15 +496,27 @@ def _test_web_camera_image_preferences(camera_profile, timeout_seconds=2):
# first see what kind of server we have
url = format_request_template(
camera_profile.webcam_settings.address, camera_profile.webcam_settings.snapshot_request_template, "")
+
try:
+ s = requests.Session()
+ s.verify = not camera_profile.webcam_settings.ignore_ssl_error
if len(camera_profile.webcam_settings.username) > 0:
- r = requests.get(url, auth=HTTPBasicAuth(camera_profile.webcam_settings.username,
- camera_profile.webcam_settings.password),
- verify=not camera_profile.webcam_settings.ignore_ssl_error,
- timeout=float(timeout_seconds))
- else:
- r = requests.get(url, verify=not camera_profile.webcam_settings.ignore_ssl_error,
- timeout=float(timeout_seconds))
+ s.auth = (
+ camera_profile.webcam_settings.username,
+ camera_profile.webcam_settings.password
+ )
+
+ r = utility.requests_retry_session(session=s).request("GET", url, timeout=timeout_seconds)
+
+
+ # if len(camera_profile.webcam_settings.username) > 0:
+ # r = requests.get(url, auth=HTTPBasicAuth(camera_profile.webcam_settings.username,
+ # camera_profile.webcam_settings.password),
+ # verify=not camera_profile.webcam_settings.ignore_ssl_error,
+ # timeout=float(timeout_seconds))
+ # else:
+ # r = requests.get(url, verify=not camera_profile.webcam_settings.ignore_ssl_error,
+ # timeout=float(timeout_seconds))
webcam_server_type = CameraControl.get_webcam_server_type_from_request(r)
@@ -413,7 +529,7 @@ def _test_web_camera_image_preferences(camera_profile, timeout_seconds=2):
raise CameraError('ssl-error', message, cause=e)
except requests.ConnectionError as e:
message = "Unable to connect to '{0}' for the '{1}' camera profile. Please double check your 'Base Address' " \
- "and 'Snapshot Address Template' settings.".format(url, camera_profile.name)
+ "and 'Snapshot Address' settings.".format(url, camera_profile.name)
logger.exception(message)
raise CameraError('connection-error', message, cause=e)
except Exception as e:
@@ -451,14 +567,35 @@ def _test_web_camera(camera_profile, timeout_seconds=2, is_before_print_test=Fal
url = format_request_template(
camera_profile.webcam_settings.address, camera_profile.webcam_settings.snapshot_request_template, "")
try:
+ s = requests.Session()
+ s.verify = not camera_profile.webcam_settings.ignore_ssl_error
if len(camera_profile.webcam_settings.username) > 0:
- r = requests.get(url, auth=HTTPBasicAuth(camera_profile.webcam_settings.username,
- camera_profile.webcam_settings.password),
- verify=not camera_profile.webcam_settings.ignore_ssl_error,
- timeout=float(timeout_seconds))
- else:
- r = requests.get(
- url, verify=not camera_profile.webcam_settings.ignore_ssl_error, timeout=float(timeout_seconds))
+ s.auth = (
+ camera_profile.webcam_settings.username,
+ camera_profile.webcam_settings.password
+ )
+ retry_session = utility.requests_retry_session(session=s)
+ r = retry_session.get(url, stream=True, timeout=timeout_seconds)
+
+ body = []
+ start_time = time.time()
+ for chunk in r.iter_content(1024):
+ body.append(chunk)
+ if time.time() > (start_time + timeout_seconds):
+ message = (
+ "A read timeout occurred while connecting to {0} for the '{1}' camera profile. Are you using the video stream URL by mistake?"
+ .format(url, camera_profile.name)
+ )
+ logger.exception(message)
+ raise CameraError('read-timeout', message)
+ #if len(camera_profile.webcam_settings.username) > 0:
+ # r = requests.get(url, auth=HTTPBasicAuth(camera_profile.webcam_settings.username,
+ # camera_profile.webcam_settings.password),
+ # verify=not camera_profile.webcam_settings.ignore_ssl_error,
+ # timeout=float(timeout_seconds))
+ #else:
+ # r = requests.get(
+ # url, verify=not camera_profile.webcam_settings.ignore_ssl_error, timeout=float(timeout_seconds))
if r.status_code == requests.codes.ok:
if 'content-length' in r.headers and r.headers["content-length"] == 0:
message = "The request contained no data for the '{0}' camera profile.".format(camera_profile.name)
@@ -466,16 +603,20 @@ def _test_web_camera(camera_profile, timeout_seconds=2, is_before_print_test=Fal
raise CameraError('request-contained-no-data', message)
elif "image/jpeg" not in r.headers["content-type"].lower():
message = (
- "The returned daata was not an image for the '{0}' camera profile.".format(camera_profile.name)
+ "The returned data was not an image for the '{0}' camera profile.".format(camera_profile.name)
)
logger.error(message)
raise CameraError('not-an-image', message)
- elif not is_before_print_test or camera_profile.apply_settings_before_print:
+ elif (
+ is_before_print_test and
+ camera_profile.enable_custom_image_preferences and
+ camera_profile.apply_settings_before_print
+ ):
CameraControl._test_web_camera_image_preferences(camera_profile, timeout_seconds)
else:
message = (
"An invalid status code or {0} was returned from the '{1}' camera profile."
- .format(r.status_code, camera_profile.name)
+ .format(r.status_code, camera_profile.name)
)
logger.error(message)
raise CameraError('invalid-status-code', message)
@@ -520,6 +661,283 @@ def _test_web_camera(camera_profile, timeout_seconds=2, is_before_print_test=Fal
logger.exception(message)
raise CameraError('missing-schema', message, cause=e)
+ @staticmethod
+ def test_script(camera_profile, script_type, base_folder):
+ snapshot_created = None
+ if script_type == 'snapshot':
+ success, error, snapshot_created = CameraControl._test_camera_snapshot_script(camera_profile, script_type, base_folder)
+ elif script_type in ['before-snapshot', 'after-snapshot']:
+ success, error = CameraControl._test_camera_before_after_snapshot_script(
+ camera_profile, script_type, base_folder
+ )
+ elif script_type in ['before-print', 'after-print']:
+ success, error = CameraControl._test_print_script(camera_profile, script_type, base_folder)
+ elif script_type == 'before-render':
+ success, error = CameraControl._test_before_render_script(camera_profile, script_type, base_folder)
+ elif script_type == 'after-render':
+ success, error = CameraControl._test_after_render_script(camera_profile, script_type, base_folder)
+ else:
+ return False, "An unknown script type of {0} was sent. Cannot test script".format(script), None
+
+ return success, error, snapshot_created
+
+ @staticmethod
+ def _test_camera_snapshot_script(camera_profile, script_type, base_folder):
+ """Test a script camera."""
+ # create a temp directory for use with this test
+ temp_directory = mkdtemp()
+
+ try:
+ camera_name = camera_profile.name
+ snapshot_number = 0
+ delay_seconds = camera_profile.delay
+ data_directory = temp_directory
+ snapshot_directory = os.path.join(
+ data_directory, "{}".format(uuid.uuid4()), "{}".format(uuid.uuid4())
+ )
+ snapshot_filename = utility.get_snapshot_filename(
+ "test_snapshot", snapshot_number
+ )
+ snapshot_full_path = os.path.join(snapshot_directory, snapshot_filename)
+ timeout_seconds = camera_profile.timeout_ms / 1000.0
+
+ script_path = camera_profile.external_camera_snapshot_script
+
+ cmd = script_job = script.CameraScriptSnapshot(
+ script_path,
+ camera_name,
+ snapshot_number,
+ delay_seconds,
+ data_directory,
+ snapshot_directory,
+ snapshot_filename,
+ snapshot_full_path,
+ timeout_seconds=timeout_seconds
+ )
+ cmd.run()
+ # check to see if the snapshot image was created
+ success = cmd.success()
+ snapshot_created = os.path.exists(snapshot_full_path)
+ return cmd.success(), cmd.error_message, snapshot_created
+ finally:
+ utility.rmtree(temp_directory)
+
+ @staticmethod
+ def _test_camera_before_after_snapshot_script(camera_profile, script_type, base_folder):
+ """Test a script camera."""
+ # create a temp directory for use with this test
+ temp_directory = mkdtemp()
+
+ try:
+ camera_name = camera_profile.name
+ snapshot_number = 0
+ delay_seconds = camera_profile.delay
+ data_directory = temp_directory
+ snapshot_directory = os.path.join(
+ data_directory, "{}".format(uuid.uuid4()), "{}".format(uuid.uuid4())
+ )
+ snapshot_filename = utility.get_snapshot_filename(
+ "test_snapshot", snapshot_number
+ )
+ snapshot_full_path = os.path.join(snapshot_directory, snapshot_filename)
+ timeout_seconds = camera_profile.timeout_ms / 1000.0
+
+ # setup test depending on the script_type
+ script_path = ""
+ if script_type == 'before-snapshot':
+ script_path = camera_profile.on_before_snapshot_script
+ elif script_type == 'after-snapshot':
+ script_path = camera_profile.on_after_snapshot_script
+ # we need to add an image to the temp folder, in case the script operates on the image
+ test_image_path = os.path.join(base_folder, "data", "Images", "test-snapshot-image.jpg")
+ target_directory = os.path.dirname(snapshot_full_path)
+ if not os.path.exists(target_directory):
+ try:
+ os.makedirs(target_directory)
+ except OSError as e:
+ if e.errno == errno.EEXIST:
+ pass
+ else:
+ raise
+ shutil.copy(test_image_path, snapshot_full_path)
+
+ if script_type == 'before-snapshot':
+ cmd = script.CameraScriptBeforeSnapshot(
+ script_path,
+ camera_name,
+ snapshot_number,
+ delay_seconds,
+ data_directory,
+ snapshot_directory,
+ snapshot_filename,
+ snapshot_full_path,
+ timeout_seconds=timeout_seconds
+ )
+ else:
+ cmd = script.CameraScriptAfterSnapshot(
+ script_path,
+ camera_name,
+ snapshot_number,
+ delay_seconds,
+ data_directory,
+ snapshot_directory,
+ snapshot_filename,
+ snapshot_full_path,
+ timeout_seconds=timeout_seconds
+ )
+ cmd.run()
+ return cmd.success(), cmd.error_message
+ finally:
+ utility.rmtree(temp_directory)
+
+ @staticmethod
+ def _test_print_script(camera_profile, script_type, base_folder):
+ timeout_seconds = camera_profile.timeout_ms / 1000.0
+ # build the arg list
+ if script_type == "before-print":
+ script_path = camera_profile.on_print_start_script.strip()
+ else:
+ script_path = camera_profile.on_print_end_script.strip()
+
+ if script_type == "before-print":
+ cmd = script.CameraScriptBeforePrint(
+ script_path,
+ camera_profile.name,
+ timeout_seconds=timeout_seconds
+ )
+ else:
+ cmd = script.CameraScriptAfterPrint(
+ script_path,
+ camera_profile.name,
+ timeout_seconds=timeout_seconds
+ )
+ cmd.run()
+ return cmd.success(), cmd.error_message
+
+ @staticmethod
+ def _test_before_render_script(camera_profile, script_type, base_folder):
+ timeout_seconds = camera_profile.timeout_ms / 1000.0
+ # create a temp folder for the rendered file and the snapshots
+ temp_directory = mkdtemp()
+
+ try:
+ # create 10 snapshots
+ test_image_path = os.path.join(base_folder, "data", "Images", "test-snapshot-image.jpg")
+ snapshot_directory = os.path.join(
+ temp_directory, "{}".format(uuid.uuid4()), "{}".format(uuid.uuid4())
+ )
+ if not os.path.exists(snapshot_directory):
+ try:
+ os.makedirs(snapshot_directory)
+ except OSError as e:
+ if e.errno == errno.EEXIST:
+ pass
+ else:
+ raise
+
+ for snapshot_number in range(10):
+ snapshot_file_name = utility.get_snapshot_filename(
+ 'test', snapshot_number
+ )
+ target_snapshot_path = os.path.join(snapshot_directory, snapshot_file_name)
+ shutil.copy(test_image_path, target_snapshot_path)
+
+ snapshot_filename_format = os.path.basename(
+ utility.get_snapshot_filename(
+ "render_script_test", utility.SnapshotNumberFormat
+ )
+ )
+ script_path = camera_profile.on_before_render_script.strip()
+
+ if script_path is None or len(script_path) == 0:
+ return False, "No script path was provided. Please enter a script path and try again."
+
+ if not os.path.isfile(script_path):
+ return False, "The script path '{0}' does not exist. Please enter a valid script path and try again.".format(script_path)
+
+ console_output = ""
+ error_message = ""
+
+ cmd = script.CameraScriptBeforeRender(
+ script_path,
+ camera_profile.name,
+ snapshot_directory,
+ snapshot_filename_format,
+ os.path.join(snapshot_directory, snapshot_filename_format),
+ timeout_seconds=timeout_seconds
+ )
+ cmd.run()
+ return cmd.success(), cmd.error_message
+ finally:
+ utility.rmtree(temp_directory)
+
+ @staticmethod
+ def _test_after_render_script(camera_profile, script_type, base_folder):
+
+ # create a temp folder for the rendered file and the snapshots
+ temp_directory = mkdtemp()
+ timeout_seconds = camera_profile.timeout_ms / 1000.0
+ try:
+ # create 10 snapshots
+ test_image_path = os.path.join(base_folder, "data", "Images", "test-snapshot-image.jpg")
+ snapshot_directory = os.path.join(
+ temp_directory, "{}".format(uuid.uuid4()), "{}".format(uuid.uuid4())
+ )
+ if not os.path.exists(snapshot_directory):
+ try:
+ os.makedirs(snapshot_directory)
+ except OSError as e:
+ if e.errno == errno.EEXIST:
+ pass
+ else:
+ raise
+
+ for snapshot_number in range(10):
+ snapshot_file_name = utility.get_snapshot_filename(
+ 'test', snapshot_number
+ )
+ target_snapshot_path = os.path.join(snapshot_directory, snapshot_file_name)
+ shutil.copy(test_image_path, target_snapshot_path)
+
+ snapshot_filename_format = os.path.basename(
+ utility.get_snapshot_filename(
+ "render_script_test", utility.SnapshotNumberFormat
+ )
+ )
+
+
+ script_path = camera_profile.on_after_render_script.strip()
+ rendering_path = os.path.join(temp_directory, "timelapse", "test_rendering.mp4")
+
+ output_filepath = utility.get_collision_free_filepath(rendering_path)
+ output_filename = utility.get_filename_from_full_path(rendering_path)
+ output_directory = utility.get_directory_from_full_path(rendering_path)
+ output_extension = utility.get_extension_from_full_path(rendering_path)
+
+ if script_path is None or len(script_path) == 0:
+ return False, "No script path was provided. Please enter a script path and try again."
+
+ if not os.path.exists(script_path):
+ return False, "The script path '{0}' does not exist. Please enter a valid script path and try again.".format(
+ script_path)
+
+ cmd = script.CameraScriptAfterRender(
+ script_path,
+ camera_profile.name,
+ snapshot_directory,
+ snapshot_filename_format,
+ os.path.join(snapshot_directory, snapshot_filename_format),
+ output_directory,
+ output_filename,
+ output_extension,
+ rendering_path,
+ timeout_seconds=timeout_seconds
+ )
+ cmd.run()
+ return cmd.success(), cmd.error_message
+ finally:
+ utility.rmtree(temp_directory)
+
@staticmethod
def load_webcam_defaults(
server_type,
@@ -528,7 +946,8 @@ def load_webcam_defaults(
username,
password,
ignore_ssl_error,
- timeout_seconds=5):
+ retries=3,
+ backoff_factor=0.1):
if server_type == MjpgStreamer.server_type:
@@ -540,14 +959,16 @@ def load_webcam_defaults(
username,
password,
ignore_ssl_error,
- timeout_seconds=timeout_seconds
+ retries=retries,
+ backoff_factor=backoff_factor
)
if not success:
return False, errors
# set the control values to the defaults
controls = settings["webcam_settings"]["mjpg_streamer"]["controls"]
- for key, control in six.iteritems(controls):
-
+ # remove python 2 compatibility
+ # for key, control in six.iteritems(controls):
+ for key, control in controls.items():
control.value = control.default
controls[key] = control.to_dict()
@@ -559,8 +980,10 @@ def load_webcam_defaults(
password=password,
ignore_ssl_error=ignore_ssl_error,
camera_name=camera_name,
- timeout_seconds=timeout_seconds
+ retries=retries,
+ backoff_factor=backoff_factor
)
+ thread.daemon = True
thread.start()
thread.join()
@@ -581,7 +1004,8 @@ def __init__(
password=None,
ignore_ssl_error=False,
camera_name=None,
- timeout_seconds=4
+ retries=3,
+ backoff_factor=0.1
):
super(MjpgStreamerThread, self).__init__()
if profile:
@@ -589,18 +1013,19 @@ def __init__(
self.username = profile.webcam_settings.username
self.password = profile.webcam_settings.password
self.ignore_ssl_error = profile.webcam_settings.ignore_ssl_error
- self.timeout_seconds = timeout_seconds
self.camera_name = profile.name
else:
self.address = address
self.username = username
self.password = password
self.ignore_ssl_error = ignore_ssl_error
- self.timeout_seconds = timeout_seconds
self.camera_name = camera_name
- if isinstance(self.address, six.string_types):
+ # remove python 2 compatibility
+ # if isinstance(self.address, six.string_types):
+ if isinstance(self.address, str):
self.address = self.address.strip()
-
+ self.retries = retries
+ self.backoff_factor=backoff_factor
self.errors = []
self.success = False
@@ -614,7 +1039,8 @@ def __init__(
password=None,
ignore_ssl_error=False,
camera_name=None,
- timeout_seconds=4
+ retries=3,
+ backoff_factor=0.1
):
super(MjpgStreamerControlThread, self).__init__(
profile=profile,
@@ -623,7 +1049,8 @@ def __init__(
password=password,
ignore_ssl_error=ignore_ssl_error,
camera_name=camera_name,
- timeout_seconds=timeout_seconds
+ retries=retries,
+ backoff_factor=backoff_factor
)
self.controls = None
@@ -631,7 +1058,7 @@ def run(self):
try:
self.controls = self.get_controls_from_server()
except CameraError as e:
- logger.exception(e)
+ logger.exception("An unexpected error occurred while running the MjpgStreamerControlThread.")
self.errors.append(e)
def get_controls_from_server(self):
@@ -648,32 +1075,22 @@ def get_controls_from_server(self):
return control_settings
def get_file(self, file_name):
+
+ url = "{camera_address}{file_name}".format(camera_address=self.address, file_name=file_name)
try:
- url = "{camera_address}{file_name}".format(camera_address=self.address, file_name=file_name)
+ s = requests.Session()
+ s.verify = not self.ignore_ssl_error
if len(self.username) > 0:
- r = requests.get(url, auth=HTTPBasicAuth(self.username, self.password),
- verify=not self.ignore_ssl_error, timeout=float(self.timeout_seconds))
- else:
- r = requests.get(url, verify=not self.ignore_ssl_error,
- timeout=float(self.timeout_seconds))
-
- if r.status_code == 501:
- message = "Access was denied to the mjpg-streamer {0} file for the " \
- "'{1}' camera profile.".format(file_name, self.camera_name)
- raise CameraError("mjpg_streamer-control-error", message)
- if r.status_code != requests.codes.ok:
- message = (
- "Recived a status code of ({0}) while retrieving the {1} file from the {2} camera profile. Double "
- "check your 'Base Address' and 'Snapshot Address Template' within your camera profile settings."
- .format(r.status_code, file_name, self.camera_name)
- )
- raise CameraError('webcam_settings_apply_error', message)
- try:
- data = json.loads(r.text)
- except ValueError as e:
- raise CameraError('json_error', "Unable to read the input.json file from mjpg-streamer. Please chack "
- "your base address and try again.", cause=e)
- return data
+ s.auth = (self.username, self.password)
+
+ r = utility.requests_retry_session(session=s, retries=self.retries,
+ backoff_factor=self.backoff_factor).request("GET", url)
+ #if len(self.username) > 0:
+ # r = requests.get(url, auth=HTTPBasicAuth(self.username, self.password),
+ # verify=not self.ignore_ssl_error, timeout=float(self.timeout_seconds))
+ #else:
+ # r = requests.get(url, verify=not self.ignore_ssl_error,
+ # timeout=float(self.timeout_seconds))
except SSLError as e:
message = (
"An SSL error was raised while retrieving the {0} file for the '{1}' camera profile."
@@ -683,15 +1100,36 @@ def get_file(self, file_name):
except requests.ConnectionError as e:
message = (
"Unable to connect to the camera to '{0}' to retrieve controls for the {1} camera profile."
- .format(url, self.camera_name)
+ .format(url, self.camera_name)
)
- raise CameraError("connection-error", message, cause=e)
+ raise CameraError("connection_error", message, cause=e)
except Exception as e:
message = (
"An unexpected error was raised while retrieving the {0} file for the '{1}' camera profile."
- .format(file_name, self.camera_name)
+ .format(file_name, self.camera_name)
+ )
+ raise CameraError('unexpected_error', message, cause=e)
+
+ if r.status_code == 501:
+ message = "Access was denied to the mjpg-streamer {0} file for the " \
+ "'{1}' camera profile.".format(file_name, self.camera_name)
+ raise CameraError("access_denied", message)
+ if r.status_code == 404:
+ message = "Unable to find the camera at the supplied base address for the " \
+ "'{0}' camera profile.".format(self.camera_name)
+ raise CameraError("file_not_found", message)
+ if r.status_code != requests.codes.ok:
+ message = (
+ "Recived a status code of ({0}) while retrieving the {1} file from the {2} camera profile."
+ .format(r.status_code, file_name, self.camera_name)
)
- raise CameraError('unexpected-error', message, cause=e)
+ raise CameraError('unexpected_status_code', message)
+ try:
+ data = json.loads(r.text, strict=False)
+ except ValueError as e:
+ raise CameraError('json_error', "Unable to read the input.json file from mjpg-streamer. Please chack "
+ "your base address and try again.", cause=e)
+ return data
class MjpgStreamerSettingsThread(MjpgStreamerThread):
@@ -704,7 +1142,8 @@ def __init__(
password=None,
ignore_ssl_error=False,
camera_name=None,
- timeout_seconds=4
+ retries=3,
+ backoff_factor=0.1
):
super(MjpgStreamerSettingsThread, self).__init__(
profile=profile,
@@ -713,7 +1152,8 @@ def __init__(
password=password,
ignore_ssl_error=ignore_ssl_error,
camera_name=camera_name,
- timeout_seconds=timeout_seconds
+ retries=retries,
+ backoff_factor=backoff_factor
)
if profile:
mjpg_streamer = profile.webcam_settings.mjpg_streamer.clone()
@@ -734,8 +1174,9 @@ def apply_mjpg_streamer_settings(self):
controls_list = []
-
- for key, control in six.iteritems(self.controls):
+ # remove python 2 compatibility
+ # for key, control in six.iteritems(self.controls):
+ for key, control in self.controls.items():
controls_list.append(control)
# Sort the controls. They need to be sent in order.
@@ -754,8 +1195,10 @@ def apply_mjpg_streamer_settings(self):
self.password,
self.ignore_ssl_error,
self.camera_name,
- timeout_seconds=self.timeout_seconds
+ retries=self.retries,
+ backoff_factor=self.backoff_factor
)
+ thread.daemon = True
threads.append(thread)
if len(threads) > 0:
@@ -770,6 +1213,7 @@ def apply_mjpg_streamer_settings(self):
if not self.success:
for error in errors:
self.errors.append(error)
+ break # Do not continue applying settings if one fails.
self.success = len(self.errors) == 0
@@ -782,7 +1226,8 @@ def __init__(
password,
ignore_ssl_error,
camera_name,
- timeout_seconds=4
+ retries=3,
+ backoff_factor=0.1
):
super(MjpgStreamerSettingThread, self).__init__(
address=address,
@@ -790,7 +1235,8 @@ def __init__(
password=password,
ignore_ssl_error=ignore_ssl_error,
camera_name=camera_name,
- timeout_seconds=timeout_seconds
+ retries=retries,
+ backoff_factor=backoff_factor
)
self.control = control
@@ -830,14 +1276,22 @@ def apply_mjpg_streamer_setting(self):
address=self.address,
querystring=querystring
)
- logger.debug("Changing %s to %s for %s. Querystring: %s", name, str(value), self.camera_name, querystring)
+ logger.debug("Changing %s to %s for %s. Request: %s", name, str(value), self.camera_name, url)
try:
+ # old version without session
+ #if len(self.username) > 0:
+ # r = requests.get(url, auth=HTTPBasicAuth(self.username, self.password),
+ # verify=not self.ignore_ssl_error, timeout=float(self.timeout_seconds))
+ #else:
+ # r = requests.get(url, verify=not self.ignore_ssl_error,
+ # timeout=float(self.timeout_seconds))
+
+ s = requests.Session()
+ s.verify = not self.ignore_ssl_error
if len(self.username) > 0:
- r = requests.get(url, auth=HTTPBasicAuth(self.username, self.password),
- verify=not self.ignore_ssl_error, timeout=float(self.timeout_seconds))
- else:
- r = requests.get(url, verify=not self.ignore_ssl_error,
- timeout=float(self.timeout_seconds))
+ s.auth = (self.username, self.password)
+
+ r = utility.requests_retry_session(session=s, retries=self.retries, backoff_factor=self.backoff_factor).request("GET", url)
webcam_server_type = CameraControl.get_webcam_server_type_from_request(r)
if webcam_server_type != "mjpg-streamer":
@@ -849,17 +1303,16 @@ def apply_mjpg_streamer_setting(self):
if r.status_code == 501:
message = "Access was denied to the mjpg-streamer control.html while applying the {0} setting to the '{" \
"1}' camera profile. Please see this link to correct this " \
+ "href=\"https://github.com/FormerLurker/Octolapse/wiki/V0.4---Enabling-Camera-Controls\" target = \"_blank\">Please see this link to correct this " \
"error., disable the 'Enable And Apply Preferences at Startup' " \
"and 'Enable And Apply Preferences Before Print' options '.".format(name,
self.camera_name)
logger.error(message)
- raise CameraError("mjpg_streamer-control-error", message)
+ raise CameraError("mjpg_streamer_control_error", message)
if r.status_code != requests.codes.ok:
message = (
"Recived a status code of ({0}) while applying the {1} settings to the {2} camera profile. "
- "Double check your 'Base Address' and 'Snapshot Address Template' within your camera profile "
+ "Double check your 'Base Address' and 'Snapshot Address' within your camera profile "
"settings. Or disable 'Custom Image Preferences' for this profile and try again.".format(
r.status_code, name, self.camera_name
)
@@ -873,73 +1326,42 @@ def apply_mjpg_streamer_setting(self):
"An SSL error was raised while applying the {0} setting to the '{1}' camera profile."
.format(name, self.camera_name)
)
- logger.error(message)
+ logger.exception(message)
raise CameraError('ssl_error', message, cause=e)
except Exception as e:
message = (
"An unexpected error was raised while applying the {0} setting to the '{1}' camera profile."
.format(name, self.camera_name)
)
- logger.error(message)
+ logger.exception(message)
raise CameraError('webcam_settings_apply_error', message, cause=e)
class CameraSettingScriptThread(Thread):
- def __init__(self, camera):
+ def __init__(self, camera, script_path, script_args, script_type):
super(CameraSettingScriptThread, self).__init__()
self.Camera = camera
self.camera_name = camera.name
- self.errors = []
+ self.script_path = script_path.strip()
+ self.script_type = script_type
+ self.script_args = script_args
+ self.console_output = ""
+ self.error = None
def run(self):
- try:
- script = self.Camera.on_print_start_script.strip()
- logger.info(
- "Executing the 'Before Print Starts' script at %s for the '%s' camera.", script, self.camera_name
+ if self.script_type == "before-print":
+ cmd = script.CameraScriptBeforePrint(
+ self.script_path,
+ self.camera_name
)
- if not script:
- message = "The Camera Initialization script is empty"
- logger.error(message)
- raise CameraError('no_camera_script_path', message)
- try:
- script_args = [
- script,
- self.Camera.name
- ]
- cmd = utility.POpenWithTimeout()
- return_code = cmd.run(script_args, None)
- console_output = cmd.stdout
- error_message = cmd.stderr
- except utility.POpenWithTimeout.ProcessError as e:
- message = "An OS Error error occurred while executing the custom camera initialization script"
- logger.exception(message)
- raise CameraError('camera_initialization_error', message, cause=e)
-
- if error_message is not None:
- if error_message.endswith("\r\n"):
- error_message = error_message[:-2]
- if not return_code == 0:
- if error_message is not None:
- error_message = "The custom camera initialization script failed with the following error message: {0}" \
- .format(error_message)
- else:
- error_message = (
- "The custom camera initialization script returned {0},"
- " which indicates an error.".format(return_code)
- )
- logger.exception(error_message)
- elif error_message is not None:
- logger.warn(
- "The console returned an error while running the script at %s for the '%s' camera. Details:%s",
- script, self.camera_name, error_message
- )
- else:
- logger.info(
- "The 'Before Print Starts' script for the %s camera completed successfully.", self.camera_name
- )
- raise CameraError('camera_initialization_error', error_message)
- except CameraError as e:
- self.errors.append(e)
+ else:
+ cmd = script.CameraScriptAfterPrint(
+ self.script_path,
+ self.camera_name
+ )
+ cmd.run()
+ if not cmd.success():
+ self.error = CameraError('error_message_returned', cmd.error_message)
class CameraError(Exception):
diff --git a/octoprint_octolapse/data/Images/no-camera-selected.png b/octoprint_octolapse/data/Images/no-camera-selected.png
index a47f15d5..a25823f1 100644
Binary files a/octoprint_octolapse/data/Images/no-camera-selected.png and b/octoprint_octolapse/data/Images/no-camera-selected.png differ
diff --git a/octoprint_octolapse/data/Images/no-image-available.png b/octoprint_octolapse/data/Images/no-image-available.png
index a47f15d5..be147974 100644
Binary files a/octoprint_octolapse/data/Images/no-image-available.png and b/octoprint_octolapse/data/Images/no-image-available.png differ
diff --git a/octoprint_octolapse/data/Images/no_snapshot.png b/octoprint_octolapse/data/Images/no_snapshot.png
index 53a72bd8..32234ed1 100644
Binary files a/octoprint_octolapse/data/Images/no_snapshot.png and b/octoprint_octolapse/data/Images/no_snapshot.png differ
diff --git a/octoprint_octolapse/data/Images/test-snapshot-image.jpg b/octoprint_octolapse/data/Images/test-snapshot-image.jpg
new file mode 100644
index 00000000..d65f2e91
Binary files /dev/null and b/octoprint_octolapse/data/Images/test-snapshot-image.jpg differ
diff --git a/octoprint_octolapse/data/lib/c/extruder.cpp b/octoprint_octolapse/data/lib/c/extruder.cpp
new file mode 100644
index 00000000..9f1a6e5d
--- /dev/null
+++ b/octoprint_octolapse/data/lib/c/extruder.cpp
@@ -0,0 +1,160 @@
+#include "extruder.h"
+#include "logging.h"
+
+extruder::extruder()
+{
+ x_firmware_offset = 0;
+ y_firmware_offset = 0;
+ z_firmware_offset = 0;
+ e = 0;
+ e_offset = 0;
+ e_relative = 0;
+ extrusion_length = 0;
+ extrusion_length_total = 0;
+ retraction_length = 0;
+ deretraction_length = 0;
+ is_extruding_start = false;
+ is_extruding = false;
+ is_primed = false;
+ is_retracting_start = false;
+ is_retracting = false;
+ is_retracted = false;
+ is_partially_retracted = false;
+ is_deretracting_start = false;
+ is_deretracting = false;
+ is_deretracted = false;
+}
+
+double extruder::get_offset_e() const
+{
+ return e - e_offset;
+}
+
+PyObject* extruder::to_py_tuple() const
+{
+ //std::cout << "Building extruder py_tuple.\r\n";
+ PyObject* py_extruder = Py_BuildValue(
+ // ReSharper disable once StringLiteralTypo
+ "ddddddddddllllllllll",
+ // Floats
+ x_firmware_offset, // 0
+ y_firmware_offset, // 1
+ z_firmware_offset, // 2
+ e, // 3
+ e_offset, // 4
+ e_relative, // 5
+ extrusion_length, // 6
+ extrusion_length_total, // 7
+ retraction_length, // 8
+ deretraction_length, // 9
+ // Bool (represented as an integer)
+ (long int)(is_extruding_start ? 1 : 0), // 10
+ (long int)(is_extruding ? 1 : 0), // 11
+ (long int)(is_primed ? 1 : 0), // 12
+ (long int)(is_retracting_start ? 1 : 0), // 13
+ (long int)(is_retracting ? 1 : 0), // 14
+ (long int)(is_retracted ? 1 : 0), // 15
+ (long int)(is_partially_retracted ? 1 : 0), // 16
+ (long int)(is_deretracting_start ? 1 : 0), // 17
+ (long int)(is_deretracting ? 1 : 0), // 18
+ (long int)(is_deretracted ? 1 : 0) // 19
+ );
+ if (py_extruder == NULL)
+ {
+ std::string message =
+ "extruder.to_py_tuple: Unable to convert extruder value to a PyObject tuple via Py_BuildValue.";
+ octolapse_log_exception(octolapse_log::GCODE_POSITION, message);
+ return NULL;
+ }
+ return py_extruder;
+}
+
+PyObject* extruder::to_py_dict() const
+{
+ PyObject* p_extruder = Py_BuildValue(
+ "{s:d,s:d,s:d,s:d,s:d,s:d,s:d,s:d,s:d,s:d,s:i,s:i,s:i,s:i,s:i,s:i,s:i,s:i,s:i,s:i}",
+ // FLOATS
+ "x_firmware_offset",
+ x_firmware_offset,
+ "y_firmware_offset",
+ y_firmware_offset,
+ "z_firmware_offset",
+ z_firmware_offset,
+ "e",
+ e,
+ "e_offset",
+ e_offset,
+ "e_relative",
+ e_relative,
+ "extrusion_length",
+ extrusion_length,
+ "extrusion_length_total",
+ extrusion_length_total,
+ "retraction_length",
+ retraction_length,
+ "deretraction_length",
+ deretraction_length,
+ // Bool (represented as an integer)
+ "is_extruding_start",
+ (long int)(is_extruding_start ? 1 : 0),
+ "is_extruding",
+ (long int)(is_extruding ? 1 : 0),
+ "is_primed",
+ (long int)(is_primed ? 1 : 0),
+ "is_retracting_start",
+ (long int)(is_retracting_start ? 1 : 0),
+ "is_retracting",
+ (long int)(is_retracting ? 1 : 0),
+ "is_retracted",
+ (long int)(is_retracted ? 1 : 0),
+ "is_partially_retracted",
+ (long int)(is_partially_retracted ? 1 : 0),
+ "is_deretracting_start",
+ (long int)(is_deretracting_start ? 1 : 0),
+ "is_deretracting",
+ (long int)(is_deretracting ? 1 : 0),
+ "is_deretracted",
+ (long int)(is_deretracted ? 1 : 0)
+ );
+ if (p_extruder == NULL)
+ {
+ std::string message = "extruder.to_py_dict: Unable to convert extruder value to a dict PyObject via Py_BuildValue.";
+ octolapse_log_exception(octolapse_log::GCODE_POSITION, message);
+ return NULL;
+ }
+ return p_extruder;
+}
+
+PyObject* extruder::build_py_object(extruder* p_extruders, const unsigned int num_extruders)
+{
+ //std::cout << "Building extruders py_object.\r\n";
+ PyObject* py_extruders = PyList_New(0);
+ if (py_extruders == NULL)
+ {
+ std::string message = "Error executing Extruder.build_py_object: Unable to create Extruder PyList object.";
+ octolapse_log_exception(octolapse_log::GCODE_POSITION, message);
+ return NULL;
+ }
+
+ // Create each snapshot plan
+ for (unsigned int index = 0; index < num_extruders; index++)
+ {
+ PyObject* py_extruder = p_extruders[index].to_py_tuple();
+ if (py_extruder == NULL)
+ {
+ return NULL;
+ }
+ bool success = !(PyList_Append(py_extruders, py_extruder) < 0); // reference to pSnapshotPlan stolen
+ if (!success)
+ {
+ std::string message =
+ "Error executing Extruder.build_py_object Unable to append the extruder to the extruders list.";
+ octolapse_log_exception(octolapse_log::GCODE_POSITION, message);
+ return NULL;
+ }
+ // Need to decref after PyList_Append, since it increfs the PyObject
+ Py_DECREF(py_extruder);
+ }
+
+ return py_extruders;
+}
diff --git a/octoprint_octolapse/data/lib/c/extruder.h b/octoprint_octolapse/data/lib/c/extruder.h
new file mode 100644
index 00000000..74537376
--- /dev/null
+++ b/octoprint_octolapse/data/lib/c/extruder.h
@@ -0,0 +1,36 @@
+#pragma once
+#ifdef _DEBUG
+//#undef _DEBUG
+#include ' + error_popup.original_title + '
'+ error_title_text +'
';
+ }
+ else
+ {
+ title_html =
+ '' + error_popup.original_title +
+ ' - ' + (error_popup.current_error_index+1).toString() + ' of ' + error_popup.errors.length +
+ '
'+ error_title_text +'
';
+ }
+ error_popup.$error_title.html(title_html);
+ };
+ error_popup.show_current_error = function()
+ {
+ var current_error = error_popup.current_error();
+ error_popup.$error_text.text(current_error.description);
+ error_popup.set_title();
+ // enable/disable buttons
+ // if we only have 1 error we don't want any previous/next buttons
+ if(error_popup.errors.length > 1)
+ {
+ // disable buttons as necessary
+ error_popup.$previous_button.prop('disabled', error_popup.current_error_index == 0);
+ error_popup.$next_button.prop('disabled', error_popup.current_error_index + 1 == self.errors.length);
+ }
+ };
+ error_popup.display_help_for_current_error = function()
+ {
+ var current_error = error_popup.current_error();
+ showHelpForLink(
+ current_error.help_link,
+ current_error.name,
+ "No help could be found for this error.");
+ };
+
+ error_popup.next_error = function()
+ {
+ error_popup.current_error_index = (error_popup.errors.length+error_popup.current_error_index+1) % error_popup.errors.length;
+ error_popup.show_current_error();
+ };
+
+ error_popup.previous_error = function()
+ {
+ error_popup.current_error_index = (error_popup.errors.length+error_popup.current_error_index-1) % error_popup.errors.length;
+ error_popup.show_current_error();
+ };
+
+ Octolapse.closeConfirmDialogsForKeys([remove_keys]);
+ // Make sure that the default pnotify buttons exist
+ Octolapse.checkPNotifyDefaultConfirmButtons();
+ Octolapse.ConfirmDialogs[popup_key] = (
+ new PNotify({
+ title: options.title,
+ text: options.text,
+ icon: options.icon,
+ hide: options.hide,
+ desktop: options.desktop,
+ type: options.type,
+ addclass: options.addclass,
+ confirm: {
+ confirm: true,
+ buttons: [{
+ text: 'Ok',
+ addClass: 'remove_button',
+ },{
+ text: 'Cancel',
+ addClass: 'remove_button',
+ },{
+ // Previous Error
+ text: '',
+ addClass: 'error_previous',
+ click: function(){
+ //console.log("Showing the previous error");
+ error_popup.previous_error();
+ }
+ },{
+ text: 'Help',
+ addClass: 'error_help',
+ click: function() {
+ var current_error = error_popup.current_error();
+ Octolapse.Help.showHelpForLink(
+ current_error.help_link,
+ current_error.name,
+ "No help could be found for this error.");
+ }
+ },{
+ // Next Error
+ text: '',
+ addClass: 'error_next',
+ click: function(){
+ //console.log("Showing the next error");
+ error_popup.next_error();
+ }
+ },{
+ text: 'Close',
+ addClass: 'error_close',
+ click: function(){
+ //console.log("Closing popup with key: " + popup_key.toString());
+ Octolapse.closeConfirmDialogsForKeys([popup_key]);
+ }
+ }]
+ },
+ buttons: {
+ closer: false,
+ sticker: false
+ },
+ history: {
+ history: false
+ },
+ before_open: function(notice) {
+ notice.get().find(".remove_button").remove();
+ error_popup.configure_notice(notice);
+ error_popup.show_current_error();
+ }
+ })
+ );
+ };
+ };
});
diff --git a/octoprint_octolapse/static/js/octolapse.helpers.js b/octoprint_octolapse/static/js/octolapse.helpers.js
new file mode 100644
index 00000000..0a13da23
--- /dev/null
+++ b/octoprint_octolapse/static/js/octolapse.helpers.js
@@ -0,0 +1,532 @@
+/*
+##################################################################################
+# Octolapse - A plugin for OctoPrint used for making stabilized timelapse videos.
+# Copyright (C) 2023 Brad Hochgesang
+##################################################################################
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published
+# by the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see the following:
+# https://github.com/FormerLurker/Octolapse/blob/master/LICENSE
+#
+# You can contact the author either through the git-hub repository, or at the
+# following email address: FormerLurker@pm.me
+##################################################################################
+*/
+$(function () {
+ Octolapse.ListViewColumn = function(text, id, options){
+ var self = this;
+ self.text = text;
+ self.id = id;
+ self.sortable = (options || {}).sortable || false;
+ self.sort_column_id = (options || {}).sort_column_id || id;
+ self.classes = (options || {}).class || "";
+ self.template_id = (options || {}).template_id || "octolapse-list-item";
+ self.visible_observable = (options || {}).visible_observable || true;
+ };
+
+ Octolapse.ListItemViewModel = function(parent, id, name, title, selected, value){
+ var self = this;
+ self.id = id;
+ self.name = name;
+ self.title = title;
+ self.value = value;
+ self.data = ko.observable();
+ self.data.parent = parent;
+ //self.selected = ko.observable(!!selected);
+ self.selected = ko.observable(!!selected);
+ self.disabled = ko.observable(false);
+ self.get_list_item_value = function(column){
+ var value = null;
+ if (self.value !== null && self.value[column.id] !== undefined)
+ value = self.value[column.id];
+ else if (self[column.id] !== undefined)
+ value = self[column.id];
+
+ return {
+ item: self,
+ column: column,
+ value: value
+ };
+ };
+ };
+
+ Octolapse.ListViewModel = function (parent, id, options){
+ var self = this;
+ self.data = ko.observable();
+ self.data.parent = parent;
+ self.list_id = id;
+ self.options = options ? options : {};
+ self.current_page_index = ko.observable(0);
+ self.all_selected = ko.observable(false);
+ self.page_selected = ko.observable(false);
+ self.page_size = ko.observable(10);
+ self.has_loaded = ko.observable(false);
+ // Must be at least 6 pages, can be more.
+ self.list_items = ko.observableArray([]);
+ self.page_sizes = [
+ {name: "10", value: 10},
+ {name: "25", value: 25},
+ {name: "50", value: 50},
+ {name: "100", value: 100},
+ {name: "250", value: 250},
+ {name: "500", value: 500}
+ ];
+ self.pager_values = ko.observableArray([]);
+ self.selection_enabled = false;
+ if (self.options.selection_enabled !== undefined)
+ {
+ self.selection_enabled = self.options.selection_enabled;
+ }
+
+ self.select_all_enabled = false;
+ if (self.options.select_all_enabled !== undefined)
+ {
+ self.select_all_enabled = self.options.select_all_enabled;
+ }
+ self.action_text = self.options.action_text || "Action";
+ self.on_resize = self.options.on_resize;
+ self.columns = self.options.columns || [new Octolapse.ListViewColumn('Name', 'name')];
+ self.action_class = self.options.action_class || 'list-item-action';
+ self.selection_class = self.options.selection_class || 'list-item-selection';
+ self.selection_header_class = self.options.selection_header_class || 'list-item-selection';
+ self.to_list_item = self.options.to_list_item || function(item) {
+ return new Octolapse.ListItemViewModel(self.data.parent, item.name, item.name, item.name, false, null);
+ };
+ self.no_items_template_id = self.options.no_items_template_id || "octolapse-list-no-items";
+ self.header_template_id = self.options.header_template_id || "octolapse-list-item-header";
+ self.list_item_template_id = self.options.list_item_template_id || "octolapse-list-item";
+ self.top_left_pagination_template_id = self.options.top_left_pagination_template_id || "octolapse-list-empty-template";
+ self.top_right_pagination_template_id = self.options.top_right_pagination_template_id || "octolapse-list-empty-template";
+ self.bottom_left_pagination_template_id = self.options.bottom_left_pagination_template_id || "octolapse-list-empty-template";
+ self.bottom_right_pagination_template_id = self.options.bottom_right_pagination_template_id || "octolapse-pagination-page-size";
+ self.select_header_template_id = self.options.select_header_template_id || "octolapse-list-select-header-checkbox-page-template";
+ self.custom_row_template_id = self.options.custom_row_template_id || null;
+ self.sort_column = ko.observable(self.options.sort_column || "name");
+ self.sort_direction_ascending = ko.observable((self.options.sort_direction || "ascending") === "ascending");
+ self.default_sort_direction_ascending = self.sort_direction_ascending();
+ self.not_loaded_template_id = options.not_loaded_template_id || "octolapse-list-not-loaded";
+ self.pagination_pages = self.options.pagination_pages || 11;
+ self.pagination_style = self.options.pagination_style || "normal";
+ self.pagination = self.options.pagination || "top-and-bottom";
+ self.pagination_top = self.pagination === 'top' || self.pagination === "top-and-bottom";
+ self.pagination_bottom = self.pagination === 'bottom' || self.pagination === "top-and-bottom";
+ self.pagination_row_auto_hide = self.options.pagination_row_auto_hide === undefined? true : self.options.pagination_row_auto_hide;
+
+ self.pagination_top_left_class = "span2";
+ self.pagination_top_center_class = "span8";
+ self.pagination_top_right_class = "span2";
+ self.pagination_bottom_left_class = "span2";
+ self.pagination_bottom_center_class = "span8";
+ self.pagination_bottom_right_class = "span2";
+
+ if (self.pagination_style === 'wide')
+ {
+ self.pagination_top_left_class = "span1";
+ self.pagination_top_center_class = "span10";
+ self.pagination_top_right_class = "span1";
+ self.pagination_bottom_left_class = "span1";
+ self.pagination_bottom_center_class = "span10";
+ self.pagination_bottom_right_class = "span1";
+ if (!self.options.pagination_pages)
+ self.pagination_pages = 13;
+ }
+ else if (self.pagination_style === "narrow")
+ {
+ self.pagination_top_left_class = "span3";
+ self.pagination_top_center_class = "span6";
+ self.pagination_top_right_class = "span3";
+ self.pagination_bottom_left_class = "span3";
+ self.pagination_bottom_center_class = "span6";
+ self.pagination_bottom_right_class = "span3";
+ if (!self.options.pagination_pages)
+ self.pagination_pages = 9;
+ }
+
+ self.show_pagination_row_top = ko.pureComputed(function(){
+ return self.pagination_top && (self.list_items().length > 0 || !self.pagination_row_auto_hide);
+ });
+
+ self.show_pagination_row_bottom = ko.pureComputed(function(){
+ return self.pagination_bottom && (self.list_items().length > 0 || !self.pagination_row_auto_hide);
+ });
+
+ self.resize = function(){
+ //console.log("octolapse.helpers.js - resize");
+ if (self.on_resize)
+ self.on_resize();
+ };
+ self.is_empty = ko.pureComputed(function(){
+ return self.list_items().length == 0;
+ });
+
+ self.all_selected.subscribe(function(value){
+ //console.log("octolapse.helpers.js - all selected");
+ self.select('all', value);
+ });
+
+ self.page_selected.subscribe(function(value){
+ //console.log("octolapse.helpers.js - page selected");
+ self.select('page', value);
+ });
+
+ self.page_size.octolapseSubscribeChanged(function (newValue, oldValue) {
+ //console.log("octolapse.helpers.js - page size changed");
+ var new_page = Math.floor(self.current_page_index()*oldValue/newValue);
+ self.current_page_index(new_page);
+ self.resize();
+ });
+
+ self.num_pages = ko.pureComputed(function(){
+ //console.log("octolapse.helpers.js - num pages changed");
+ if (self.list_items().length === 0)
+ return 0;
+ return Math.ceil(self.list_items().length / self.page_size());
+ });
+
+ self.pager = ko.pureComputed(function(){
+ //console.log("octolapse.helpers.js - pager changed");
+ if (self.pagination_pages < 6) {
+ console.error("The file browser pager must have at least 6 pages.");
+ return [];
+ }
+ // determine how many pager items will be in the array. Can be 0-self.num_pager_pages
+ var num_pages = self.num_pages();
+ if (num_pages < 1)
+ return [];
+ var pager_size = Math.min(self.pagination_pages, num_pages);
+ var midpoint = Math.floor((self.pagination_pages - 4)/2);
+ // If we have only one page, there is no need for a pager!
+
+ var pager_pages = new Array(pager_size);
+ // set the first and last page
+ pager_pages[0] = new Octolapse.PagerPageViewModel('link', 0);
+ pager_pages[pager_size-1] = new Octolapse.PagerPageViewModel('link', num_pages-1);
+ for (var index=1; index < pager_size-1; index++)
+ {
+ // There are four scenarios to handle:
+ if (num_pages <= self.pagination_pages)
+ {
+ // 1. There are up to pagination_pages pages. In that case just add the available indexes.
+ pager_pages[index] = new Octolapse.PagerPageViewModel('link', index);
+ }
+ else if (self.current_page_index() < self.pagination_pages - 3)
+ {
+ // 2. We are in the first pagination_pages - 2 page. In that case we want only one
+ // ellipsis right before the final page.
+ if (index < self.pagination_pages - 2)
+ {
+ pager_pages[index] = new Octolapse.PagerPageViewModel('link', index);
+ }
+ else
+ {
+ pager_pages[index] = new Octolapse.PagerPageViewModel('ellipsis');
+ }
+ }
+ else if (self.current_page_index() < num_pages - self.pagination_pages + 3)
+ {
+ // 3. We are in the last num_pages - (pagination_pages - 2) page. In that case we want only one
+ // ellipsis right after the first page.
+ if (index === 1 || index === self.pagination_pages - 2)
+ {
+ pager_pages[index] = new Octolapse.PagerPageViewModel('ellipsis');
+ }
+ else
+ {
+ pager_pages[index] = new Octolapse.PagerPageViewModel('link', self.current_page_index() - midpoint + index - 2 );
+ }
+ }
+ else
+ {
+ // 4. We are at the end of the list. In this case we want ellipsis after page 1 and before max_page
+ // ellipsis right after the first page.
+ if (index === 1)
+ {
+ pager_pages[index] = new Octolapse.PagerPageViewModel('ellipsis');
+ }
+ else
+ {
+
+ pager_pages[index] = new Octolapse.PagerPageViewModel('link', num_pages - (self.pagination_pages - index));
+ }
+ }
+ }
+ return pager_pages;
+ });
+
+ self.get_column = function(column_name){
+ for (var index=0; index < self.columns.length; index++)
+ {
+ var column = self.columns[index];
+ if (column.id === column_name)
+ return column;
+ }
+ };
+
+ self.sort_by_column = function(column){
+ if (!column.sortable)
+ return;
+
+ //console.log("octolapse.helpers.js - sorting by column:" + column.id);
+ var ascending = true;
+ if (column.id !== self.sort_column())
+ {
+ ascending = self.default_sort_direction_ascending;
+ }
+ else
+ {
+ ascending = !self.sort_direction_ascending();
+ }
+ self.sort_direction_ascending(ascending);
+ self.sort_column(column.id);
+ self.current_page_index(0);
+ };
+
+ self.get_current_page_indexes = function(){
+ //console.log("octolapse.helpers.js - current page indexes changed");
+ if (self.page_size() === 0 || self.list_items().length === 0)
+ return null;
+ var page_start_index = Math.max(self.current_page_index() * self.page_size(), 0);
+ var page_end_index = Math.min(page_start_index + self.page_size(), self.list_items().length);
+ return [page_start_index, page_end_index];
+ };
+
+ self.list_items_sorted = ko.pureComputed(function() {
+ //console.log("octolapse.helpers.js - list items sorting");
+ var current_sort_column = self.get_column(self.sort_column());
+ if (!current_sort_column)
+ return self.list_items();
+ var left_direction = self.sort_direction_ascending() ? -1 : 1;
+ var right_direction = left_direction * -1;
+ var sort_column_id = current_sort_column.sort_column_id;
+ return self.list_items().sort(
+ function (left, right) {
+ var leftItem = (left.value || {})[sort_column_id] || left[sort_column_id];
+ var rightItem = (right.value || {})[sort_column_id] || right[sort_column_id];
+ return leftItem === rightItem ? 0 : (leftItem < rightItem ? left_direction : right_direction);
+ });
+ });
+
+ self.current_page = ko.pureComputed(function(){
+ //console.log("octolapse.helpers.js - current page changing");
+ var page_indexes = self.get_current_page_indexes();
+ if (!page_indexes) {
+ return[];
+ }
+ return self.list_items_sorted().slice(page_indexes[0], page_indexes[1]);
+ });
+
+ //self.max_page = ko.pureComputed(function(){return Math.floor(self.list_items().length/self.page_size());});
+
+ self._fix_page_numbers = function() {
+ //console.log("octolapse.helpers.js - repairing page numbers");
+ // Ensure we are not off of any existing pages. This can happen during a reload, after a delete, or
+ // maybe some other scenarios.
+ // Make sure we haven't deleted ourselves off the the current page!
+ var cur_page = self.current_page_index();
+ var has_changed = false;
+ if (cur_page + 1 >= self.num_pages())
+ {
+ has_changed = true;
+ cur_page = self.num_pages() - 1;
+ }
+ if (cur_page < 0)
+ {
+ has_changed = true;
+ cur_page = 0;
+ }
+
+ if (has_changed)
+ self.current_page_index(cur_page);
+ };
+
+ self.set = function(items, on_added){
+ self.has_loaded(true);
+ var list_items = [];
+ for (var index = 0; index < items.length; index++)
+ {
+ var list_item = self.to_list_item(items[index]);
+ list_items.push(list_item);
+ if (on_added)
+ {
+ on_added(list_item);
+ }
+ }
+ self.list_items(list_items);
+ self._fix_page_numbers();
+ self.resize();
+ };
+
+ self.clear = function(){
+ self.list_items([]);
+ };
+
+ self.add = function(item){
+ self.has_loaded(true);
+ self.list_items.push(self.to_list_item(item));
+ };
+
+ self.get = function(id){
+ for (var index = 0; index < self.list_items().length; index++)
+ {
+ if (self.list_items()[index].id === id)
+ {
+ return self.list_items()[index];
+ }
+ }
+ return null;
+ };
+
+ self.get_index = function(id){
+ for (var index = 0; index < self.list_items().length; index++)
+ {
+ if (self.list_items()[index].id === id)
+ {
+ return index;
+ }
+ }
+ return -1;
+ };
+
+ self.remove = function(id){
+ var item = self.get(id);
+ if (item)
+ {
+ self.list_items.remove(item);
+ self._fix_page_numbers();
+ return item;
+ }
+ return null;
+ };
+
+ self.replace = function(item){
+ var list_item = self.to_list_item(item);
+ var index = self.get_index(list_item.id);
+ if (index > -1)
+ {
+ var old_item = self.list_items.splice(index, 1, list_item);
+ return old_item[0];
+ }
+ return null;
+ };
+
+ self.select = function(type, selection){
+ //console.log("octolapse.helpers.js - selecting " + type);
+ // force selection to true or false
+ selection = !!selection;
+ var page_indexes = null;
+ if (type === 'page')
+ {
+ page_indexes = self.get_current_page_indexes();
+ }
+ else if (type === 'all')
+ {
+ if (self.list_items().length > 0)
+ {
+ page_indexes = [0, self.list_items().length];
+ }
+ else {
+ return;
+ }
+ }
+
+ if (!page_indexes)
+ {
+ return;
+ }
+ var list_items_sorted = self.list_items_sorted();
+ for (var index=page_indexes[0]; index < page_indexes[1]; index++)
+ {
+ list_items_sorted[index].selected(selection);
+ }
+ };
+
+ self.goto_page = function(page_index){
+ var num_pages =self.num_pages();
+ if (page_index > num_pages - 1)
+ page_index = num_pages - 1;
+
+ if (page_index < 0)
+ page_index = 0;
+
+ self.current_page_index(page_index);
+ };
+
+ self.selected_count = ko.pureComputed(function(){
+ var selected_count = 0;
+ self.list_items().forEach(function(item, index){
+ if (item.selected()) {
+ selected_count++;
+ }
+ });
+ return selected_count;
+ });
+
+ self.selected = function(fields_to_return){
+ //console.log("octolapse.helpers.js - returning selected items.");
+ var selected_items = [];
+ for (var index=0; index < self.list_items().length; index++)
+ {
+ var item = self.list_items()[index];
+ if (item.selected()) {
+ if (fields_to_return)
+ {
+ var value = {};
+ fields_to_return.forEach(function(field, index){
+ var is_value_field = false;
+ var field_value = null;
+ if (item[field] === undefined)
+ {
+ is_value_field = true;
+ field_value = item.value[field];
+ }
+ else
+ {
+ field_value = item[field];
+ }
+ if (field_value && (ko.isObservable(field_value) || ko.isComputed(field_value)))
+ {
+ field_value = field_value();
+ }
+ if(is_value_field)
+ {
+ if (!value.value)
+ {
+ value.value = {};
+ }
+ value.value[field] = field_value;
+ }
+ else{
+ value[field] = field_value;
+ }
+
+ });
+ selected_items.push(value);
+ }
+ else
+ {
+ selected_items.push(item);
+ }
+ }
+ }
+ return selected_items;
+ };
+
+ };
+
+ Octolapse.PagerPageViewModel = function(type, index){
+ var self = this;
+ self.index = type === 'link'? index : -1;
+ self.name = type === 'link' ? (index+1).toString() : "";
+ self.type = type;
+ };
+
+});
diff --git a/octoprint_octolapse/static/js/octolapse.js b/octoprint_octolapse/static/js/octolapse.js
index e5472407..5f804680 100644
--- a/octoprint_octolapse/static/js/octolapse.js
+++ b/octoprint_octolapse/static/js/octolapse.js
@@ -1,7 +1,7 @@
/*
##################################################################################
# Octolapse - A plugin for OctoPrint used for making stabilized timelapse videos.
-# Copyright (C) 2017 Brad Hochgesang
+# Copyright (C) 2023 Brad Hochgesang
##################################################################################
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published
@@ -22,7 +22,11 @@
##################################################################################
*/
Octolapse = {};
-Octolapse.Printers = { 'current_profile_guid': function () {return null;}};
+Octolapse.Printers = {
+ 'current_profile_guid': function () {
+ return null;
+ }
+};
OctolapseViewModel = {};
//UI_API_KEY = "";
Octolapse.Help = null;
@@ -31,14 +35,12 @@ $(function () {
// Finds the first index of an array with the matching predicate
Octolapse.IsShowingSettingsChangedPopup = false;
- Octolapse.toggleContentFunction = function ($elm, options, updateObservable)
- {
+ Octolapse.toggleContentFunction = function ($elm, options, updateObservable) {
- if(options.toggle_observable){
+ if (options.toggle_observable) {
//console.log("Toggling element.");
- if(updateObservable) {
+ if (updateObservable) {
options.toggle_observable(!options.toggle_observable());
- //console.log("Observable updated - " + options.toggle_observable())
}
if (options.toggle_observable()) {
if (options.class_showing) {
@@ -49,25 +51,23 @@ $(function () {
$elm.children('[class^="icon-"]').removeClass(options.class_hiding);
$elm.children('[class^="fa"]').removeClass(options.class_hiding);
}
- if(options.container) {
+ if (options.container) {
if (options.parent) {
$elm.parents(options.parent).find(options.container).stop().slideDown('fast', options.onComplete);
} else {
$(options.container).stop().slideDown('fast', options.onComplete);
}
}
- }
- else
- {
- if (options.class_hiding) {
- $elm.children('[class^="icon-"]').addClass(options.class_hiding);
- $elm.children('[class^="fa"]').addClass(options.class_hiding);
- }
+ } else {
+ if (options.class_hiding) {
+ $elm.children('[class^="icon-"]').addClass(options.class_hiding);
+ $elm.children('[class^="fa"]').addClass(options.class_hiding);
+ }
if (options.class_showing) {
$elm.children('[class^="icon-"]').removeClass(options.class_showing);
$elm.children('[class^="fa"]').removeClass(options.class_showing);
}
- if(options.container) {
+ if (options.container) {
if (options.parent) {
$elm.parents(options.parent).find(options.container).stop().slideUp('fast', options.onComplete);
} else {
@@ -75,8 +75,7 @@ $(function () {
}
}
}
- }
- else {
+ } else {
if (options.class) {
$elm.children('[class^="icon-"]').toggleClass(options.class_hiding + ' ' + options.class_showing);
$elm.children('[class^="fa"]').toggleClass(options.class_hiding + ' ' + options.class_showing);
@@ -93,32 +92,32 @@ $(function () {
};
Octolapse.toggleContent = {
- init: function(element, valueAccessor) {
- var $elm = $(element),
- options = $.extend({
- class_showing: null,
- class_hiding: null,
- container: null,
- parent: null,
- toggle_observable: null,
- onComplete: function() {
- $(document).trigger("slideCompleted");
- }
- }, valueAccessor());
-
- if(options.toggle_observable) {
- Octolapse.toggleContentFunction($elm, options, false);
+ init: function (element, valueAccessor) {
+ var $elm = $(element),
+ options = $.extend({
+ class_showing: null,
+ class_hiding: null,
+ container: null,
+ parent: null,
+ toggle_observable: null,
+ onComplete: function () {
+ $(document).trigger("slideCompleted");
}
+ }, valueAccessor());
+ if (options.toggle_observable) {
+ Octolapse.toggleContentFunction($elm, options, false);
+ }
- $elm.on("click", function(e) {
- e.preventDefault();
- Octolapse.toggleContentFunction($elm,options, true);
- });
- }
- };
- ko.bindingHandlers.octolapseToggle = Octolapse.toggleContent ;
+ $elm.on("click", function (e) {
+ e.preventDefault();
+ Octolapse.toggleContentFunction($elm, options, true);
+
+ });
+ }
+ };
+ ko.bindingHandlers.octolapseToggle = Octolapse.toggleContent;
Octolapse.arrayFirstIndexOf = function (array, predicate, predicateOwner) {
for (var i = 0, j = array.length; i < j; i++) {
@@ -135,6 +134,7 @@ $(function () {
.toString(16)
.substring(1);
}
+
return s4() + s4() + '-' + s4() + '-' + s4() + '-' +
s4() + '-' + s4() + s4() + s4();
};
@@ -178,21 +178,22 @@ $(function () {
});
};
- Octolapse.progressBar = function (cancel_callback, initial_text)
- {
+ Octolapse.progressBar = function (cancel_callback, initial_text) {
var self = this;
self.notice = null;
self.$progress = null;
self.$progressText = null;
self.initial_text = initial_text;
- self.close = function()
- {
+ self.popup_margin = 15;
+ self.popup_width_with_margin = 400;
+ self.popup_width = self.popup_width_with_margin - self.popup_margin*2;
+
+ self.close = function () {
if (self.loader != null)
self.loader.remove();
};
- self.update = function(percent_complete, progress_text)
- {
+ self.update = function (percent_complete, progress_text) {
self.notice.find(".remove_button").remove();
if (self.$progress == null)
@@ -201,12 +202,13 @@ $(function () {
percent_complete = 0;
if (percent_complete > 100)
percent_complete = 100;
- if (percent_complete == 100) {
+ if (percent_complete === 100) {
//console.log("Received 100% complete progress message, removing progress bar.");
self.loader.remove();
- return null
+ return null;
}
- self.$progress.width(percent_complete + "%").attr("aria-valuenow", percent_complete).find("span").html(percent_complete + "%");
+ var percent_complete_text = percent_complete.toFixed(1);
+ self.$progress.width(percent_complete_text + "%").attr("aria-valuenow", percent_complete_text).find("span").html(percent_complete_text + "%");
self.$progressText.text(progress_text);
return self;
};
@@ -218,12 +220,13 @@ $(function () {
\
(GFM Style)',
+ type: 'boolean'
+ },
+ requireSpaceBeforeHeadingText: {
+ defaultValue: false,
+ description: 'Makes adding a space between `#` and the header text mandatory (GFM Style)',
+ type: 'boolean'
+ },
+ ghMentions: {
+ defaultValue: false,
+ description: 'Enables github @mentions',
+ type: 'boolean'
+ },
+ ghMentionsLink: {
+ defaultValue: 'https://github.com/{u}',
+ description: 'Changes the link generated by @mentions. Only applies if ghMentions option is enabled.',
+ type: 'string'
+ },
+ encodeEmails: {
+ defaultValue: true,
+ description: 'Encode e-mail addresses through the use of Character Entities, transforming ASCII e-mail addresses into its equivalent decimal entities',
+ type: 'boolean'
+ },
+ openLinksInNewWindow: {
+ defaultValue: false,
+ description: 'Open all links in new windows',
+ type: 'boolean'
+ },
+ backslashEscapesHTMLTags: {
+ defaultValue: false,
+ description: 'Support for HTML Tag escaping. ex: \
(GFM Style)",type:"boolean"},requireSpaceBeforeHeadingText:{defaultValue:!1,description:"Makes adding a space between `#` and the header text mandatory (GFM Style)",type:"boolean"},ghMentions:{defaultValue:!1,description:"Enables github @mentions",type:"boolean"},ghMentionsLink:{defaultValue:"https://github.com/{u}",description:"Changes the link generated by @mentions. Only applies if ghMentions option is enabled.",type:"string"},encodeEmails:{defaultValue:!0,description:"Encode e-mail addresses through the use of Character Entities, transforming ASCII e-mail addresses into its equivalent decimal entities",type:"boolean"},openLinksInNewWindow:{defaultValue:!1,description:"Open all links in new windows",type:"boolean"},backslashEscapesHTMLTags:{defaultValue:!1,description:"Support for HTML Tag escaping. ex:
',showdown:"S"},a.Converter=function(e){"use strict";function t(e,t){if(t=t||null,a.helper.isString(e)){if(e=a.helper.stdExtName(e),t=e,a.extensions[e])return console.warn("DEPRECATION WARNING: "+e+" is an old extension that uses a deprecated loading method.Please inform the developer that the extension should be updated!"),void function(e,t){"function"==typeof e&&(e=e(new a.Converter));a.helper.isArray(e)||(e=[e]);var n=r(e,t);if(!n.valid)throw Error(n.error);for(var s=0;s[^\r]+?<\/pre>)/gm,function(e,r){var t=r;return t=t.replace(/^ /gm,"¨0"),t=t.replace(/¨0/g,"")}),a.subParser("hashBlock")("","gim"),e=t.converter._dispatch("hashPreCodeTags.after",e,r,t)}),a.subParser("headers",function(e,r,t){"use strict";function n(e){var n,s;if(r.customizedHeaderId){var o=e.match(/\{([^{]+?)}\s*$/);o&&o[1]&&(e=o[1])}return n=e,s=a.helper.isString(r.prefixHeaderId)?r.prefixHeaderId:!0===r.prefixHeaderId?"section-":"",r.rawPrefixHeaderId||(n=s+n),n=r.ghCompatibleHeaderId?n.replace(/ /g,"-").replace(/&/g,"").replace(/¨T/g,"").replace(/¨D/g,"").replace(/[&+$,\/:;=?@"#{}|^¨~\[\]`\\*)(%.!'<>]/g,"").toLowerCase():r.rawHeaderId?n.replace(/ /g,"-").replace(/&/g,"&").replace(/¨T/g,"¨").replace(/¨D/g,"$").replace(/["']/g,"-").toLowerCase():n.replace(/[^\w]/g,"").toLowerCase(),r.rawPrefixHeaderId&&(n=s+n),t.hashLinkCounts[n]?n=n+"-"+t.hashLinkCounts[n]++:t.hashLinkCounts[n]=1,n}e=t.converter._dispatch("headers.before",e,r,t);var s=isNaN(parseInt(r.headerLevelStart))?1:parseInt(r.headerLevelStart),o=r.smoothLivePreview?/^(.+)[ \t]*\n={2,}[ \t]*\n+/gm:/^(.+)[ \t]*\n=+[ \t]*\n+/gm,i=r.smoothLivePreview?/^(.+)[ \t]*\n-{2,}[ \t]*\n+/gm:/^(.+)[ \t]*\n-+[ \t]*\n+/gm;e=(e=e.replace(o,function(e,o){var i=a.subParser("spanGamut")(o,r,t),l=r.noHeaderId?"":' id="'+n(o)+'"',c="\n"+e+"\n
",r,t)}),e=t.converter._dispatch("blockQuotes.after",e,r,t)}),a.subParser("codeBlocks",function(e,r,t){"use strict";e=t.converter._dispatch("codeBlocks.before",e,r,t);return e=(e+="¨0").replace(/(?:\n\n|^)((?:(?:[ ]{4}|\t).*\n+)+)(\n*[ ]{0,3}[^ \t\n]|(?=¨0))/g,function(e,n,s){var o=n,i=s,l="\n";return o=a.subParser("outdent")(o,r,t),o=a.subParser("encodeCode")(o,r,t),o=a.subParser("detab")(o,r,t),o=o.replace(/^\n+/g,""),o=o.replace(/\n+$/g,""),r.omitExtraWLInCodeBlocks&&(l=""),o="
",a.subParser("hashBlock")(o,r,t)+i}),e=e.replace(/¨0/,""),e=t.converter._dispatch("codeBlocks.after",e,r,t)}),a.subParser("codeSpans",function(e,r,t){"use strict";return void 0===(e=t.converter._dispatch("codeSpans.before",e,r,t))&&(e=""),e=e.replace(/(^|[^\\])(`+)([^\r]*?[^`])\2(?!`)/gm,function(e,n,s,o){var i=o;return i=i.replace(/^([ \t]*)/g,""),i=i.replace(/[ \t]*$/g,""),i=a.subParser("encodeCode")(i,r,t),i=n+""+o+l+""+i+"",i=a.subParser("hashHTMLSpans")(i,r,t)}),e=t.converter._dispatch("codeSpans.after",e,r,t)}),a.subParser("completeHTMLDocument",function(e,r,t){"use strict";if(!r.completeHTMLDocument)return e;e=t.converter._dispatch("completeHTMLDocument.before",e,r,t);var a="html",n="\n",s="",o='\n',i="",l="";void 0!==t.metadata.parsed.doctype&&(n="\n","html"!==(a=t.metadata.parsed.doctype.toString().toLowerCase())&&"html5"!==a||(o=''));for(var c in t.metadata.parsed)if(t.metadata.parsed.hasOwnProperty(c))switch(c.toLowerCase()){case"doctype":break;case"title":s="
",o=a.subParser("hashBlock")(o,r,t),"\n\n¨G"+(t.ghCodeBlocks.push({text:e,codeblock:o})-1)+"G\n\n"}),e=e.replace(/¨0/,""),t.converter._dispatch("githubCodeBlocks.after",e,r,t)):e}),a.subParser("hashBlock",function(e,r,t){"use strict";return e=t.converter._dispatch("hashBlock.before",e,r,t),e=e.replace(/(^\n+|\n+$)/g,""),e="\n\n¨K"+(t.gHtmlBlocks.push(e)-1)+"K\n\n",e=t.converter._dispatch("hashBlock.after",e,r,t)}),a.subParser("hashCodeTags",function(e,r,t){"use strict";e=t.converter._dispatch("hashCodeTags.before",e,r,t);return e=a.helper.replaceRecursiveRegExp(e,function(e,n,s,o){var i=s+a.subParser("encodeCode")(n,r,t)+o;return"¨C"+(t.gHtmlSpans.push(i)-1)+"C"},""+o+i+"]*>","","gim"),e=t.converter._dispatch("hashCodeTags.after",e,r,t)}),a.subParser("hashElement",function(e,r,t){"use strict";return function(e,r){var a=r;return a=a.replace(/\n\n/g,"\n"),a=a.replace(/^\n/,""),a=a.replace(/\n+$/g,""),a="\n\n¨K"+(t.gHtmlBlocks.push(a)-1)+"K\n\n"}}),a.subParser("hashHTMLBlocks",function(e,r,t){"use strict";e=t.converter._dispatch("hashHTMLBlocks.before",e,r,t);var n=["pre","div","h1","h2","h3","h4","h5","h6","blockquote","table","dl","ol","ul","script","noscript","form","fieldset","iframe","math","style","section","header","footer","nav","article","aside","address","audio","canvas","figure","hgroup","output","video","p"],s=function(e,r,a,n){var s=e;return-1!==a.search(/\bmarkdown\b/)&&(s=a+t.converter.makeHtml(r)+n),"\n\n¨K"+(t.gHtmlBlocks.push(s)-1)+"K\n\n"};r.backslashEscapesHTMLTags&&(e=e.replace(/\\<(\/?[^>]+?)>/g,function(e,r){return"<"+r+">"}));for(var o=0;o]*>","^ {0,3}\\s*
",r,t);return e=e.replace(/^ {0,2}( ?-){3,}[ \t]*$/gm,n),e=e.replace(/^ {0,2}( ?\*){3,}[ \t]*$/gm,n),e=e.replace(/^ {0,2}( ?_){3,}[ \t]*$/gm,n),e=t.converter._dispatch("horizontalRule.after",e,r,t)}),a.subParser("images",function(e,r,t){"use strict";function n(e,r,n,s,o,i,l,c){var u=t.gUrls,d=t.gTitles,p=t.gDimensions;if(n=n.toLowerCase(),c||(c=""),e.search(/\(\s*>? ?(['"].*['"])?\)$/m)>-1)s="";else if(""===s||null===s){if(""!==n&&null!==n||(n=r.toLowerCase().replace(/ ?\n/g," ")),s="#"+n,a.helper.isUndefined(u[n]))return e;s=u[n],a.helper.isUndefined(d[n])||(c=d[n]),a.helper.isUndefined(p[n])||(o=p[n].width,i=p[n].height)}r=r.replace(/"/g,""").replace(a.helper.regexes.asteriskDashAndColon,a.helper.escapeCharactersCallback);var h='"}return e=(e=t.converter._dispatch("images.before",e,r,t)).replace(/!\[([^\]]*?)] ?(?:\n *)?\[([\s\S]*?)]()()()()()/g,n),e=e.replace(/!\[([^\]]*?)][ \t]*()\([ \t]?(data:.+?\/.+?;base64,[A-Za-z0-9+/=\n]+?)>?(?: =([*\d]+[A-Za-z%]{0,4})x([*\d]+[A-Za-z%]{0,4}))?[ \t]*(?:(["'])([^"]*?)\6)?[ \t]?\)/g,function(e,r,t,a,s,o,i,l){return a=a.replace(/\s/g,""),n(e,r,t,a,s,o,0,l)}),e=e.replace(/!\[([^\]]*?)][ \t]*()\([ \t]?<([^>]*)>(?: =([*\d]+[A-Za-z%]{0,4})x([*\d]+[A-Za-z%]{0,4}))?[ \t]*(?:(?:(["'])([^"]*?)\6))?[ \t]?\)/g,n),e=e.replace(/!\[([^\]]*?)][ \t]*()\([ \t]?([\S]+?(?:\([\S]*?\)[\S]*?)?)>?(?: =([*\d]+[A-Za-z%]{0,4})x([*\d]+[A-Za-z%]{0,4}))?[ \t]*(?:(["'])([^"]*?)\6)?[ \t]?\)/g,n),e=e.replace(/!\[([^\[\]]+)]()()()()()/g,n),e=t.converter._dispatch("images.after",e,r,t)}),a.subParser("italicsAndBold",function(e,r,t){"use strict";function a(e,r,t){return r+e+t}return e=t.converter._dispatch("italicsAndBold.before",e,r,t),e=r.literalMidWordUnderscores?(e=(e=e.replace(/\b___(\S[\s\S]*?)___\b/g,function(e,r){return a(r,"","")})).replace(/\b__(\S[\s\S]*?)__\b/g,function(e,r){return a(r,"","")})).replace(/\b_(\S[\s\S]*?)_\b/g,function(e,r){return a(r,"","")}):(e=(e=e.replace(/___(\S[\s\S]*?)___/g,function(e,r){return/\S$/.test(r)?a(r,"",""):e})).replace(/__(\S[\s\S]*?)__/g,function(e,r){return/\S$/.test(r)?a(r,"",""):e})).replace(/_([^\s_][\s\S]*?)_/g,function(e,r){return/\S$/.test(r)?a(r,"",""):e}),e=r.literalMidWordAsterisks?(e=(e=e.replace(/([^*]|^)\B\*\*\*(\S[\s\S]*?)\*\*\*\B(?!\*)/g,function(e,r,t){return a(t,r+"","")})).replace(/([^*]|^)\B\*\*(\S[\s\S]*?)\*\*\B(?!\*)/g,function(e,r,t){return a(t,r+"","")})).replace(/([^*]|^)\B\*(\S[\s\S]*?)\*\B(?!\*)/g,function(e,r,t){return a(t,r+"","")}):(e=(e=e.replace(/\*\*\*(\S[\s\S]*?)\*\*\*/g,function(e,r){return/\S$/.test(r)?a(r,"",""):e})).replace(/\*\*(\S[\s\S]*?)\*\*/g,function(e,r){return/\S$/.test(r)?a(r,"",""):e})).replace(/\*([^\s*][\s\S]*?)\*/g,function(e,r){return/\S$/.test(r)?a(r,"",""):e}),e=t.converter._dispatch("italicsAndBold.after",e,r,t)}),a.subParser("lists",function(e,r,t){"use strict";function n(e,n){t.gListLevel++,e=e.replace(/\n{2,}$/,"\n");var s=/(\n)?(^ {0,3})([*+-]|\d+[.])[ \t]+((\[(x|X| )?])?[ \t]*[^\r]+?(\n{1,2}))(?=\n*(¨0| {0,3}([*+-]|\d+[.])[ \t]+))/gm,o=/\n[ \t]*\n(?!¨0)/.test(e+="¨0");return r.disableForced4SpacesIndentedSublists&&(s=/(\n)?(^ {0,3})([*+-]|\d+[.])[ \t]+((\[(x|X| )?])?[ \t]*[^\r]+?(\n{1,2}))(?=\n*(¨0|\2([*+-]|\d+[.])[ \t]+))/gm),e=e.replace(s,function(e,n,s,i,l,c,u){u=u&&""!==u.trim();var d=a.subParser("outdent")(l,r,t),p="";return c&&r.tasklists&&(p=' class="task-list-item" style="list-style-type: none;"',d=d.replace(/^[ \t]*\[(x|X| )?]/m,function(){var e='"})),d=d.replace(/^([-*+]|\d\.)[ \t]+[\S\n ]*/g,function(e){return"¨A"+e}),n||d.search(/\n{2,}/)>-1?(d=a.subParser("githubCodeBlocks")(d,r,t),d=a.subParser("blockGamut")(d,r,t)):(d=(d=a.subParser("lists")(d,r,t)).replace(/\n$/,""),d=(d=a.subParser("hashHTMLBlocks")(d,r,t)).replace(/\n\n+/g,"\n\n"),d=o?a.subParser("paragraphs")(d,r,t):a.subParser("spanGamut")(d,r,t)),d=d.replace("¨A",""),d="
]*>/.test(u)&&(d=!0)}s[i]=u}return e=s.join("\n"),e=e.replace(/^\n+/g,""),e=e.replace(/\n+$/g,""),t.converter._dispatch("paragraphs.after",e,r,t)}),a.subParser("runExtension",function(e,r,t,a){"use strict";if(e.filter)r=e.filter(r,a.converter,t);else if(e.regex){var n=e.regex;n instanceof RegExp||(n=new RegExp(n,"g")),r=r.replace(n,e.replace)}return r}),a.subParser("spanGamut",function(e,r,t){"use strict";return e=t.converter._dispatch("spanGamut.before",e,r,t),e=a.subParser("codeSpans")(e,r,t),e=a.subParser("escapeSpecialCharsWithinTagAttributes")(e,r,t),e=a.subParser("encodeBackslashEscapes")(e,r,t),e=a.subParser("images")(e,r,t),e=a.subParser("anchors")(e,r,t),e=a.subParser("autoLinks")(e,r,t),e=a.subParser("simplifiedAutoLinks")(e,r,t),e=a.subParser("emoji")(e,r,t),e=a.subParser("underline")(e,r,t),e=a.subParser("italicsAndBold")(e,r,t),e=a.subParser("strikethrough")(e,r,t),e=a.subParser("ellipsis")(e,r,t),e=a.subParser("hashHTMLSpans")(e,r,t),e=a.subParser("encodeAmpsAndAngles")(e,r,t),r.simpleLineBreaks?/\n\n¨K/.test(e)||(e=e.replace(/\n+/g,"
\n")):e=e.replace(/ +\n/g,"
\n"),e=t.converter._dispatch("spanGamut.after",e,r,t)}),a.subParser("strikethrough",function(e,r,t){"use strict";return r.strikethrough&&(e=(e=t.converter._dispatch("strikethrough.before",e,r,t)).replace(/(?:~){2}([\s\S]+?)(?:~){2}/g,function(e,n){return function(e){return r.simplifiedAutoLink&&(e=a.subParser("simplifiedAutoLinks")(e,r,t)),""+e+""}(n)}),e=t.converter._dispatch("strikethrough.after",e,r,t)),e}),a.subParser("stripLinkDefinitions",function(e,r,t){"use strict";var n=function(e,n,s,o,i,l,c){return n=n.toLowerCase(),s.match(/^data:.+?\/.+?;base64,/)?t.gUrls[n]=s.replace(/\s/g,""):t.gUrls[n]=a.subParser("encodeAmpsAndAngles")(s,r,t),l?l+c:(c&&(t.gTitles[n]=c.replace(/"|'/g,""")),r.parseImgDimensions&&o&&i&&(t.gDimensions[n]={width:o,height:i}),"")};return e=(e+="¨0").replace(/^ {0,3}\[(.+)]:[ \t]*\n?[ \t]*(data:.+?\/.+?;base64,[A-Za-z0-9+/=\n]+?)>?(?: =([*\d]+[A-Za-z%]{0,4})x([*\d]+[A-Za-z%]{0,4}))?[ \t]*\n?[ \t]*(?:(\n*)["|'(](.+?)["|')][ \t]*)?(?:\n\n|(?=¨0)|(?=\n\[))/gm,n),e=e.replace(/^ {0,3}\[(.+)]:[ \t]*\n?[ \t]*([^>\s]+)>?(?: =([*\d]+[A-Za-z%]{0,4})x([*\d]+[A-Za-z%]{0,4}))?[ \t]*\n?[ \t]*(?:(\n*)["|'(](.+?)["|')][ \t]*)?(?:\n+|(?=¨0))/gm,n),e=e.replace(/¨0/,"")}),a.subParser("tables",function(e,r,t){"use strict";function n(e){return/^:[ \t]*--*$/.test(e)?' style="text-align:left;"':/^--*[ \t]*:[ \t]*$/.test(e)?' style="text-align:right;"':/^:[ \t]*--*[ \t]*:$/.test(e)?' style="text-align:center;"':""}function s(e,n){var s="";return e=e.trim(),(r.tablesHeaderId||r.tableHeaderId)&&(s=' id="'+e.replace(/ /g,"_").toLowerCase()+'"'),e=a.subParser("spanGamut")(e,r,t),""+e+" \n"}function o(e,n){return""+a.subParser("spanGamut")(e,r,t)+" \n"}function i(e){var i,l=e.split("\n");for(i=0;i "+t.split("\n").join("\n> ")}),a.subParser("makeMarkdown.codeBlock",function(e,r){"use strict";var t=e.getAttribute("language"),a=e.getAttribute("precodenum");return"```"+t+"\n"+r.preList[a]+"\n```"}),a.subParser("makeMarkdown.codeSpan",function(e){"use strict";return"`"+e.innerHTML+"`"}),a.subParser("makeMarkdown.emphasis",function(e,r){"use strict";var t="";if(e.hasChildNodes()){t+="*";for(var n=e.childNodes,s=n.length,o=0;o",e.hasAttribute("width")&&e.hasAttribute("height")&&(r+=" ="+e.getAttribute("width")+"x"+e.getAttribute("height")),e.hasAttribute("title")&&(r+=' "'+e.getAttribute("title")+'"'),r+=")"),r}),a.subParser("makeMarkdown.links",function(e,r){"use strict";var t="";if(e.hasChildNodes()&&e.hasAttribute("href")){var n=e.childNodes,s=n.length;t="[";for(var o=0;o",e.hasAttribute("title")&&(t+=' "'+e.getAttribute("title")+'"'),t+=")"}return t}),a.subParser("makeMarkdown.list",function(e,r,t){"use strict";var n="";if(!e.hasChildNodes())return"";for(var s=e.childNodes,o=s.length,i=e.getAttribute("start")||1,l=0;ltr>th"),l=e.querySelectorAll("tbody>tr");for(t=0;t>t<>", "<", ">", "g")
+ * returns: ["t<", ""]
+ * matchRecursiveRegExp("
',
+ 'showdown': 'S'
+};
+
+/**
+ * Created by Estevao on 31-05-2015.
+ */
+
+/**
+ * Showdown Converter class
+ * @class
+ * @param {object} [converterOptions]
+ * @returns {Converter}
+ */
+showdown.Converter = function (converterOptions) {
+ 'use strict';
+
+ var
+ /**
+ * Options used by this converter
+ * @private
+ * @type {{}}
+ */
+ options = {},
+
+ /**
+ * Language extensions used by this converter
+ * @private
+ * @type {Array}
+ */
+ langExtensions = [],
+
+ /**
+ * Output modifiers extensions used by this converter
+ * @private
+ * @type {Array}
+ */
+ outputModifiers = [],
+
+ /**
+ * Event listeners
+ * @private
+ * @type {{}}
+ */
+ listeners = {},
+
+ /**
+ * The flavor set in this converter
+ */
+ setConvFlavor = setFlavor,
+
+ /**
+ * Metadata of the document
+ * @type {{parsed: {}, raw: string, format: string}}
+ */
+ metadata = {
+ parsed: {},
+ raw: '',
+ format: ''
+ };
+
+ _constructor();
+
+ /**
+ * Converter constructor
+ * @private
+ */
+ function _constructor () {
+ converterOptions = converterOptions || {};
+
+ for (var gOpt in globalOptions) {
+ if (globalOptions.hasOwnProperty(gOpt)) {
+ options[gOpt] = globalOptions[gOpt];
+ }
+ }
+
+ // Merge options
+ if (typeof converterOptions === 'object') {
+ for (var opt in converterOptions) {
+ if (converterOptions.hasOwnProperty(opt)) {
+ options[opt] = converterOptions[opt];
+ }
+ }
+ } else {
+ throw Error('Converter expects the passed parameter to be an object, but ' + typeof converterOptions +
+ ' was passed instead.');
+ }
+
+ if (options.extensions) {
+ showdown.helper.forEach(options.extensions, _parseExtension);
+ }
+ }
+
+ /**
+ * Parse extension
+ * @param {*} ext
+ * @param {string} [name='']
+ * @private
+ */
+ function _parseExtension (ext, name) {
+
+ name = name || null;
+ // If it's a string, the extension was previously loaded
+ if (showdown.helper.isString(ext)) {
+ ext = showdown.helper.stdExtName(ext);
+ name = ext;
+
+ // LEGACY_SUPPORT CODE
+ if (showdown.extensions[ext]) {
+ console.warn('DEPRECATION WARNING: ' + ext + ' is an old extension that uses a deprecated loading method.' +
+ 'Please inform the developer that the extension should be updated!');
+ legacyExtensionLoading(showdown.extensions[ext], ext);
+ return;
+ // END LEGACY SUPPORT CODE
+
+ } else if (!showdown.helper.isUndefined(extensions[ext])) {
+ ext = extensions[ext];
+
+ } else {
+ throw Error('Extension "' + ext + '" could not be loaded. It was either not found or is not a valid extension.');
+ }
+ }
+
+ if (typeof ext === 'function') {
+ ext = ext();
+ }
+
+ if (!showdown.helper.isArray(ext)) {
+ ext = [ext];
+ }
+
+ var validExt = validate(ext, name);
+ if (!validExt.valid) {
+ throw Error(validExt.error);
+ }
+
+ for (var i = 0; i < ext.length; ++i) {
+ switch (ext[i].type) {
+
+ case 'lang':
+ langExtensions.push(ext[i]);
+ break;
+
+ case 'output':
+ outputModifiers.push(ext[i]);
+ break;
+ }
+ if (ext[i].hasOwnProperty('listeners')) {
+ for (var ln in ext[i].listeners) {
+ if (ext[i].listeners.hasOwnProperty(ln)) {
+ listen(ln, ext[i].listeners[ln]);
+ }
+ }
+ }
+ }
+
+ }
+
+ /**
+ * LEGACY_SUPPORT
+ * @param {*} ext
+ * @param {string} name
+ */
+ function legacyExtensionLoading (ext, name) {
+ if (typeof ext === 'function') {
+ ext = ext(new showdown.Converter());
+ }
+ if (!showdown.helper.isArray(ext)) {
+ ext = [ext];
+ }
+ var valid = validate(ext, name);
+
+ if (!valid.valid) {
+ throw Error(valid.error);
+ }
+
+ for (var i = 0; i < ext.length; ++i) {
+ switch (ext[i].type) {
+ case 'lang':
+ langExtensions.push(ext[i]);
+ break;
+ case 'output':
+ outputModifiers.push(ext[i]);
+ break;
+ default:// should never reach here
+ throw Error('Extension loader error: Type unrecognized!!!');
+ }
+ }
+ }
+
+ /**
+ * Listen to an event
+ * @param {string} name
+ * @param {function} callback
+ */
+ function listen (name, callback) {
+ if (!showdown.helper.isString(name)) {
+ throw Error('Invalid argument in converter.listen() method: name must be a string, but ' + typeof name + ' given');
+ }
+
+ if (typeof callback !== 'function') {
+ throw Error('Invalid argument in converter.listen() method: callback must be a function, but ' + typeof callback + ' given');
+ }
+
+ if (!listeners.hasOwnProperty(name)) {
+ listeners[name] = [];
+ }
+ listeners[name].push(callback);
+ }
+
+ function rTrimInputText (text) {
+ var rsp = text.match(/^\s*/)[0].length,
+ rgx = new RegExp('^\\s{0,' + rsp + '}', 'gm');
+ return text.replace(rgx, '');
+ }
+
+ /**
+ * Dispatch an event
+ * @private
+ * @param {string} evtName Event name
+ * @param {string} text Text
+ * @param {{}} options Converter Options
+ * @param {{}} globals
+ * @returns {string}
+ */
+ this._dispatch = function dispatch (evtName, text, options, globals) {
+ if (listeners.hasOwnProperty(evtName)) {
+ for (var ei = 0; ei < listeners[evtName].length; ++ei) {
+ var nText = listeners[evtName][ei](evtName, text, this, options, globals);
+ if (nText && typeof nText !== 'undefined') {
+ text = nText;
+ }
+ }
+ }
+ return text;
+ };
+
+ /**
+ * Listen to an event
+ * @param {string} name
+ * @param {function} callback
+ * @returns {showdown.Converter}
+ */
+ this.listen = function (name, callback) {
+ listen(name, callback);
+ return this;
+ };
+
+ /**
+ * Converts a markdown string into HTML
+ * @param {string} text
+ * @returns {*}
+ */
+ this.makeHtml = function (text) {
+ //check if text is not falsy
+ if (!text) {
+ return text;
+ }
+
+ var globals = {
+ gHtmlBlocks: [],
+ gHtmlMdBlocks: [],
+ gHtmlSpans: [],
+ gUrls: {},
+ gTitles: {},
+ gDimensions: {},
+ gListLevel: 0,
+ hashLinkCounts: {},
+ langExtensions: langExtensions,
+ outputModifiers: outputModifiers,
+ converter: this,
+ ghCodeBlocks: [],
+ metadata: {
+ parsed: {},
+ raw: '',
+ format: ''
+ }
+ };
+
+ // This lets us use ¨ trema as an escape char to avoid md5 hashes
+ // The choice of character is arbitrary; anything that isn't
+ // magic in Markdown will work.
+ text = text.replace(/¨/g, '¨T');
+
+ // Replace $ with ¨D
+ // RegExp interprets $ as a special character
+ // when it's in a replacement string
+ text = text.replace(/\$/g, '¨D');
+
+ // Standardize line endings
+ text = text.replace(/\r\n/g, '\n'); // DOS to Unix
+ text = text.replace(/\r/g, '\n'); // Mac to Unix
+
+ // Stardardize line spaces
+ text = text.replace(/\u00A0/g, ' ');
+
+ if (options.smartIndentationFix) {
+ text = rTrimInputText(text);
+ }
+
+ // Make sure text begins and ends with a couple of newlines:
+ text = '\n\n' + text + '\n\n';
+
+ // detab
+ text = showdown.subParser('detab')(text, options, globals);
+
+ /**
+ * Strip any lines consisting only of spaces and tabs.
+ * This makes subsequent regexs easier to write, because we can
+ * match consecutive blank lines with /\n+/ instead of something
+ * contorted like /[ \t]*\n+/
+ */
+ text = text.replace(/^[ \t]+$/mg, '');
+
+ //run languageExtensions
+ showdown.helper.forEach(langExtensions, function (ext) {
+ text = showdown.subParser('runExtension')(ext, text, options, globals);
+ });
+
+ // run the sub parsers
+ text = showdown.subParser('metadata')(text, options, globals);
+ text = showdown.subParser('hashPreCodeTags')(text, options, globals);
+ text = showdown.subParser('githubCodeBlocks')(text, options, globals);
+ text = showdown.subParser('hashHTMLBlocks')(text, options, globals);
+ text = showdown.subParser('hashCodeTags')(text, options, globals);
+ text = showdown.subParser('stripLinkDefinitions')(text, options, globals);
+ text = showdown.subParser('blockGamut')(text, options, globals);
+ text = showdown.subParser('unhashHTMLSpans')(text, options, globals);
+ text = showdown.subParser('unescapeSpecialChars')(text, options, globals);
+
+ // attacklab: Restore dollar signs
+ text = text.replace(/¨D/g, '$$');
+
+ // attacklab: Restore tremas
+ text = text.replace(/¨T/g, '¨');
+
+ // render a complete html document instead of a partial if the option is enabled
+ text = showdown.subParser('completeHTMLDocument')(text, options, globals);
+
+ // Run output modifiers
+ showdown.helper.forEach(outputModifiers, function (ext) {
+ text = showdown.subParser('runExtension')(ext, text, options, globals);
+ });
+
+ // update metadata
+ metadata = globals.metadata;
+ return text;
+ };
+
+ /**
+ * Converts an HTML string into a markdown string
+ * @param src
+ * @param [HTMLParser] A WHATWG DOM and HTML parser, such as JSDOM. If none is supplied, window.document will be used.
+ * @returns {string}
+ */
+ this.makeMarkdown = this.makeMd = function (src, HTMLParser) {
+
+ // replace \r\n with \n
+ src = src.replace(/\r\n/g, '\n');
+ src = src.replace(/\r/g, '\n'); // old macs
+
+ // due to an edge case, we need to find this: > <
+ // to prevent removing of non silent white spaces
+ // ex: this is sparta
+ src = src.replace(/>[ \t]+, '>¨NBSP;<');
+
+ if (!HTMLParser) {
+ if (window && window.document) {
+ HTMLParser = window.document;
+ } else {
+ throw new Error('HTMLParser is undefined. If in a webworker or nodejs environment, you need to provide a WHATWG DOM and HTML such as JSDOM');
+ }
+ }
+
+ var doc = HTMLParser.createElement('div');
+ doc.innerHTML = src;
+
+ var globals = {
+ preList: substitutePreCodeTags(doc)
+ };
+
+ // remove all newlines and collapse spaces
+ clean(doc);
+
+ // some stuff, like accidental reference links must now be escaped
+ // TODO
+ // doc.innerHTML = doc.innerHTML.replace(/\[[\S\t ]]/);
+
+ var nodes = doc.childNodes,
+ mdDoc = '';
+
+ for (var i = 0; i < nodes.length; i++) {
+ mdDoc += showdown.subParser('makeMarkdown.node')(nodes[i], globals);
+ }
+
+ function clean (node) {
+ for (var n = 0; n < node.childNodes.length; ++n) {
+ var child = node.childNodes[n];
+ if (child.nodeType === 3) {
+ if (!/\S/.test(child.nodeValue)) {
+ node.removeChild(child);
+ --n;
+ } else {
+ child.nodeValue = child.nodeValue.split('\n').join(' ');
+ child.nodeValue = child.nodeValue.replace(/(\s)+/g, '$1');
+ }
+ } else if (child.nodeType === 1) {
+ clean(child);
+ }
+ }
+ }
+
+ // find all pre tags and replace contents with placeholder
+ // we need this so that we can remove all indentation from html
+ // to ease up parsing
+ function substitutePreCodeTags (doc) {
+
+ var pres = doc.querySelectorAll('pre'),
+ presPH = [];
+
+ for (var i = 0; i < pres.length; ++i) {
+
+ if (pres[i].childElementCount === 1 && pres[i].firstChild.tagName.toLowerCase() === 'code') {
+ var content = pres[i].firstChild.innerHTML.trim(),
+ language = pres[i].firstChild.getAttribute('data-language') || '';
+
+ // if data-language attribute is not defined, then we look for class language-*
+ if (language === '') {
+ var classes = pres[i].firstChild.className.split(' ');
+ for (var c = 0; c < classes.length; ++c) {
+ var matches = classes[c].match(/^language-(.+)$/);
+ if (matches !== null) {
+ language = matches[1];
+ break;
+ }
+ }
+ }
+
+ // unescape html entities in content
+ content = showdown.helper.unescapeHTMLEntities(content);
+
+ presPH.push(content);
+ pres[i].outerHTML = ' content, so we need to fix that:
+ bq = bq.replace(/(\s*
[^\r]+?<\/pre>)/gm, function (wholeMatch, m1) {
+ var pre = m1;
+ // attacklab: hack around Konqueror 3.5.4 bug:
+ pre = pre.replace(/^ /mg, '¨0');
+ pre = pre.replace(/¨0/g, '');
+ return pre;
+ });
+
+ return showdown.subParser('hashBlock')('\n' + bq + '\n
', options, globals);
+ });
+
+ text = globals.converter._dispatch('blockQuotes.after', text, options, globals);
+ return text;
+});
+
+/**
+ * Process Markdown `` blocks.
+ */
+showdown.subParser('codeBlocks', function (text, options, globals) {
+ 'use strict';
+
+ text = globals.converter._dispatch('codeBlocks.before', text, options, globals);
+
+ // sentinel workarounds for lack of \A and \Z, safari\khtml bug
+ text += '¨0';
+
+ var pattern = /(?:\n\n|^)((?:(?:[ ]{4}|\t).*\n+)+)(\n*[ ]{0,3}[^ \t\n]|(?=¨0))/g;
+ text = text.replace(pattern, function (wholeMatch, m1, m2) {
+ var codeblock = m1,
+ nextChar = m2,
+ end = '\n';
+
+ codeblock = showdown.subParser('outdent')(codeblock, options, globals);
+ codeblock = showdown.subParser('encodeCode')(codeblock, options, globals);
+ codeblock = showdown.subParser('detab')(codeblock, options, globals);
+ codeblock = codeblock.replace(/^\n+/g, ''); // trim leading newlines
+ codeblock = codeblock.replace(/\n+$/g, ''); // trim trailing newlines
+
+ if (options.omitExtraWLInCodeBlocks) {
+ end = '';
+ }
+
+ codeblock = '
';
+
+ return showdown.subParser('hashBlock')(codeblock, options, globals) + nextChar;
+ });
+
+ // strip sentinel
+ text = text.replace(/¨0/, '');
+
+ text = globals.converter._dispatch('codeBlocks.after', text, options, globals);
+ return text;
+});
+
+/**
+ *
+ * * Backtick quotes are used for ' + codeblock + end + ' spans.
+ *
+ * * You can use multiple backticks as the delimiters if you want to
+ * include literal backticks in the code span. So, this input:
+ *
+ * Just type ``foo `bar` baz`` at the prompt.
+ *
+ * Will translate to:
+ *
+ * foo `bar` baz at the prompt.`bar` ...
+ */
+showdown.subParser('codeSpans', function (text, options, globals) {
+ 'use strict';
+
+ text = globals.converter._dispatch('codeSpans.before', text, options, globals);
+
+ if (typeof text === 'undefined') {
+ text = '';
+ }
+ text = text.replace(/(^|[^\\])(`+)([^\r]*?[^`])\2(?!`)/gm,
+ function (wholeMatch, m1, m2, m3) {
+ var c = m3;
+ c = c.replace(/^([ \t]*)/g, ''); // leading whitespace
+ c = c.replace(/[ \t]*$/g, ''); // trailing whitespace
+ c = showdown.subParser('encodeCode')(c, options, globals);
+ c = m1 + '' + c + '';
+ c = showdown.subParser('hashHTMLSpans')(c, options, globals);
+ return c;
+ }
+ );
+
+ text = globals.converter._dispatch('codeSpans.after', text, options, globals);
+ return text;
+});
+
+/**
+ * Create a full HTML document from the processed markdown
+ */
+showdown.subParser('completeHTMLDocument', function (text, options, globals) {
+ 'use strict';
+
+ if (!options.completeHTMLDocument) {
+ return text;
+ }
+
+ text = globals.converter._dispatch('completeHTMLDocument.before', text, options, globals);
+
+ var doctype = 'html',
+ doctypeParsed = '\n',
+ title = '',
+ charset = '\n',
+ lang = '',
+ metadata = '';
+
+ if (typeof globals.metadata.parsed.doctype !== 'undefined') {
+ doctypeParsed = '\n';
+ doctype = globals.metadata.parsed.doctype.toString().toLowerCase();
+ if (doctype === 'html' || doctype === 'html5') {
+ charset = '';
+ }
+ }
+
+ for (var meta in globals.metadata.parsed) {
+ if (globals.metadata.parsed.hasOwnProperty(meta)) {
+ switch (meta.toLowerCase()) {
+ case 'doctype':
+ break;
+
+ case 'title':
+ title = '
';
+
+ codeblock = showdown.subParser('hashBlock')(codeblock, options, globals);
+
+ // Since GHCodeblocks can be false positives, we need to
+ // store the primitive text and the parsed text in a global var,
+ // and then return a token
+ return '\n\n¨G' + (globals.ghCodeBlocks.push({text: wholeMatch, codeblock: codeblock}) - 1) + 'G\n\n';
+ });
+
+ // attacklab: strip sentinel
+ text = text.replace(/¨0/, '');
+
+ return globals.converter._dispatch('githubCodeBlocks.after', text, options, globals);
+});
+
+showdown.subParser('hashBlock', function (text, options, globals) {
+ 'use strict';
+ text = globals.converter._dispatch('hashBlock.before', text, options, globals);
+ text = text.replace(/(^\n+|\n+$)/g, '');
+ text = '\n\n¨K' + (globals.gHtmlBlocks.push(text) - 1) + 'K\n\n';
+ text = globals.converter._dispatch('hashBlock.after', text, options, globals);
+ return text;
+});
+
+/**
+ * Hash and escape ' + codeblock + end + ' elements that should not be parsed as markdown
+ */
+showdown.subParser('hashCodeTags', function (text, options, globals) {
+ 'use strict';
+ text = globals.converter._dispatch('hashCodeTags.before', text, options, globals);
+
+ var repFunc = function (wholeMatch, match, left, right) {
+ var codeblock = left + showdown.subParser('encodeCode')(match, options, globals) + right;
+ return '¨C' + (globals.gHtmlSpans.push(codeblock) - 1) + 'C';
+ };
+
+ // Hash naked
+ text = showdown.helper.replaceRecursiveRegExp(text, repFunc, ']*>', '', 'gim');
+
+ text = globals.converter._dispatch('hashCodeTags.after', text, options, globals);
+ return text;
+});
+
+showdown.subParser('hashElement', function (text, options, globals) {
+ 'use strict';
+
+ return function (wholeMatch, m1) {
+ var blockText = m1;
+
+ // Undo double lines
+ blockText = blockText.replace(/\n\n/g, '\n');
+ blockText = blockText.replace(/^\n/, '');
+
+ // strip trailing blank lines
+ blockText = blockText.replace(/\n+$/g, '');
+
+ // Replace the element text with a marker ("¨KxK" where x is its key)
+ blockText = '\n\n¨K' + (globals.gHtmlBlocks.push(blockText) - 1) + 'K\n\n';
+
+ return blockText;
+ };
+});
+
+showdown.subParser('hashHTMLBlocks', function (text, options, globals) {
+ 'use strict';
+ text = globals.converter._dispatch('hashHTMLBlocks.before', text, options, globals);
+
+ var blockTags = [
+ 'pre',
+ 'div',
+ 'h1',
+ 'h2',
+ 'h3',
+ 'h4',
+ 'h5',
+ 'h6',
+ 'blockquote',
+ 'table',
+ 'dl',
+ 'ol',
+ 'ul',
+ 'script',
+ 'noscript',
+ 'form',
+ 'fieldset',
+ 'iframe',
+ 'math',
+ 'style',
+ 'section',
+ 'header',
+ 'footer',
+ 'nav',
+ 'article',
+ 'aside',
+ 'address',
+ 'audio',
+ 'canvas',
+ 'figure',
+ 'hgroup',
+ 'output',
+ 'video',
+ 'p'
+ ],
+ repFunc = function (wholeMatch, match, left, right) {
+ var txt = wholeMatch;
+ // check if this html element is marked as markdown
+ // if so, it's contents should be parsed as markdown
+ if (left.search(/\bmarkdown\b/) !== -1) {
+ txt = left + globals.converter.makeHtml(match) + right;
+ }
+ return '\n\n¨K' + (globals.gHtmlBlocks.push(txt) - 1) + 'K\n\n';
+ };
+
+ if (options.backslashEscapesHTMLTags) {
+ // encode backslash escaped HTML tags
+ text = text.replace(/\\<(\/?[^>]+?)>/g, function (wm, inside) {
+ return '<' + inside + '>';
+ });
+ }
+
+ // hash HTML Blocks
+ for (var i = 0; i < blockTags.length; ++i) {
+
+ var opTagPos,
+ rgx1 = new RegExp('^ {0,3}(<' + blockTags[i] + '\\b[^>]*>)', 'im'),
+ patLeft = '<' + blockTags[i] + '\\b[^>]*>',
+ patRight = '' + blockTags[i] + '>';
+ // 1. Look for the first position of the first opening HTML tag in the text
+ while ((opTagPos = showdown.helper.regexIndexOf(text, rgx1)) !== -1) {
+
+ // if the HTML tag is \ escaped, we need to escape it and break
+
+
+ //2. Split the text in that position
+ var subTexts = showdown.helper.splitAtIndex(text, opTagPos),
+ //3. Match recursively
+ newSubText1 = showdown.helper.replaceRecursiveRegExp(subTexts[1], repFunc, patLeft, patRight, 'im');
+
+ // prevent an infinite loop
+ if (newSubText1 === subTexts[1]) {
+ break;
+ }
+ text = subTexts[0].concat(newSubText1);
+ }
+ }
+ // HR SPECIAL CASE
+ text = text.replace(/(\n {0,3}(<(hr)\b([^<>])*?\/?>)[ \t]*(?=\n{2,}))/g,
+ showdown.subParser('hashElement')(text, options, globals));
+
+ // Special case for standalone HTML comments
+ text = showdown.helper.replaceRecursiveRegExp(text, function (txt) {
+ return '\n\n¨K' + (globals.gHtmlBlocks.push(txt) - 1) + 'K\n\n';
+ }, '^ {0,3}', 'gm');
+
+ // PHP and ASP-style processor instructions (...?> and <%...%>)
+ text = text.replace(/(?:\n\n)( {0,3}(?:<([?%])[^\r]*?\2>)[ \t]*(?=\n{2,}))/g,
+ showdown.subParser('hashElement')(text, options, globals));
+
+ text = globals.converter._dispatch('hashHTMLBlocks.after', text, options, globals);
+ return text;
+});
+
+/**
+ * Hash span elements that should not be parsed as markdown
+ */
+showdown.subParser('hashHTMLSpans', function (text, options, globals) {
+ 'use strict';
+ text = globals.converter._dispatch('hashHTMLSpans.before', text, options, globals);
+
+ function hashHTMLSpan (html) {
+ return '¨C' + (globals.gHtmlSpans.push(html) - 1) + 'C';
+ }
+
+ // Hash Self Closing tags
+ text = text.replace(/<[^>]+?\/>/gi, function (wm) {
+ return hashHTMLSpan(wm);
+ });
+
+ // Hash tags without properties
+ text = text.replace(/<([^>]+?)>[\s\S]*?<\/\1>/g, function (wm) {
+ return hashHTMLSpan(wm);
+ });
+
+ // Hash tags with properties
+ text = text.replace(/<([^>]+?)\s[^>]+?>[\s\S]*?<\/\1>/g, function (wm) {
+ return hashHTMLSpan(wm);
+ });
+
+ // Hash self closing tags without />
+ text = text.replace(/<[^>]+?>/gi, function (wm) {
+ return hashHTMLSpan(wm);
+ });
+
+ /*showdown.helper.matchRecursiveRegExp(text, ']*>', '', 'gi');*/
+
+ text = globals.converter._dispatch('hashHTMLSpans.after', text, options, globals);
+ return text;
+});
+
+/**
+ * Unhash HTML spans
+ */
+showdown.subParser('unhashHTMLSpans', function (text, options, globals) {
+ 'use strict';
+ text = globals.converter._dispatch('unhashHTMLSpans.before', text, options, globals);
+
+ for (var i = 0; i < globals.gHtmlSpans.length; ++i) {
+ var repText = globals.gHtmlSpans[i],
+ // limiter to prevent infinite loop (assume 10 as limit for recurse)
+ limit = 0;
+
+ while (/¨C(\d+)C/.test(repText)) {
+ var num = RegExp.$1;
+ repText = repText.replace('¨C' + num + 'C', globals.gHtmlSpans[num]);
+ if (limit === 10) {
+ console.error('maximum nesting of 10 spans reached!!!');
+ break;
+ }
+ ++limit;
+ }
+ text = text.replace('¨C' + i + 'C', repText);
+ }
+
+ text = globals.converter._dispatch('unhashHTMLSpans.after', text, options, globals);
+ return text;
+});
+
+/**
+ * Hash and escape elements that should not be parsed as markdown
+ */
+showdown.subParser('hashPreCodeTags', function (text, options, globals) {
+ 'use strict';
+ text = globals.converter._dispatch('hashPreCodeTags.before', text, options, globals);
+
+ var repFunc = function (wholeMatch, match, left, right) {
+ // encode html entities
+ var codeblock = left + showdown.subParser('encodeCode')(match, options, globals) + right;
+ return '\n\n¨G' + (globals.ghCodeBlocks.push({text: wholeMatch, codeblock: codeblock}) - 1) + 'G\n\n';
+ };
+
+ // Hash
+ text = showdown.helper.replaceRecursiveRegExp(text, repFunc, '^ {0,3}]*>\\s*
', 'gim');
+
+ text = globals.converter._dispatch('hashPreCodeTags.after', text, options, globals);
+ return text;
+});
+
+showdown.subParser('headers', function (text, options, globals) {
+ 'use strict';
+
+ text = globals.converter._dispatch('headers.before', text, options, globals);
+
+ var headerLevelStart = (isNaN(parseInt(options.headerLevelStart))) ? 1 : parseInt(options.headerLevelStart),
+
+ // Set text-style headers:
+ // Header 1
+ // ========
+ //
+ // Header 2
+ // --------
+ //
+ setextRegexH1 = (options.smoothLivePreview) ? /^(.+)[ \t]*\n={2,}[ \t]*\n+/gm : /^(.+)[ \t]*\n=+[ \t]*\n+/gm,
+ setextRegexH2 = (options.smoothLivePreview) ? /^(.+)[ \t]*\n-{2,}[ \t]*\n+/gm : /^(.+)[ \t]*\n-+[ \t]*\n+/gm;
+
+ text = text.replace(setextRegexH1, function (wholeMatch, m1) {
+
+ var spanGamut = showdown.subParser('spanGamut')(m1, options, globals),
+ hID = (options.noHeaderId) ? '' : ' id="' + headerId(m1) + '"',
+ hLevel = headerLevelStart,
+ hashBlock = ']*>', '^ {0,3}\\s*
', options, globals);
+ text = text.replace(/^ {0,2}( ?-){3,}[ \t]*$/gm, key);
+ text = text.replace(/^ {0,2}( ?\*){3,}[ \t]*$/gm, key);
+ text = text.replace(/^ {0,2}( ?_){3,}[ \t]*$/gm, key);
+
+ text = globals.converter._dispatch('horizontalRule.after', text, options, globals);
+ return text;
+});
+
+/**
+ * Turn Markdown image shortcuts into tags.
+ */
+showdown.subParser('images', function (text, options, globals) {
+ 'use strict';
+
+ text = globals.converter._dispatch('images.before', text, options, globals);
+
+ var inlineRegExp = /!\[([^\]]*?)][ \t]*()\([ \t]?([\S]+?(?:\([\S]*?\)[\S]*?)?)>?(?: =([*\d]+[A-Za-z%]{0,4})x([*\d]+[A-Za-z%]{0,4}))?[ \t]*(?:(["'])([^"]*?)\6)?[ \t]?\)/g,
+ crazyRegExp = /!\[([^\]]*?)][ \t]*()\([ \t]?<([^>]*)>(?: =([*\d]+[A-Za-z%]{0,4})x([*\d]+[A-Za-z%]{0,4}))?[ \t]*(?:(?:(["'])([^"]*?)\6))?[ \t]?\)/g,
+ base64RegExp = /!\[([^\]]*?)][ \t]*()\([ \t]?(data:.+?\/.+?;base64,[A-Za-z0-9+/=\n]+?)>?(?: =([*\d]+[A-Za-z%]{0,4})x([*\d]+[A-Za-z%]{0,4}))?[ \t]*(?:(["'])([^"]*?)\6)?[ \t]?\)/g,
+ referenceRegExp = /!\[([^\]]*?)] ?(?:\n *)?\[([\s\S]*?)]()()()()()/g,
+ refShortcutRegExp = /!\[([^\[\]]+)]()()()()()/g;
+
+ function writeImageTagBase64 (wholeMatch, altText, linkId, url, width, height, m5, title) {
+ url = url.replace(/\s/g, '');
+ return writeImageTag (wholeMatch, altText, linkId, url, width, height, m5, title);
+ }
+
+ function writeImageTag (wholeMatch, altText, linkId, url, width, height, m5, title) {
+
+ var gUrls = globals.gUrls,
+ gTitles = globals.gTitles,
+ gDims = globals.gDimensions;
+
+ linkId = linkId.toLowerCase();
+
+ if (!title) {
+ title = '';
+ }
+ // Special case for explicit empty url
+ if (wholeMatch.search(/\(\s*>? ?(['"].*['"])?\)$/m) > -1) {
+ url = '';
+
+ } else if (url === '' || url === null) {
+ if (linkId === '' || linkId === null) {
+ // lower-case and turn embedded newlines into spaces
+ linkId = altText.toLowerCase().replace(/ ?\n/g, ' ');
+ }
+ url = '#' + linkId;
+
+ if (!showdown.helper.isUndefined(gUrls[linkId])) {
+ url = gUrls[linkId];
+ if (!showdown.helper.isUndefined(gTitles[linkId])) {
+ title = gTitles[linkId];
+ }
+ if (!showdown.helper.isUndefined(gDims[linkId])) {
+ width = gDims[linkId].width;
+ height = gDims[linkId].height;
+ }
+ } else {
+ return wholeMatch;
+ }
+ }
+
+ altText = altText
+ .replace(/"/g, '"')
+ //altText = showdown.helper.escapeCharacters(altText, '*_', false);
+ .replace(showdown.helper.regexes.asteriskDashAndColon, showdown.helper.escapeCharactersCallback);
+ //url = showdown.helper.escapeCharacters(url, '*_', false);
+ url = url.replace(showdown.helper.regexes.asteriskDashAndColon, showdown.helper.escapeCharactersCallback);
+ var result = '
';
+
+ return result;
+ }
+
+ // First, handle reference-style labeled images: ![alt text][id]
+ text = text.replace(referenceRegExp, writeImageTag);
+
+ // Next, handle inline images: in the beginning of the line
+ // Kind of hackish/monkey patching, but seems more effective than overcomplicating the list parser
+ item = item.replace(/^([-*+]|\d\.)[ \t]+[\S\n ]*/g, function (wm2) {
+ return '¨A' + wm2;
+ });
+
+ // m1 - Leading line or
+ // Has a double return (multi paragraph) or
+ // Has sublist
+ if (m1 || (item.search(/\n{2,}/) > -1)) {
+ item = showdown.subParser('githubCodeBlocks')(item, options, globals);
+ item = showdown.subParser('blockGamut')(item, options, globals);
+ } else {
+ // Recursion for sub-lists:
+ item = showdown.subParser('lists')(item, options, globals);
+ item = item.replace(/\n$/, ''); // chomp(item)
+ item = showdown.subParser('hashHTMLBlocks')(item, options, globals);
+
+ // Colapse double linebreaks
+ item = item.replace(/\n\n+/g, '\n\n');
+ if (isParagraphed) {
+ item = showdown.subParser('paragraphs')(item, options, globals);
+ } else {
+ item = showdown.subParser('spanGamut')(item, options, globals);
+ }
+ }
+
+ // now we need to remove the marker (¨A)
+ item = item.replace('¨A', '');
+ // we can finally wrap the line in list item tags
+ item = ']*>\s*
]*>/.test(grafsOutIt)) {
+ codeFlag = true;
+ }
+ }
+ grafsOut[i] = grafsOutIt;
+ }
+ text = grafsOut.join('\n');
+ // Strip leading and trailing lines:
+ text = text.replace(/^\n+/g, '');
+ text = text.replace(/\n+$/g, '');
+ return globals.converter._dispatch('paragraphs.after', text, options, globals);
+});
+
+/**
+ * Run extension
+ */
+showdown.subParser('runExtension', function (ext, text, options, globals) {
+ 'use strict';
+
+ if (ext.filter) {
+ text = ext.filter(text, globals.converter, options);
+
+ } else if (ext.regex) {
+ // TODO remove this when old extension loading mechanism is deprecated
+ var re = ext.regex;
+ if (!(re instanceof RegExp)) {
+ re = new RegExp(re, 'g');
+ }
+ text = text.replace(re, ext.replace);
+ }
+
+ return text;
+});
+
+/**
+ * These are all the transformations that occur *within* block-level
+ * tags like paragraphs, headers, and list items.
+ */
+showdown.subParser('spanGamut', function (text, options, globals) {
+ 'use strict';
+
+ text = globals.converter._dispatch('spanGamut.before', text, options, globals);
+ text = showdown.subParser('codeSpans')(text, options, globals);
+ text = showdown.subParser('escapeSpecialCharsWithinTagAttributes')(text, options, globals);
+ text = showdown.subParser('encodeBackslashEscapes')(text, options, globals);
+
+ // Process anchor and image tags. Images must come first,
+ // because ![foo][f] looks like an anchor.
+ text = showdown.subParser('images')(text, options, globals);
+ text = showdown.subParser('anchors')(text, options, globals);
+
+ // Make links out of things like `
\n');
+ }
+ } else {
+ // Vanilla hard breaks
+ text = text.replace(/ +\n/g, '
\n');
+ }
+
+ text = globals.converter._dispatch('spanGamut.after', text, options, globals);
+ return text;
+});
+
+showdown.subParser('strikethrough', function (text, options, globals) {
+ 'use strict';
+
+ function parseInside (txt) {
+ if (options.simplifiedAutoLink) {
+ txt = showdown.subParser('simplifiedAutoLinks')(txt, options, globals);
+ }
+ return '' + txt + '';
+ }
+
+ if (options.strikethrough) {
+ text = globals.converter._dispatch('strikethrough.before', text, options, globals);
+ text = text.replace(/(?:~){2}([\s\S]+?)(?:~){2}/g, function (wm, txt) { return parseInside(txt); });
+ text = globals.converter._dispatch('strikethrough.after', text, options, globals);
+ }
+
+ return text;
+});
+
+/**
+ * Strips link definitions from text, stores the URLs and titles in
+ * hash references.
+ * Link defs are in the form: ^[id]: url "optional title"
+ */
+showdown.subParser('stripLinkDefinitions', function (text, options, globals) {
+ 'use strict';
+
+ var regex = /^ {0,3}\[(.+)]:[ \t]*\n?[ \t]*([^>\s]+)>?(?: =([*\d]+[A-Za-z%]{0,4})x([*\d]+[A-Za-z%]{0,4}))?[ \t]*\n?[ \t]*(?:(\n*)["|'(](.+?)["|')][ \t]*)?(?:\n+|(?=¨0))/gm,
+ base64Regex = /^ {0,3}\[(.+)]:[ \t]*\n?[ \t]*(data:.+?\/.+?;base64,[A-Za-z0-9+/=\n]+?)>?(?: =([*\d]+[A-Za-z%]{0,4})x([*\d]+[A-Za-z%]{0,4}))?[ \t]*\n?[ \t]*(?:(\n*)["|'(](.+?)["|')][ \t]*)?(?:\n\n|(?=¨0)|(?=\n\[))/gm;
+
+ // attacklab: sentinel workarounds for lack of \A and \Z, safari\khtml bug
+ text += '¨0';
+
+ var replaceFunc = function (wholeMatch, linkId, url, width, height, blankLines, title) {
+ linkId = linkId.toLowerCase();
+ if (url.match(/^data:.+?\/.+?;base64,/)) {
+ // remove newlines
+ globals.gUrls[linkId] = url.replace(/\s/g, '');
+ } else {
+ globals.gUrls[linkId] = showdown.subParser('encodeAmpsAndAngles')(url, options, globals); // Link IDs are case-insensitive
+ }
+
+ if (blankLines) {
+ // Oops, found blank lines, so it's not a title.
+ // Put back the parenthetical statement we stole.
+ return blankLines + title;
+
+ } else {
+ if (title) {
+ globals.gTitles[linkId] = title.replace(/"|'/g, '"');
+ }
+ if (options.parseImgDimensions && width && height) {
+ globals.gDimensions[linkId] = {
+ width: width,
+ height: height
+ };
+ }
+ }
+ // Completely remove the definition from the text
+ return '';
+ };
+
+ // first we try to find base64 link references
+ text = text.replace(base64Regex, replaceFunc);
+
+ text = text.replace(regex, replaceFunc);
+
+ // attacklab: strip sentinel
+ text = text.replace(/¨0/, '');
+
+ return text;
+});
+
+showdown.subParser('tables', function (text, options, globals) {
+ 'use strict';
+
+ if (!options.tables) {
+ return text;
+ }
+
+ var tableRgx = /^ {0,3}\|?.+\|.+\n {0,3}\|?[ \t]*:?[ \t]*(?:[-=]){2,}[ \t]*:?[ \t]*\|[ \t]*:?[ \t]*(?:[-=]){2,}[\s\S]+?(?:\n\n|¨0)/gm,
+ //singeColTblRgx = /^ {0,3}\|.+\|\n {0,3}\|[ \t]*:?[ \t]*(?:[-=]){2,}[ \t]*:?[ \t]*\|[ \t]*\n(?: {0,3}\|.+\|\n)+(?:\n\n|¨0)/gm;
+ singeColTblRgx = /^ {0,3}\|.+\|[ \t]*\n {0,3}\|[ \t]*:?[ \t]*(?:[-=]){2,}[ \t]*:?[ \t]*\|[ \t]*\n( {0,3}\|.+\|[ \t]*\n)*(?:\n|¨0)/gm;
+
+ function parseStyles (sLine) {
+ if (/^:[ \t]*--*$/.test(sLine)) {
+ return ' style="text-align:left;"';
+ } else if (/^--*[ \t]*:[ \t]*$/.test(sLine)) {
+ return ' style="text-align:right;"';
+ } else if (/^:[ \t]*--*[ \t]*:$/.test(sLine)) {
+ return ' style="text-align:center;"';
+ } else {
+ return '';
+ }
+ }
+
+ function parseHeaders (header, style) {
+ var id = '';
+ header = header.trim();
+ // support both tablesHeaderId and tableHeaderId due to error in documentation so we don't break backwards compatibility
+ if (options.tablesHeaderId || options.tableHeaderId) {
+ id = ' id="' + header.replace(/ /g, '_').toLowerCase() + '"';
+ }
+ header = showdown.subParser('spanGamut')(header, options, globals);
+
+ return '' + header + ' \n';
+ }
+
+ function parseCells (cell, style) {
+ var subText = showdown.subParser('spanGamut')(cell, options, globals);
+ return '' + subText + ' \n';
+ }
+
+ function buildTable (headers, cells) {
+ var tb = '\n\n
\n';
+ return tb;
+ }
+
+ function parseTable (rawTable) {
+ var i, tableLines = rawTable.split('\n');
+
+ for (i = 0; i < tableLines.length; ++i) {
+ // strip wrong first and last column if wrapped tables are used
+ if (/^ {0,3}\|/.test(tableLines[i])) {
+ tableLines[i] = tableLines[i].replace(/^ {0,3}\|/, '');
+ }
+ if (/\|[ \t]*$/.test(tableLines[i])) {
+ tableLines[i] = tableLines[i].replace(/\|[ \t]*$/, '');
+ }
+ // parse code spans first, but we only support one line code spans
+ tableLines[i] = showdown.subParser('codeSpans')(tableLines[i], options, globals);
+ }
+
+ var rawHeaders = tableLines[0].split('|').map(function (s) { return s.trim();}),
+ rawStyles = tableLines[1].split('|').map(function (s) { return s.trim();}),
+ rawCells = [],
+ headers = [],
+ styles = [],
+ cells = [];
+
+ tableLines.shift();
+ tableLines.shift();
+
+ for (i = 0; i < tableLines.length; ++i) {
+ if (tableLines[i].trim() === '') {
+ continue;
+ }
+ rawCells.push(
+ tableLines[i]
+ .split('|')
+ .map(function (s) {
+ return s.trim();
+ })
+ );
+ }
+
+ if (rawHeaders.length < rawStyles.length) {
+ return rawTable;
+ }
+
+ for (i = 0; i < rawStyles.length; ++i) {
+ styles.push(parseStyles(rawStyles[i]));
+ }
+
+ for (i = 0; i < rawHeaders.length; ++i) {
+ if (showdown.helper.isUndefined(styles[i])) {
+ styles[i] = '';
+ }
+ headers.push(parseHeaders(rawHeaders[i], styles[i]));
+ }
+
+ for (i = 0; i < rawCells.length; ++i) {
+ var row = [];
+ for (var ii = 0; ii < headers.length; ++ii) {
+ if (showdown.helper.isUndefined(rawCells[i][ii])) {
+
+ }
+ row.push(parseCells(rawCells[i][ii], styles[ii]));
+ }
+ cells.push(row);
+ }
+
+ return buildTable(headers, cells);
+ }
+
+ text = globals.converter._dispatch('tables.before', text, options, globals);
+
+ // find escaped pipe characters
+ text = text.replace(/\\(\|)/g, showdown.helper.escapeCharactersCallback);
+
+ // parse multi column tables
+ text = text.replace(tableRgx, parseTable);
+
+ // parse one column tables
+ text = text.replace(singeColTblRgx, parseTable);
+
+ text = globals.converter._dispatch('tables.after', text, options, globals);
+
+ return text;
+});
+
+showdown.subParser('underline', function (text, options, globals) {
+ 'use strict';
+
+ if (!options.underline) {
+ return text;
+ }
+
+ text = globals.converter._dispatch('underline.before', text, options, globals);
+
+ if (options.literalMidWordUnderscores) {
+ text = text.replace(/\b___(\S[\s\S]*?)___\b/g, function (wm, txt) {
+ return '' + txt + '';
+ });
+ text = text.replace(/\b__(\S[\s\S]*?)__\b/g, function (wm, txt) {
+ return '' + txt + '';
+ });
+ } else {
+ text = text.replace(/___(\S[\s\S]*?)___/g, function (wm, m) {
+ return (/\S$/.test(m)) ? '' + m + '' : wm;
+ });
+ text = text.replace(/__(\S[\s\S]*?)__/g, function (wm, m) {
+ return (/\S$/.test(m)) ? '' + m + '' : wm;
+ });
+ }
+
+ // escape remaining underscores to prevent them being parsed by italic and bold
+ text = text.replace(/(_)/g, showdown.helper.escapeCharactersCallback);
+
+ text = globals.converter._dispatch('underline.after', text, options, globals);
+
+ return text;
+});
+
+/**
+ * Swap back in all the special characters we've hidden.
+ */
+showdown.subParser('unescapeSpecialChars', function (text, options, globals) {
+ 'use strict';
+ text = globals.converter._dispatch('unescapeSpecialChars.before', text, options, globals);
+
+ text = text.replace(/¨E(\d+)E/g, function (wholeMatch, m1) {
+ var charCodeToReplace = parseInt(m1);
+ return String.fromCharCode(charCodeToReplace);
+ });
+
+ text = globals.converter._dispatch('unescapeSpecialChars.after', text, options, globals);
+ return text;
+});
+
+showdown.subParser('makeMarkdown.blockquote', function (node, globals) {
+ 'use strict';
+
+ var txt = '';
+ if (node.hasChildNodes()) {
+ var children = node.childNodes,
+ childrenLength = children.length;
+
+ for (var i = 0; i < childrenLength; ++i) {
+ var innerTxt = showdown.subParser('makeMarkdown.node')(children[i], globals);
+
+ if (innerTxt === '') {
+ continue;
+ }
+ txt += innerTxt;
+ }
+ }
+ // cleanup
+ txt = txt.trim();
+ txt = '> ' + txt.split('\n').join('\n> ');
+ return txt;
+});
+
+showdown.subParser('makeMarkdown.codeBlock', function (node, globals) {
+ 'use strict';
+
+ var lang = node.getAttribute('language'),
+ num = node.getAttribute('precodenum');
+ return '```' + lang + '\n' + globals.preList[num] + '\n```';
+});
+
+showdown.subParser('makeMarkdown.codeSpan', function (node) {
+ 'use strict';
+
+ return '`' + node.innerHTML + '`';
+});
+
+showdown.subParser('makeMarkdown.emphasis', function (node, globals) {
+ 'use strict';
+
+ var txt = '';
+ if (node.hasChildNodes()) {
+ txt += '*';
+ var children = node.childNodes,
+ childrenLength = children.length;
+ for (var i = 0; i < childrenLength; ++i) {
+ txt += showdown.subParser('makeMarkdown.node')(children[i], globals);
+ }
+ txt += '*';
+ }
+ return txt;
+});
+
+showdown.subParser('makeMarkdown.header', function (node, globals, headerLevel) {
+ 'use strict';
+
+ var headerMark = new Array(headerLevel + 1).join('#'),
+ txt = '';
+
+ if (node.hasChildNodes()) {
+ txt = headerMark + ' ';
+ var children = node.childNodes,
+ childrenLength = children.length;
+
+ for (var i = 0; i < childrenLength; ++i) {
+ txt += showdown.subParser('makeMarkdown.node')(children[i], globals);
+ }
+ }
+ return txt;
+});
+
+showdown.subParser('makeMarkdown.hr', function () {
+ 'use strict';
+
+ return '---';
+});
+
+showdown.subParser('makeMarkdown.image', function (node) {
+ 'use strict';
+
+ var txt = '';
+ if (node.hasAttribute('src')) {
+ txt += ' + '>';
+ if (node.hasAttribute('width') && node.hasAttribute('height')) {
+ txt += ' =' + node.getAttribute('width') + 'x' + node.getAttribute('height');
+ }
+
+ if (node.hasAttribute('title')) {
+ txt += ' "' + node.getAttribute('title') + '"';
+ }
+ txt += ')';
+ }
+ return txt;
+});
+
+showdown.subParser('makeMarkdown.links', function (node, globals) {
+ 'use strict';
+
+ var txt = '';
+ if (node.hasChildNodes() && node.hasAttribute('href')) {
+ var children = node.childNodes,
+ childrenLength = children.length;
+ txt = '[';
+ for (var i = 0; i < childrenLength; ++i) {
+ txt += showdown.subParser('makeMarkdown.node')(children[i], globals);
+ }
+ txt += '](';
+ txt += '<' + node.getAttribute('href') + '>';
+ if (node.hasAttribute('title')) {
+ txt += ' "' + node.getAttribute('title') + '"';
+ }
+ txt += ')';
+ }
+ return txt;
+});
+
+showdown.subParser('makeMarkdown.list', function (node, globals, type) {
+ 'use strict';
+
+ var txt = '';
+ if (!node.hasChildNodes()) {
+ return '';
+ }
+ var listItems = node.childNodes,
+ listItemsLenght = listItems.length,
+ listNum = node.getAttribute('start') || 1;
+
+ for (var i = 0; i < listItemsLenght; ++i) {
+ if (typeof listItems[i].tagName === 'undefined' || listItems[i].tagName.toLowerCase() !== 'li') {
+ continue;
+ }
+
+ // define the bullet to use in list
+ var bullet = '';
+ if (type === 'ol') {
+ bullet = listNum.toString() + '. ';
+ } else {
+ bullet = '- ';
+ }
+
+ // parse list item
+ txt += bullet + showdown.subParser('makeMarkdown.listItem')(listItems[i], globals);
+ ++listNum;
+ }
+
+ // add comment at the end to prevent consecutive lists to be parsed as one
+ txt += '\n\n';
+ return txt.trim();
+});
+
+showdown.subParser('makeMarkdown.listItem', function (node, globals) {
+ 'use strict';
+
+ var listItemTxt = '';
+
+ var children = node.childNodes,
+ childrenLenght = children.length;
+
+ for (var i = 0; i < childrenLenght; ++i) {
+ listItemTxt += showdown.subParser('makeMarkdown.node')(children[i], globals);
+ }
+ // if it's only one liner, we need to add a newline at the end
+ if (!/\n$/.test(listItemTxt)) {
+ listItemTxt += '\n';
+ } else {
+ // it's multiparagraph, so we need to indent
+ listItemTxt = listItemTxt
+ .split('\n')
+ .join('\n ')
+ .replace(/^ {4}$/gm, '')
+ .replace(/\n\n+/g, '\n\n');
+ }
+
+ return listItemTxt;
+});
+
+
+
+showdown.subParser('makeMarkdown.node', function (node, globals, spansOnly) {
+ 'use strict';
+
+ spansOnly = spansOnly || false;
+
+ var txt = '';
+
+ // edge case of text without wrapper paragraph
+ if (node.nodeType === 3) {
+ return showdown.subParser('makeMarkdown.txt')(node, globals);
+ }
+
+ // HTML comment
+ if (node.nodeType === 8) {
+ return '\n\n';
+ }
+
+ // process only node elements
+ if (node.nodeType !== 1) {
+ return '';
+ }
+
+ var tagName = node.tagName.toLowerCase();
+
+ switch (tagName) {
+
+ //
+ // BLOCKS
+ //
+ case 'h1':
+ if (!spansOnly) { txt = showdown.subParser('makeMarkdown.header')(node, globals, 1) + '\n\n'; }
+ break;
+ case 'h2':
+ if (!spansOnly) { txt = showdown.subParser('makeMarkdown.header')(node, globals, 2) + '\n\n'; }
+ break;
+ case 'h3':
+ if (!spansOnly) { txt = showdown.subParser('makeMarkdown.header')(node, globals, 3) + '\n\n'; }
+ break;
+ case 'h4':
+ if (!spansOnly) { txt = showdown.subParser('makeMarkdown.header')(node, globals, 4) + '\n\n'; }
+ break;
+ case 'h5':
+ if (!spansOnly) { txt = showdown.subParser('makeMarkdown.header')(node, globals, 5) + '\n\n'; }
+ break;
+ case 'h6':
+ if (!spansOnly) { txt = showdown.subParser('makeMarkdown.header')(node, globals, 6) + '\n\n'; }
+ break;
+
+ case 'p':
+ if (!spansOnly) { txt = showdown.subParser('makeMarkdown.paragraph')(node, globals) + '\n\n'; }
+ break;
+
+ case 'blockquote':
+ if (!spansOnly) { txt = showdown.subParser('makeMarkdown.blockquote')(node, globals) + '\n\n'; }
+ break;
+
+ case 'hr':
+ if (!spansOnly) { txt = showdown.subParser('makeMarkdown.hr')(node, globals) + '\n\n'; }
+ break;
+
+ case 'ol':
+ if (!spansOnly) { txt = showdown.subParser('makeMarkdown.list')(node, globals, 'ol') + '\n\n'; }
+ break;
+
+ case 'ul':
+ if (!spansOnly) { txt = showdown.subParser('makeMarkdown.list')(node, globals, 'ul') + '\n\n'; }
+ break;
+
+ case 'precode':
+ if (!spansOnly) { txt = showdown.subParser('makeMarkdown.codeBlock')(node, globals) + '\n\n'; }
+ break;
+
+ case 'pre':
+ if (!spansOnly) { txt = showdown.subParser('makeMarkdown.pre')(node, globals) + '\n\n'; }
+ break;
+
+ case 'table':
+ if (!spansOnly) { txt = showdown.subParser('makeMarkdown.table')(node, globals) + '\n\n'; }
+ break;
+
+ //
+ // SPANS
+ //
+ case 'code':
+ txt = showdown.subParser('makeMarkdown.codeSpan')(node, globals);
+ break;
+
+ case 'em':
+ case 'i':
+ txt = showdown.subParser('makeMarkdown.emphasis')(node, globals);
+ break;
+
+ case 'strong':
+ case 'b':
+ txt = showdown.subParser('makeMarkdown.strong')(node, globals);
+ break;
+
+ case 'del':
+ txt = showdown.subParser('makeMarkdown.strikethrough')(node, globals);
+ break;
+
+ case 'a':
+ txt = showdown.subParser('makeMarkdown.links')(node, globals);
+ break;
+
+ case 'img':
+ txt = showdown.subParser('makeMarkdown.image')(node, globals);
+ break;
+
+ default:
+ txt = node.outerHTML + '\n\n';
+ }
+
+ // common normalization
+ // TODO eventually
+
+ return txt;
+});
+
+showdown.subParser('makeMarkdown.paragraph', function (node, globals) {
+ 'use strict';
+
+ var txt = '';
+ if (node.hasChildNodes()) {
+ var children = node.childNodes,
+ childrenLength = children.length;
+ for (var i = 0; i < childrenLength; ++i) {
+ txt += showdown.subParser('makeMarkdown.node')(children[i], globals);
+ }
+ }
+
+ // some text normalization
+ txt = txt.trim();
+
+ return txt;
+});
+
+showdown.subParser('makeMarkdown.pre', function (node, globals) {
+ 'use strict';
+
+ var num = node.getAttribute('prenum');
+ return '\n',
+ tblLgn = headers.length;
+
+ for (var i = 0; i < tblLgn; ++i) {
+ tb += headers[i];
+ }
+ tb += ' \n\n\n';
+
+ for (i = 0; i < cells.length; ++i) {
+ tb += '\n';
+ for (var ii = 0; ii < tblLgn; ++ii) {
+ tb += cells[i][ii];
+ }
+ tb += ' \n';
+ }
+ tb += '\n' + globals.preList[num] + '
';
+});
+
+showdown.subParser('makeMarkdown.strikethrough', function (node, globals) {
+ 'use strict';
+
+ var txt = '';
+ if (node.hasChildNodes()) {
+ txt += '~~';
+ var children = node.childNodes,
+ childrenLength = children.length;
+ for (var i = 0; i < childrenLength; ++i) {
+ txt += showdown.subParser('makeMarkdown.node')(children[i], globals);
+ }
+ txt += '~~';
+ }
+ return txt;
+});
+
+showdown.subParser('makeMarkdown.strong', function (node, globals) {
+ 'use strict';
+
+ var txt = '';
+ if (node.hasChildNodes()) {
+ txt += '**';
+ var children = node.childNodes,
+ childrenLength = children.length;
+ for (var i = 0; i < childrenLength; ++i) {
+ txt += showdown.subParser('makeMarkdown.node')(children[i], globals);
+ }
+ txt += '**';
+ }
+ return txt;
+});
+
+showdown.subParser('makeMarkdown.table', function (node, globals) {
+ 'use strict';
+
+ var txt = '',
+ tableArray = [[], []],
+ headings = node.querySelectorAll('thead>tr>th'),
+ rows = node.querySelectorAll('tbody>tr'),
+ i, ii;
+ for (i = 0; i < headings.length; ++i) {
+ var headContent = showdown.subParser('makeMarkdown.tableCell')(headings[i], globals),
+ allign = '---';
+
+ if (headings[i].hasAttribute('style')) {
+ var style = headings[i].getAttribute('style').toLowerCase().replace(/\s/g, '');
+ switch (style) {
+ case 'text-align:left;':
+ allign = ':---';
+ break;
+ case 'text-align:right;':
+ allign = '---:';
+ break;
+ case 'text-align:center;':
+ allign = ':---:';
+ break;
+ }
+ }
+ tableArray[0][i] = headContent.trim();
+ tableArray[1][i] = allign;
+ }
+
+ for (i = 0; i < rows.length; ++i) {
+ var r = tableArray.push([]) - 1,
+ cols = rows[i].getElementsByTagName('td');
+
+ for (ii = 0; ii < headings.length; ++ii) {
+ var cellContent = ' ';
+ if (typeof cols[ii] !== 'undefined') {
+ cellContent = showdown.subParser('makeMarkdown.tableCell')(cols[ii], globals);
+ }
+ tableArray[r].push(cellContent);
+ }
+ }
+
+ var cellSpacesCount = 3;
+ for (i = 0; i < tableArray.length; ++i) {
+ for (ii = 0; ii < tableArray[i].length; ++ii) {
+ var strLen = tableArray[i][ii].length;
+ if (strLen > cellSpacesCount) {
+ cellSpacesCount = strLen;
+ }
+ }
+ }
+
+ for (i = 0; i < tableArray.length; ++i) {
+ for (ii = 0; ii < tableArray[i].length; ++ii) {
+ if (i === 1) {
+ if (tableArray[i][ii].slice(-1) === ':') {
+ tableArray[i][ii] = showdown.helper.padEnd(tableArray[i][ii].slice(-1), cellSpacesCount - 1, '-') + ':';
+ } else {
+ tableArray[i][ii] = showdown.helper.padEnd(tableArray[i][ii], cellSpacesCount, '-');
+ }
+ } else {
+ tableArray[i][ii] = showdown.helper.padEnd(tableArray[i][ii], cellSpacesCount);
+ }
+ }
+ txt += '| ' + tableArray[i].join(' | ') + ' |\n';
+ }
+
+ return txt.trim();
+});
+
+showdown.subParser('makeMarkdown.tableCell', function (node, globals) {
+ 'use strict';
+
+ var txt = '';
+ if (!node.hasChildNodes()) {
+ return '';
+ }
+ var children = node.childNodes,
+ childrenLength = children.length;
+
+ for (var i = 0; i < childrenLength; ++i) {
+ txt += showdown.subParser('makeMarkdown.node')(children[i], globals, true);
+ }
+ return txt.trim();
+});
+
+showdown.subParser('makeMarkdown.txt', function (node) {
+ 'use strict';
+
+ var txt = node.nodeValue;
+
+ // multiple spaces are collapsed
+ txt = txt.replace(/ +/g, ' ');
+
+ // replace the custom ¨NBSP; with a space
+ txt = txt.replace(/¨NBSP;/g, ' ');
+
+ // ", <, > and & should replace escaped html entities
+ txt = showdown.helper.unescapeHTMLEntities(txt);
+
+ // escape markdown magic characters
+ // emphasis, strong and strikethrough - can appear everywhere
+ // we also escape pipe (|) because of tables
+ // and escape ` because of code blocks and spans
+ txt = txt.replace(/([*_~|`])/g, '\\$1');
+
+ // escape > because of blockquotes
+ txt = txt.replace(/^(\s*)>/g, '\\$1>');
+
+ // hash character, only troublesome at the beginning of a line because of headers
+ txt = txt.replace(/^#/gm, '\\#');
+
+ // horizontal rules
+ txt = txt.replace(/^(\s*)([-=]{3,})(\s*)$/, '$1\\$2$3');
+
+ // dot, because of ordered lists, only troublesome at the beginning of a line when preceded by an integer
+ txt = txt.replace(/^( {0,3}\d+)\./gm, '$1\\.');
+
+ // +, * and -, at the beginning of a line becomes a list, so we need to escape them also (asterisk was already escaped)
+ txt = txt.replace(/^( {0,3})([+-])/gm, '$1\\$2');
+
+ // images and links, ] followed by ( is problematic, so we escape it
+ txt = txt.replace(/]([\s]*)\(/g, '\\]$1\\(');
+
+ // reference URIs must also be escaped
+ txt = txt.replace(/^ {0,3}\[([\S \t]*?)]:/gm, '\\[$1]:');
+
+ return txt;
+});
+
+var root = this;
+
+// AMD Loader
+if (typeof define === 'function' && define.amd) {
+ define(function () {
+ 'use strict';
+ return showdown;
+ });
+
+// CommonJS/nodeJS Loader
+} else if (typeof module !== 'undefined' && module.exports) {
+ module.exports = showdown;
+
+// Regular Browser loader
+} else {
+ root.showdown = showdown;
+}
+}).call(this);
+
+//# sourceMappingURL=showdown.js.map
diff --git a/octoprint_octolapse/static/js/webcams/mjpg_streamer/raspi_cam_v2.js b/octoprint_octolapse/static/js/webcams/mjpg_streamer/raspi_cam_v2.js
index 1c6d69af..061d47d7 100644
--- a/octoprint_octolapse/static/js/webcams/mjpg_streamer/raspi_cam_v2.js
+++ b/octoprint_octolapse/static/js/webcams/mjpg_streamer/raspi_cam_v2.js
@@ -1,7 +1,7 @@
/*
##################################################################################
# Octolapse - A plugin for OctoPrint used for making stabilized timelapse videos.
-# Copyright (C) 2019 Brad Hochgesang
+# Copyright (C) 2023 Brad Hochgesang
##################################################################################
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published
diff --git a/octoprint_octolapse/templates/octolapse_dialog.jinja2 b/octoprint_octolapse/templates/octolapse_dialog.jinja2
new file mode 100644
index 00000000..be4c4c75
--- /dev/null
+++ b/octoprint_octolapse/templates/octolapse_dialog.jinja2
@@ -0,0 +1,66 @@
+
+
diff --git a/octoprint_octolapse/templates/octolapse_dialog_rendering_in_process.jinja2 b/octoprint_octolapse/templates/octolapse_dialog_rendering_in_process.jinja2
new file mode 100644
index 00000000..6daee418
--- /dev/null
+++ b/octoprint_octolapse/templates/octolapse_dialog_rendering_in_process.jinja2
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/octoprint_octolapse/templates/octolapse_dialog_rendering_unfinished.jinja2 b/octoprint_octolapse/templates/octolapse_dialog_rendering_unfinished.jinja2
new file mode 100644
index 00000000..da597278
--- /dev/null
+++ b/octoprint_octolapse/templates/octolapse_dialog_rendering_unfinished.jinja2
@@ -0,0 +1,96 @@
+
+
+
+
+
+
+
+
diff --git a/octoprint_octolapse/templates/octolapse_dialog_timelapse_files.jinja2 b/octoprint_octolapse/templates/octolapse_dialog_timelapse_files.jinja2
new file mode 100644
index 00000000..dd729999
--- /dev/null
+++ b/octoprint_octolapse/templates/octolapse_dialog_timelapse_files.jinja2
@@ -0,0 +1,91 @@
+
+
+
+
+
+
+
+{% include "octolapse_file_browser.jinja2" %}
+
diff --git a/octoprint_octolapse/templates/octolapse_file_browser.jinja2 b/octoprint_octolapse/templates/octolapse_file_browser.jinja2
new file mode 100644
index 00000000..4a61359b
--- /dev/null
+++ b/octoprint_octolapse/templates/octolapse_file_browser.jinja2
@@ -0,0 +1,101 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/octoprint_octolapse/templates/octolapse_helpers.jinja2 b/octoprint_octolapse/templates/octolapse_helpers.jinja2
new file mode 100644
index 00000000..3d4856b2
--- /dev/null
+++ b/octoprint_octolapse/templates/octolapse_helpers.jinja2
@@ -0,0 +1,241 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/octoprint_octolapse/templates/octolapse_navbar.jinja2 b/octoprint_octolapse/templates/octolapse_navbar.jinja2
index d7a1c8a6..e064d61d 100644
--- a/octoprint_octolapse/templates/octolapse_navbar.jinja2
+++ b/octoprint_octolapse/templates/octolapse_navbar.jinja2
@@ -1,7 +1,7 @@
-
-
|
@@ -84,7 +83,7 @@
-
@@ -149,36 +244,43 @@
Custom Camera Scripts
Gcode Scripts
+
+
+
-
+