diff options
author | Kostas Karmas <konskarm@gmail.com> | 2021-04-20 17:09:54 +0300 |
---|---|---|
committer | Kostas Karmas <konskarm@gmail.com> | 2021-04-20 17:09:54 +0300 |
commit | 5b74090dce6c4d2f268f869b03a7a39c3c4f20f2 (patch) | |
tree | 6f0b6e357219dfff30a11c7f5c8d4e2a56197258 /plugins/DigitalLibrary | |
parent | 196c8913311a8c6a2d033620df0b6d70d4d132b4 (diff) | |
parent | 0db033a6907b83e92645e4ac7b8f7532388745ca (diff) |
Merge branch 'df49' into 4.94.9.0
Diffstat (limited to 'plugins/DigitalLibrary')
37 files changed, 3495 insertions, 0 deletions
diff --git a/plugins/DigitalLibrary/__init__.py b/plugins/DigitalLibrary/__init__.py new file mode 100644 index 0000000000..968aef66ee --- /dev/null +++ b/plugins/DigitalLibrary/__init__.py @@ -0,0 +1,17 @@ +# Copyright (c) 2021 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. + +from UM.MimeTypeDatabase import MimeType, MimeTypeDatabase +from .src import DigitalFactoryFileProvider, DigitalFactoryOutputDevicePlugin, DigitalFactoryController + + +def getMetaData(): + return {} + + +def register(app): + df_controller = DigitalFactoryController.DigitalFactoryController(app) + return { + "file_provider": DigitalFactoryFileProvider.DigitalFactoryFileProvider(df_controller), + "output_device": DigitalFactoryOutputDevicePlugin.DigitalFactoryOutputDevicePlugin(df_controller) + } diff --git a/plugins/DigitalLibrary/plugin.json b/plugins/DigitalLibrary/plugin.json new file mode 100644 index 0000000000..77d9818421 --- /dev/null +++ b/plugins/DigitalLibrary/plugin.json @@ -0,0 +1,8 @@ +{ + "name": "Ultimaker Digital Library", + "author": "Ultimaker B.V.", + "description": "Connects to the Digital Library, allowing Cura to open files from and save files to the Digital Library.", + "version": "1.0.0", + "api": "7.5.0", + "i18n-catalog": "cura" +} diff --git a/plugins/DigitalLibrary/resources/images/arrow_down.svg b/plugins/DigitalLibrary/resources/images/arrow_down.svg new file mode 100644 index 0000000000..d11d6a63fd --- /dev/null +++ b/plugins/DigitalLibrary/resources/images/arrow_down.svg @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="iso-8859-1"?> +<!-- Generator: Adobe Illustrator 25.2.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) --> +<svg version="1.1" id="Artwork" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" + viewBox="0 0 24 24" style="enable-background:new 0 0 24 24;" xml:space="preserve"> +<polygon style="fill:#000E1A;" points="19.7,13.3 18.3,11.9 13,17.2 13,3 11,3 11,17.2 5.7,11.9 4.3,13.3 12,21 "/> +</svg> diff --git a/plugins/DigitalLibrary/resources/images/digital_factory.svg b/plugins/DigitalLibrary/resources/images/digital_factory.svg new file mode 100644 index 0000000000..d8c30f62f2 --- /dev/null +++ b/plugins/DigitalLibrary/resources/images/digital_factory.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 475.96 400.94"><defs><style type="text/css">.cls-1{fill:#061784;}.cls-2{fill:#061884;}.cls-3{fill:#f3f8fe;}.cls-4{fill:#fff;}.cls-5{fill:#dde9fd;}.cls-6{fill:#c5dbfb;}</style></defs><g id="Layer_1" data-name="Layer 1"><path class="cls-1" d="M139,198.79a.75.75,0,0,1-.21,0l-2-.42a1,1,0,1,1,.42-1.95l2,.42A1,1,0,0,1,140,198,1,1,0,0,1,139,198.79Z"/><path class="cls-1" d="M131.07,197.09l-.21,0-4-.86a1,1,0,0,1-.77-1.18,1,1,0,0,1,1.19-.77l4,.85a1,1,0,0,1-.21,2Zm-10-2.14a.75.75,0,0,1-.21,0l-4-.85a1,1,0,0,1,.42-2l4,.86a1,1,0,0,1-.21,2Zm-10-2.13a.76.76,0,0,1-.21,0l-4-.85a1,1,0,0,1,.42-2l4,.86a1,1,0,0,1-.21,2Zm-10-2.14-.21,0-4-.86a1,1,0,1,1,.42-2l4,.86a1,1,0,0,1-.21,2Zm-10-2.14-.21,0-4-.86a1,1,0,1,1,.42-1.95l4,.85a1,1,0,0,1-.21,2Z"/><path class="cls-1" d="M81.3,186.4l-.21,0-2-.42a1,1,0,0,1,.42-2l2,.42a1,1,0,0,1-.21,2Z"/><path class="cls-1" d="M391.69,312.83a1,1,0,0,1-.53-.15l-1.7-1a1,1,0,0,1,1-1.71l1.71,1.05a1,1,0,0,1-.52,1.85Z"/><path class="cls-1" d="M385.22,308.88a1.07,1.07,0,0,1-.52-.14l-3.17-1.94a1,1,0,0,1-.33-1.38,1,1,0,0,1,1.37-.33l3.17,1.94a1,1,0,0,1-.52,1.85ZM377.29,304a1,1,0,0,1-.52-.15L373.6,302a1,1,0,0,1-.33-1.38,1,1,0,0,1,1.37-.33l3.17,1.94a1,1,0,0,1,.34,1.37A1,1,0,0,1,377.29,304Zm-7.93-4.84a1,1,0,0,1-.52-.15l-3.17-1.94a1,1,0,1,1,1.05-1.7l3.17,1.93a1,1,0,0,1-.53,1.86Zm-7.92-4.85a1,1,0,0,1-.52-.14l-3.18-1.94a1,1,0,0,1-.33-1.37,1,1,0,0,1,1.38-.34L362,292.5a1,1,0,0,1,.33,1.38A1,1,0,0,1,361.44,294.35Zm-7.93-4.84a1,1,0,0,1-.52-.15l-3.17-1.93a1,1,0,1,1,1-1.71l3.17,1.94a1,1,0,0,1-.52,1.85Zm-7.93-4.84a1,1,0,0,1-.52-.15l-3.17-1.94a1,1,0,1,1,1-1.7l3.17,1.93a1,1,0,0,1,.33,1.38A1,1,0,0,1,345.58,284.67Z"/><path class="cls-1" d="M337.65,279.83a1,1,0,0,1-.52-.15l-1.71-1a1,1,0,0,1,1.05-1.71l1.7,1a1,1,0,0,1,.33,1.38A1,1,0,0,1,337.65,279.83Z"/><path class="cls-1" d="M180.74,123.92a1,1,0,0,1-.86-.49l-1-1.72a1,1,0,0,1,.35-1.37,1,1,0,0,1,1.37.35l1,1.72a1,1,0,0,1-.35,1.37A1.09,1.09,0,0,1,180.74,123.92Z"/><path class="cls-1" d="M176.42,116.62a1,1,0,0,1-.87-.49l-2.2-3.71a1,1,0,0,1,.35-1.37,1,1,0,0,1,1.37.35l2.2,3.71a1,1,0,0,1-.35,1.37A.93.93,0,0,1,176.42,116.62Zm-5.51-9.29a1,1,0,0,1-.86-.49l-2.2-3.72a1,1,0,1,1,1.72-1l2.2,3.72a1,1,0,0,1-.35,1.37A1,1,0,0,1,170.91,107.33ZM165.41,98a1,1,0,0,1-.86-.49l-2.2-3.72a1,1,0,0,1,.35-1.37,1,1,0,0,1,1.37.35l2.2,3.72a1,1,0,0,1-.35,1.37A1.06,1.06,0,0,1,165.41,98Zm-5.5-9.29a1,1,0,0,1-.87-.49l-2.2-3.72a1,1,0,1,1,1.72-1l2.2,3.72a1,1,0,0,1-.35,1.37A1,1,0,0,1,159.91,88.75Z"/><path class="cls-1" d="M154.4,79.46a1,1,0,0,1-.86-.49l-1-1.73a1,1,0,1,1,1.72-1l1,1.73a1,1,0,0,1-.35,1.37A1,1,0,0,1,154.4,79.46Z"/><path class="cls-1" d="M137.27,275.72a1,1,0,0,1-.51-1.86l1.73-1a1,1,0,1,1,1,1.73l-1.73,1A.93.93,0,0,1,137.27,275.72Z"/><path class="cls-1" d="M93.48,301.24a1,1,0,0,1-.51-1.86l3.5-2a1,1,0,0,1,1,1.73l-3.5,2A1,1,0,0,1,93.48,301.24Zm8.75-5.1a1,1,0,0,1-.5-1.87l3.5-2a1,1,0,0,1,1.37.36,1,1,0,0,1-.36,1.37l-3.5,2A1.06,1.06,0,0,1,102.23,296.14ZM111,291a1,1,0,0,1-.86-.49,1,1,0,0,1,.36-1.37l3.5-2a1,1,0,0,1,1,1.73l-3.5,2A1,1,0,0,1,111,291Zm8.76-5.1a1,1,0,0,1-.5-1.86l3.5-2.05a1,1,0,0,1,1,1.73l-3.51,2A.9.9,0,0,1,119.75,285.93Zm8.76-5.11a1,1,0,0,1-.86-.49A1,1,0,0,1,128,279l3.5-2a1,1,0,0,1,1.37.36,1,1,0,0,1-.36,1.37l-3.51,2A1,1,0,0,1,128.51,280.82Z"/><path class="cls-1" d="M86.49,305.31a1,1,0,0,1-.86-.49,1,1,0,0,1,.36-1.37l1.73-1a1,1,0,0,1,1.36.36,1,1,0,0,1-.36,1.37l-1.73,1A1,1,0,0,1,86.49,305.31Z"/><path class="cls-1" d="M394.66,202.33a1,1,0,0,1-.1-2l1.92-.2a1,1,0,1,1,.21,2l-1.93.2Z"/><path class="cls-1" d="M342.4,207.69a1,1,0,0,1-.1-2l4.18-.43a1,1,0,1,1,.2,2l-4.17.43Zm10.45-1.08a1,1,0,0,1-.1-2l4.18-.43a1,1,0,1,1,.21,2l-4.18.43Zm10.46-1.07a1,1,0,0,1-.11-2l4.18-.43a1,1,0,0,1,1.1.89,1,1,0,0,1-.89,1.1l-4.18.43Zm10.45-1.07a1,1,0,0,1-.1-2l4.17-.43a1,1,0,0,1,1.1.89A1,1,0,0,1,378,204l-4.18.43Zm10.45-1.07a1,1,0,0,1-.1-2l4.18-.43a1,1,0,0,1,1.09.89,1,1,0,0,1-.89,1.1l-4.18.43Z"/><path class="cls-1" d="M334.2,208.53a1,1,0,0,1-.1-2l1.93-.2a1,1,0,1,1,.2,2l-1.92.2Z"/><path class="cls-1" d="M239.41,219h-.07l-4.17-.29,3-3a1.75,1.75,0,1,1,1.29,3.26Z"/><path class="cls-1" d="M302.59,146.94a1,1,0,0,1-.67-.26,1,1,0,0,1-.07-1.41l1.3-1.46a1,1,0,0,1,1.49,1.34l-1.31,1.46A1,1,0,0,1,302.59,146.94Z"/><path class="cls-1" d="M308,140.94a1,1,0,0,1-.67-.26,1,1,0,0,1-.08-1.41l2.72-3a1,1,0,0,1,1.49,1.34l-2.72,3A1,1,0,0,1,308,140.94Zm6.79-7.58a1,1,0,0,1-.67-.25,1,1,0,0,1-.07-1.41l2.72-3a1,1,0,1,1,1.48,1.33l-2.71,3A1,1,0,0,1,314.77,133.36Zm6.8-7.57a1,1,0,0,1-.67-.26,1,1,0,0,1-.08-1.41l2.72-3a1,1,0,0,1,1.49,1.34l-2.72,3A1,1,0,0,1,321.57,125.79Zm6.79-7.57a1,1,0,0,1-.74-1.67l2.72-3a1,1,0,0,1,1.41-.08,1,1,0,0,1,.08,1.42l-2.72,3A1,1,0,0,1,328.36,118.22Zm6.8-7.58a1,1,0,0,1-.74-1.66l2.71-3a1,1,0,0,1,1.42-.08,1,1,0,0,1,.07,1.41l-2.71,3A1,1,0,0,1,335.16,110.64Z"/><path class="cls-1" d="M342,103.07a1,1,0,0,1-.67-.26,1,1,0,0,1-.08-1.41l1.31-1.45a1,1,0,0,1,1.49,1.33l-1.31,1.46A1,1,0,0,1,342,103.07Z"/><g id="_3D-printer" data-name=" 3D-printer"><path class="cls-2" d="M178.4,156.58a2.16,2.16,0,0,1-.78-.15,2,2,0,0,1-1.07-2.62c.08-.18,7.71-18.35,17.43-35.85,13.64-24.56,24.37-35.67,32.81-34,19.06,3.82,12,63,11.13,69.72a2,2,0,0,1-4-.51c3.09-24,3.67-63-7.95-65.29-11.79-2.36-34.32,40.12-45.76,67.45A2,2,0,0,1,178.4,156.58Z"/><path class="cls-2" d="M297.26,166.58a2,2,0,0,1-1.85-1.23C284,138,261.44,95.56,249.65,97.9c-11.62,2.33-11,41.28-8,65.29a2,2,0,1,1-4,.51C236.86,157,229.8,97.8,248.86,94c8.44-1.69,19.18,9.42,32.81,34,9.72,17.5,17.36,35.67,17.43,35.85a2,2,0,0,1-1.07,2.62A2.11,2.11,0,0,1,297.26,166.58Z"/><path class="cls-3" d="M322.94,346.44h0a4.14,4.14,0,0,0,4.14-4.14V157.72a4.15,4.15,0,0,0-4.14-4.14H152.77a4.15,4.15,0,0,0-4.14,4.14v184.6a4.14,4.14,0,0,0,4.14,4.14h8l.29-.3a19.58,19.58,0,0,1,13.2-5h127c9.61,0,15.05,4.95,15.1,5l.29.28Z"/><path class="cls-2" d="M322.94,154.58H152.7a3.15,3.15,0,0,0-3.14,3.14V342.3a3.14,3.14,0,0,0,3.14,3.14h7.65a20.3,20.3,0,0,1,13.91-5.3h127c10.16,0,15.78,5.3,15.78,5.3h5.92a3.14,3.14,0,0,0,3.14-3.14V157.72a3.15,3.15,0,0,0-3.14-3.14m0-2a5.14,5.14,0,0,1,5.14,5.14V342.29h0a5.14,5.14,0,0,1-5.14,5.14h-6.71l-.57-.53c-.05,0-5.25-4.76-14.42-4.76h-127a18.62,18.62,0,0,0-12.51,4.68l-.59.62H152.7a5.14,5.14,0,0,1-5.14-5.14V157.72a5.15,5.15,0,0,1,5.14-5.14H323Z"/><path class="cls-4" d="M303.09,314.43a11.69,11.69,0,0,0,11.68-11.68V174.89a2,2,0,0,0-2-2H162.89a2,2,0,0,0-2,2V302.75a11.68,11.68,0,0,0,11.68,11.68Z"/><path class="cls-3" d="M312.77,173.89H162.89a1,1,0,0,0-1,1V302.75a10.68,10.68,0,0,0,10.68,10.68H303.09a10.69,10.69,0,0,0,10.68-10.68V174.91a1,1,0,0,0-1-1m0-2a3,3,0,0,1,3,3V302.75a12.68,12.68,0,0,1-12.68,12.68H172.56a12.67,12.67,0,0,1-12.68-12.68V174.89a3,3,0,0,1,3-3H312.74Z"/><path class="cls-2" d="M312.77,171.89H162.89a3,3,0,0,0-3,3V302.75a12.67,12.67,0,0,0,12.68,12.68H303.09a12.68,12.68,0,0,0,12.68-12.68V174.91a3,3,0,0,0-3-3m0-2a5,5,0,0,1,5,5V302.75a14.7,14.7,0,0,1-14.68,14.68H172.56a14.69,14.69,0,0,1-14.68-14.68V174.89a5,5,0,0,1,5-5H312.74Z"/><polygon class="cls-5" points="272.82 286.07 255.14 316.68 219.79 316.68 202.12 286.07 219.79 255.46 255.14 255.46 272.82 286.07"/><circle class="cls-2" cx="311.54" cy="322.6" r="1.65"/><circle class="cls-2" cx="163.68" cy="322.6" r="1.65"/><circle class="cls-2" cx="311.61" cy="158.04" r="1.65"/><circle class="cls-2" cx="263.66" cy="158.04" r="1.65"/><circle class="cls-2" cx="213.05" cy="158.04" r="1.65"/><circle class="cls-2" cx="163.75" cy="158.04" r="1.65"/><rect class="cls-2" x="184.64" y="326.52" width="7.01" height="2.47"/><rect class="cls-4" x="212.62" y="309.02" width="50.41" height="26.44" rx="2.34"/><path class="cls-2" d="M260.69,310H215a1.33,1.33,0,0,0-1.33,1.33v21.79a1.31,1.31,0,0,0,1.33,1.31H260.7a1.33,1.33,0,0,0,1.33-1.33V311.35a1.35,1.35,0,0,0-1.34-1.33m3.34,1.33v21.77a3.34,3.34,0,0,1-3.34,3.33H215a3.33,3.33,0,0,1-3.33-3.33V311.35h0A3.33,3.33,0,0,1,215,308h45.74A3.34,3.34,0,0,1,264,311.35Z"/><path class="cls-6" d="M260,312H215.62v20.43H260V312m.66-2a1.35,1.35,0,0,1,1.34,1.33v21.77h0a1.33,1.33,0,0,1-1.34,1.32H214.93a1.31,1.31,0,0,1-1.31-1.33V311.35h0A1.33,1.33,0,0,1,215,310Z"/><path class="cls-2" d="M343.38,347.44H132.26a1,1,0,0,1,0-2H343.38a1,1,0,0,1,0,2Z"/><path class="cls-4" d="M248.68,194.14a3.68,3.68,0,0,0,3.45-2.42l4.4-11.88a3.86,3.86,0,0,0,.23-1.27v-4.76H218.89v4.53a3.49,3.49,0,0,0,.23,1.28l4.37,12a3.66,3.66,0,0,0,3.47,2.47Z"/><path class="cls-2" d="M255.75,174.81H219.89v3.6a2.52,2.52,0,0,0,.17.94l4.38,12a2.65,2.65,0,0,0,2.52,1.79h21.71a2.67,2.67,0,0,0,2.51-1.77l4.4-11.88a2.94,2.94,0,0,0,.17-.93v-3.75m2-2v5.77a5,5,0,0,1-.29,1.62L253,192.06a4.68,4.68,0,0,1-4.39,3.09H227a4.65,4.65,0,0,1-4.4-3.11l-4.38-12a4.47,4.47,0,0,1-.29-1.62v-5.61h39.84Z"/><path class="cls-4" d="M245,196a2.15,2.15,0,0,0,1.56-.67,2.21,2.21,0,0,0,.58-1.59l-.83-13.91a2,2,0,0,0-1.89-1.83H231.33a2,2,0,0,0-2,1.83l-.83,13.9a.53.53,0,0,0,0,.13,2.14,2.14,0,0,0,2.15,2.13Z"/><path class="cls-2" d="M244.42,179H231.31a.93.93,0,0,0-1,.89l-.83,13.9v0a1.14,1.14,0,0,0,1.14,1.14h14.48a1.15,1.15,0,0,0,1.07-1.21l-.82-13.9a1,1,0,0,0-1-.89M244.3,177a3,3,0,0,1,2.94,2.77l.83,13.91v.21a3.13,3.13,0,0,1-3.14,3.11H230.37a3.13,3.13,0,0,1-2.93-3.32l.82-13.91a3,3,0,0,1,3-2.77h13Z"/><path class="cls-4" d="M258.47,178.47a5.1,5.1,0,0,0,0-10.2H217.26a5.1,5.1,0,0,0,0,10.2Z"/><path class="cls-2" d="M258.47,169.27H217.26a4.1,4.1,0,0,0,0,8.2h41.17a4.1,4.1,0,0,0,0-8.2m6.13,4.1a6.09,6.09,0,0,1-6.09,6.1H217.26a6.1,6.1,0,1,1,0-12.2h41.21A6.1,6.1,0,0,1,264.56,173.37Z"/></g><path class="cls-2" d="M50.77,148.17l19.62,11v23.06L50.75,193.56,31.1,182.22v-22.7l19.67-11.35m0-2.3L29.1,158.37v25l21.65,12.5,21.65-12.5V158L50.75,145.87Z"/><polygon class="cls-2" points="52.1 194.88 50.1 194.88 50.1 170.48 29.63 159.76 30.56 157.99 52.1 169.27 52.1 194.88"/><rect class="cls-2" x="49.68" y="163.37" width="22.83" height="2" transform="translate(-71.67 49.81) rotate(-28.82)"/><path class="cls-2" d="M71.1,183.87a1,1,0,0,1-.49-.12L69.3,183a1,1,0,0,1,1-1.75l1.31.72a1,1,0,0,1,.39,1.36A1,1,0,0,1,71.1,183.87Z"/><path class="cls-2" d="M65.44,180.76a1,1,0,0,1-.49-.12l-2.17-1.19a1,1,0,0,1,1-1.76l2.17,1.2a1,1,0,0,1,.39,1.36A1,1,0,0,1,65.44,180.76Zm-6.52-3.58a.91.91,0,0,1-.48-.13l-2.18-1.19a1,1,0,0,1,1-1.75l2.17,1.19a1,1,0,0,1-.48,1.88Z"/><path class="cls-2" d="M52.41,173.59a1,1,0,0,1-.49-.12l-1.82-1v-2.1a1,1,0,0,1,2,0v.91l.79.44a1,1,0,0,1,.39,1.36A1,1,0,0,1,52.41,173.59Z"/><path class="cls-2" d="M51.1,166.12a1,1,0,0,1-1-1v-2.63a1,1,0,0,1,2,0v2.63A1,1,0,0,1,51.1,166.12Zm0-7.88a1,1,0,0,1-1-1v-2.63a1,1,0,0,1,2,0v2.63A1,1,0,0,1,51.1,158.24Z"/><path class="cls-2" d="M51.1,150.37a1,1,0,0,1-1-1v-1.5a1,1,0,0,1,2,0v1.5A1,1,0,0,1,51.1,150.37Z"/><path class="cls-2" d="M30.1,183.87a1,1,0,0,1-.47-1.88l1.33-.7a1,1,0,1,1,.93,1.77l-1.33.7A1,1,0,0,1,30.1,183.87Z"/><path class="cls-2" d="M36,180.77a1,1,0,0,1-.47-1.88l2.3-1.2a1,1,0,0,1,1.35.42,1,1,0,0,1-.43,1.35l-2.29,1.2A1,1,0,0,1,36,180.77Zm6.88-3.6a1,1,0,0,1-.46-1.88l2.29-1.2a1,1,0,0,1,.93,1.77l-2.3,1.2A.92.92,0,0,1,42.89,177.17Z"/><path class="cls-2" d="M49.77,173.57a1,1,0,0,1-.47-1.88l1.33-.7a1,1,0,1,1,.93,1.77l-1.33.7A1,1,0,0,1,49.77,173.57Z"/><path class="cls-2" d="M148,28.2l12.15,7.49V62.43l-22.68,13.1-12.18-7.89V41.31L148,28.2m0-2.33L123.24,40.16V68.73l14.12,9.14L162.1,63.58v-29L148,25.87Z"/><polygon class="cls-2" points="138.76 76.73 136.76 76.73 136.76 48.75 123.9 41.61 124.87 39.86 138.76 47.58 138.76 76.73"/><rect class="cls-2" x="136.14" y="40.87" width="26.08" height="2" transform="translate(-1.69 77.16) rotate(-28.84)"/><path class="cls-2" d="M160.61,64a1,1,0,0,1-.64-.23l-1.15-1a1,1,0,1,1,1.28-1.53l1.15,1a1,1,0,0,1-.64,1.76Z"/><path class="cls-2" d="M155.49,59.71a1,1,0,0,1-.65-.23l-2-1.68a1,1,0,0,1,1.29-1.53l2,1.68a1,1,0,0,1-.64,1.76Z"/><path class="cls-2" d="M149.53,54.69a1,1,0,0,1-.65-.23l-1.49-1.26v-2a1,1,0,1,1,2,0v1l.78.66a1,1,0,0,1-.64,1.76Z"/><path class="cls-2" d="M148.39,46.83a1,1,0,0,1-1-1v-2.7a1,1,0,1,1,2,0v2.7A1,1,0,0,1,148.39,46.83Zm0-8.09a1,1,0,0,1-1-1V35a1,1,0,1,1,2,0v2.7A1,1,0,0,1,148.39,38.74Z"/><path class="cls-2" d="M148.39,30.66a1,1,0,0,1-1-1v-1.5a1,1,0,1,1,2,0v1.5A1,1,0,0,1,148.39,30.66Z"/><path class="cls-2" d="M124.39,69.16a1,1,0,0,1-.55-1.83l1.26-.82a1,1,0,0,1,1.09,1.67l-1.26.82A1,1,0,0,1,124.39,69.16Z"/><path class="cls-2" d="M131,64.89a1,1,0,0,1-.85-.45,1,1,0,0,1,.31-1.39l2.68-1.72A1,1,0,1,1,134.24,63l-2.68,1.73A1.08,1.08,0,0,1,131,64.89Zm8-5.17a1,1,0,0,1-.55-1.84l2.69-1.73a1,1,0,1,1,1.08,1.68l-2.68,1.73A1,1,0,0,1,139.07,59.72Z"/><path class="cls-2" d="M147.12,54.54a1,1,0,0,1-.54-1.84l1.27-.81a1,1,0,1,1,1.07,1.69l-1.27.81A1,1,0,0,1,147.12,54.54Z"/><path class="cls-2" d="M358.21,48.61l20.27,41-19,11-19.1-11,17.86-40.92m-.14-4.82-20.3,46.5,21.65,12.5,21.65-12.5Z"/><rect class="cls-2" x="357.95" y="48.86" width="2" height="53.03" transform="translate(-2.23 11.55) rotate(-1.84)"/><path class="cls-2" d="M379.8,90.87a1,1,0,0,1-.49-.12L378,90a1,1,0,0,1,1-1.75l1.31.73a1,1,0,0,1,.39,1.36A1,1,0,0,1,379.8,90.87Z"/><path class="cls-2" d="M374.22,87.75a1,1,0,0,1-.49-.12l-2.14-1.2a1,1,0,1,1,1-1.74l2.14,1.19a1,1,0,0,1,.39,1.36A1,1,0,0,1,374.22,87.75Zm-6.41-3.57a1,1,0,0,1-.49-.13l-2.14-1.19a1,1,0,1,1,1-1.75l2.14,1.19a1,1,0,0,1-.48,1.88Z"/><path class="cls-2" d="M361.41,80.6a1,1,0,0,1-.49-.12l-1.31-.73a1,1,0,1,1,1-1.75l1.31.73a1,1,0,0,1,.39,1.36A1,1,0,0,1,361.41,80.6Z"/><path class="cls-2" d="M338.8,90.87a1,1,0,0,1-.5-1.86l1.3-.74a1,1,0,1,1,1,1.73l-1.3.74A1,1,0,0,1,338.8,90.87Z"/><path class="cls-2" d="M344.28,87.75a1,1,0,0,1-.5-1.86l2.09-1.19a1,1,0,0,1,1,1.73l-2.09,1.19A1,1,0,0,1,344.28,87.75Zm6.25-3.56a1,1,0,0,1-.5-1.87l2.09-1.19a1,1,0,0,1,1.36.37,1,1,0,0,1-.37,1.37L351,84.06A1.13,1.13,0,0,1,350.53,84.19Z"/><path class="cls-2" d="M356.8,80.61a1,1,0,0,1-.5-1.86l1.3-.74a1,1,0,1,1,1,1.73l-1.3.74A1,1,0,0,1,356.8,80.61Z"/><path class="cls-2" d="M408.7,294.1,425.53,297l.84,33.88-17.62,9.69-17.06-9.38,17-37.11m-1.16-2.23L389.1,332.06l19.65,10.81,19.65-10.81-.91-36.73-20-3.46Z"/><rect class="cls-2" x="392.9" y="317.67" width="49.86" height="2" transform="translate(-27.41 597.77) rotate(-69.41)"/><path class="cls-2" d="M427.22,332.63a1,1,0,0,1-.47-.11l-1.32-.7a1,1,0,0,1,.93-1.77l1.32.7a1,1,0,0,1-.46,1.88Z"/><path class="cls-2" d="M419.8,328.69a1,1,0,0,1-.47-.11L416.28,327a1,1,0,0,1,.94-1.77l3,1.62a1,1,0,0,1-.47,1.88Z"/><path class="cls-2" d="M410.67,323.83a1,1,0,0,1-.47-.11l-1.33-.71a1,1,0,1,1,.94-1.77l1.33.71a1,1,0,0,1-.47,1.88Z"/><path class="cls-2" d="M390,332.63a1,1,0,0,1-.49-1.87l1.3-.73a1,1,0,0,1,1,1.75l-1.3.73A1.11,1.11,0,0,1,390,332.63Z"/><path class="cls-2" d="M397.64,328.34a1,1,0,0,1-.49-1.87l3.16-1.78a1,1,0,1,1,1,1.74l-3.16,1.79A1.11,1.11,0,0,1,397.64,328.34Z"/><path class="cls-2" d="M407.13,323a1,1,0,0,1-.5-1.86l.8-.46v-.92a1,1,0,0,1,2,0v2.09l-1.81,1A1,1,0,0,1,407.13,323Z"/><path class="cls-2" d="M408.43,314.59a1,1,0,0,1-1-1v-3.08a1,1,0,0,1,2,0v3.08A1,1,0,0,1,408.43,314.59Zm0-9.25a1,1,0,0,1-1-1v-3.08a1,1,0,0,1,2,0v3.08A1,1,0,0,1,408.43,305.34Z"/><path class="cls-2" d="M408.43,296.1a1,1,0,0,1-1-1v-1.5a1,1,0,0,1,2,0v1.5A1,1,0,0,1,408.43,296.1Z"/><path class="cls-2" d="M58.67,322.86a1,1,0,0,1,0-2c1,0,2-.05,3-.1a1,1,0,1,1,.1,2c-1,.06-2,.09-3,.11Zm-6-.1h-.05q-1.56-.08-3-.21a1,1,0,0,1-.91-1.08,1,1,0,0,1,1.09-.91c.95.08,1.94.15,2.95.21a1,1,0,0,1,1,1A1,1,0,0,1,52.67,322.76Zm15-.55a1,1,0,0,1-1-.87,1,1,0,0,1,.86-1.12c1-.14,2-.3,2.91-.47a1,1,0,1,1,.37,2c-1,.18-2,.35-3,.49Zm-23.91-.45-.18,0c-1.07-.2-2.08-.42-3-.66a1,1,0,1,1,.5-1.94c.88.23,1.85.45,2.86.64a1,1,0,0,1,.8,1.16A1,1,0,0,1,43.73,321.76Zm32.66-1.62a1,1,0,0,1-.37-1.92,9.85,9.85,0,0,0,2.36-1.29,1,1,0,0,1,1.4.14,1,1,0,0,1-.14,1.41,11.06,11.06,0,0,1-2.88,1.59A1,1,0,0,1,76.39,320.14ZM35.3,318.8a1,1,0,0,1-.61-.21,3.47,3.47,0,0,1-1.59-2.72,2.59,2.59,0,0,1,.19-1,1,1,0,0,1,1.86.73.59.59,0,0,0,0,.25c0,.33.29.73.81,1.14a1,1,0,0,1-.61,1.79Zm42.38-4.6a1,1,0,0,1-.46-.12A16.94,16.94,0,0,0,74.6,313a1,1,0,0,1-.65-1.26,1,1,0,0,1,1.26-.65,20.1,20.1,0,0,1,2.94,1.19,1,1,0,0,1,.42,1.35A1,1,0,0,1,77.68,314.2Zm-38.53-1.08a1,1,0,0,1-.31-1.95c.89-.29,1.9-.56,3-.81a1,1,0,0,1,1.2.76,1,1,0,0,1-.76,1.19c-1,.24-2,.49-2.81.76A.79.79,0,0,1,39.15,313.12Zm29.91-1.37H68.9c-.93-.15-1.92-.28-2.93-.4a1,1,0,0,1-.88-1.1,1,1,0,0,1,1.1-.88c1,.11,2.06.25,3,.4a1,1,0,0,1-.15,2ZM48,311.37a1,1,0,0,1-.11-2c1-.11,2-.2,3-.28a1,1,0,1,1,.14,2c-1,.07-2,.16-3,.27Zm12.14-.44h0c-1,0-2-.06-3-.06H57a1,1,0,1,1,0-2h.15q1.55,0,3,.06a1,1,0,0,1,0,2Z"/><path class="cls-2" d="M57.34,340.87a24.24,24.24,0,1,1,24.24-24.24A24.26,24.26,0,0,1,57.34,340.87Zm0-46.48a22.24,22.24,0,1,0,22.24,22.24A22.26,22.26,0,0,0,57.34,294.39Z"/><path class="cls-2" d="M58.49,340.25a1,1,0,0,1-.67-1.74,6.47,6.47,0,0,0,1.38-2.17,1,1,0,0,1,1.83.82A8.77,8.77,0,0,1,59.17,340,1.05,1.05,0,0,1,58.49,340.25Zm-4.35-2.36a1,1,0,0,1-.91-.58,21.51,21.51,0,0,1-1.08-2.95,1,1,0,0,1,.68-1.25,1,1,0,0,1,1.24.68,18.91,18.91,0,0,0,1,2.68,1,1,0,0,1-.49,1.33A1.06,1.06,0,0,1,54.14,337.89ZM61.83,332a.68.68,0,0,1-.2,0,1,1,0,0,1-.78-1.18c.19-.91.35-1.89.5-2.9a1,1,0,0,1,2,.29c-.15,1.05-.33,2.06-.52,3A1,1,0,0,1,61.83,332Zm-10-2.8a1,1,0,0,1-1-.86c-.14-1-.26-2-.37-3a1,1,0,1,1,2-.2c.1,1,.22,2,.36,2.93A1,1,0,0,1,52,329.2Zm11.07-6.12h-.06a1,1,0,0,1-.94-1.06c.06-1,.1-1.95.13-3a1,1,0,0,1,1-1,1,1,0,0,1,1,1c0,1-.07,2.05-.13,3A1,1,0,0,1,62.94,323.09Zm-11.81-2.85a1,1,0,0,1-1-1q0-1.18,0-2.4v-.64a1,1,0,0,1,1-1,1,1,0,0,1,1,1v.63q0,1.19,0,2.34a1,1,0,0,1-1,1ZM63,314.09a1,1,0,0,1-1-1c-.05-1-.11-2-.19-3a1,1,0,0,1,2-.16q.12,1.49.18,3a1,1,0,0,1-1,1Zm-11.66-2.85h-.08a1,1,0,0,1-.92-1.08c.08-1,.18-2.05.3-3a1,1,0,1,1,2,.24c-.12.94-.21,1.93-.29,2.94A1,1,0,0,1,51.35,311.24Zm10.74-6.1a1,1,0,0,1-1-.83c-.18-1-.38-2-.6-2.88a1,1,0,0,1,2-.47c.23.94.43,2,.62,3a1,1,0,0,1-.82,1.15Zm-9.42-2.8a.9.9,0,0,1-.23,0,1,1,0,0,1-.74-1.21c.26-1.08.55-2.09.86-3a1,1,0,1,1,1.88.66c-.28.83-.55,1.76-.8,2.79A1,1,0,0,1,52.67,302.34Zm6.71-5.74a1,1,0,0,1-.84-.46c-.51-.81-1-1.27-1.44-1.27A1,1,0,0,1,56,294a1,1,0,0,1,.88-1.09h.25c1.14,0,2.2.74,3.12,2.2a1,1,0,0,1-.31,1.38A1,1,0,0,1,59.38,296.6Z"/><path class="cls-2" d="M422.71,170.73c-11,0-19.94-5.8-19.94-12.93s9-12.94,19.94-12.94,19.93,5.8,19.93,12.94S433.7,170.73,422.71,170.73Zm0-23.87c-9.89,0-17.94,4.91-17.94,10.94s8.05,10.93,17.94,10.93,17.93-4.91,17.93-10.93S432.59,146.86,422.71,146.86Z"/><path class="cls-2" d="M422.71,210.73c-11,0-19.94-5.8-19.94-12.94a1,1,0,1,1,2,0c0,6,8.05,10.94,17.94,10.94s17.93-4.91,17.93-10.94a1,1,0,1,1,2,0C442.64,204.93,433.7,210.73,422.71,210.73Z"/><path class="cls-2" d="M403.77,198.79a1,1,0,0,1-1-1,8.81,8.81,0,0,1,.65-3.29,1,1,0,1,1,1.85.74,6.89,6.89,0,0,0-.5,2.55A1,1,0,0,1,403.77,198.79Zm37.78-1.12a1,1,0,0,1-1-.85,7.56,7.56,0,0,0-.86-2.48,1,1,0,0,1,1.75-1,9.34,9.34,0,0,1,1.08,3.16,1,1,0,0,1-.84,1.13Zm-33.5-6.43a1,1,0,0,1-.61-1.79,18.17,18.17,0,0,1,2.67-1.7,1,1,0,0,1,.93,1.77,17.3,17.3,0,0,0-2.38,1.51A1,1,0,0,1,408.05,191.24Zm28.4-.65a1,1,0,0,1-.55-.17,19.08,19.08,0,0,0-2.49-1.38,1,1,0,1,1,.83-1.81,19.64,19.64,0,0,1,2.76,1.52,1,1,0,0,1-.55,1.84Zm-20.26-3a1,1,0,0,1-.22-2,27.52,27.52,0,0,1,3.06-.53,1,1,0,0,1,1.11.87,1,1,0,0,1-.87,1.11,28.7,28.7,0,0,0-2.85.5A.85.85,0,0,1,416.19,187.59Zm11.92-.24h-.18a27.13,27.13,0,0,0-2.87-.39,1,1,0,1,1,.16-2c1,.09,2.07.22,3.08.41a1,1,0,0,1-.19,2Z"/><path class="cls-2" d="M403.77,198.94a1,1,0,0,1-1-1V157.8a1,1,0,1,1,2,0v40.14A1,1,0,0,1,403.77,198.94Z"/><path class="cls-2" d="M441.64,198.39a1,1,0,0,1-1-1V157.25a1,1,0,0,1,2,0v40.14A1,1,0,0,1,441.64,198.39Z"/><path class="cls-2" d="M423.24,197.3a1,1,0,0,1-1-1v-3a1,1,0,1,1,2,0v3A1,1,0,0,1,423.24,197.3Zm0-9a1,1,0,0,1-1-1v-3a1,1,0,1,1,2,0v3A1,1,0,0,1,423.24,188.3Zm0-9a1,1,0,0,1-1-1v-3a1,1,0,1,1,2,0v3A1,1,0,0,1,423.24,179.3Zm0-9a1,1,0,0,1-1-1v-3a1,1,0,1,1,2,0v3A1,1,0,0,1,423.24,170.3Zm0-9a1,1,0,0,1-1-1v-3a1,1,0,1,1,2,0v3A1,1,0,0,1,423.24,161.3Z"/></g></svg>
\ No newline at end of file diff --git a/plugins/DigitalLibrary/resources/images/placeholder.svg b/plugins/DigitalLibrary/resources/images/placeholder.svg new file mode 100644 index 0000000000..cc674a4b38 --- /dev/null +++ b/plugins/DigitalLibrary/resources/images/placeholder.svg @@ -0,0 +1,3 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48"> + <path d="M24,44,7,33.4V14.6L24,4,41,14.6V33.4ZM9,32.3l15,9.3,15-9.3V15.7L24,6.4,9,15.7Z"/> +</svg> diff --git a/plugins/DigitalLibrary/resources/images/update.svg b/plugins/DigitalLibrary/resources/images/update.svg new file mode 100644 index 0000000000..4a1aecab81 --- /dev/null +++ b/plugins/DigitalLibrary/resources/images/update.svg @@ -0,0 +1,9 @@ +<?xml version="1.0" encoding="iso-8859-1"?> +<!-- Generator: Adobe Illustrator 25.2.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) --> +<svg version="1.1" id="Artwork" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" + viewBox="0 0 24 24" style="enable-background:new 0 0 24 24;" xml:space="preserve"> +<path d="M12,19.6L4.4,12L7,9.4V12h2V6H3v2h2.6L3.7,9.9c-1.2,1.2-1.2,3.1,0,4.2l6.2,6.2c1.2,1.2,3.1,1.2,4.2,0l0.6-0.6l-1.4-1.4 + L12,19.6z"/> +<path d="M20.3,9.9l-6.2-6.2c-1.2-1.2-3.1-1.2-4.2,0L9.3,4.3l1.4,1.4L12,4.4l7.6,7.6L17,14.6V12h-2v6h6v-2h-2.6l1.9-1.9 + C21.5,12.9,21.5,11.1,20.3,9.9z"/> +</svg> diff --git a/plugins/DigitalLibrary/resources/qml/CreateNewProjectPopup.qml b/plugins/DigitalLibrary/resources/qml/CreateNewProjectPopup.qml new file mode 100644 index 0000000000..75fb8d5811 --- /dev/null +++ b/plugins/DigitalLibrary/resources/qml/CreateNewProjectPopup.qml @@ -0,0 +1,159 @@ +// Copyright (C) 2021 Ultimaker B.V. + +import QtQuick 2.10 +import QtQuick.Window 2.2 +import QtQuick.Controls 1.4 as OldControls // TableView doesn't exist in the QtQuick Controls 2.x in 5.10, so use the old one +import QtQuick.Controls 2.3 +import QtQuick.Controls.Styles 1.4 + +import UM 1.2 as UM +import Cura 1.6 as Cura + +import DigitalFactory 1.0 as DF + + +Popup +{ + id: base + + padding: UM.Theme.getSize("default_margin").width + + closePolicy: Popup.CloseOnEscape + focus: true + modal: true + background: Cura.RoundedRectangle + { + cornerSide: Cura.RoundedRectangle.Direction.All + border.color: UM.Theme.getColor("lining") + border.width: UM.Theme.getSize("default_lining").width + radius: UM.Theme.getSize("default_radius").width + width: parent.width + height: parent.height + color: UM.Theme.getColor("main_background") + } + + Connections + { + target: manager + + function onCreatingNewProjectStatusChanged(status) + { + if (status == DF.RetrievalStatus.Success) + { + base.close(); + } + } + } + + onOpened: + { + newProjectNameTextField.text = "" + newProjectNameTextField.focus = true + } + + Label + { + id: createNewLibraryProjectLabel + text: "Create new Library project" + font: UM.Theme.getFont("medium") + color: UM.Theme.getColor("small_button_text") + anchors + { + top: parent.top + left: parent.left + right: parent.right + } + } + + Label + { + id: projectNameLabel + text: "Project Name" + font: UM.Theme.getFont("default") + color: UM.Theme.getColor("text") + anchors + { + top: createNewLibraryProjectLabel.bottom + topMargin: UM.Theme.getSize("default_margin").width + left: parent.left + right: parent.right + } + } + + Cura.TextField + { + id: newProjectNameTextField + width: parent.width + anchors + { + top: projectNameLabel.bottom + topMargin: UM.Theme.getSize("thin_margin").width + left: parent.left + right: parent.right + } + validator: RegExpValidator + { + regExp: /^[^\\\/\*\?\|\[\]]{0,96}$/ + } + + text: PrintInformation.jobName + font: UM.Theme.getFont("default") + placeholderText: "Enter a name for your new project." + onAccepted: + { + if (verifyProjectCreationButton.enabled) + { + verifyProjectCreationButton.clicked() + } + } + } + + Label + { + id: errorWhileCreatingProjectLabel + text: manager.projectCreationErrorText + width: parent.width + wrapMode: Text.WordWrap + font: UM.Theme.getFont("default") + color: UM.Theme.getColor("error") + visible: manager.creatingNewProjectStatus == DF.RetrievalStatus.Failed + anchors + { + top: newProjectNameTextField.bottom + left: parent.left + right: parent.right + } + } + + Cura.SecondaryButton + { + id: cancelProjectCreationButton + + anchors.bottom: parent.bottom + anchors.left: parent.left + + text: "Cancel" + + onClicked: + { + base.close() + } + busy: false + } + + Cura.PrimaryButton + { + id: verifyProjectCreationButton + + anchors.bottom: parent.bottom + anchors.right: parent.right + text: "Create" + enabled: newProjectNameTextField.text != "" && !busy + + onClicked: + { + manager.createLibraryProjectAndSetAsPreselected(newProjectNameTextField.text) + } + busy: manager.creatingNewProjectStatus == DF.RetrievalStatus.InProgress + } +} diff --git a/plugins/DigitalLibrary/resources/qml/DigitalFactoryOpenDialog.qml b/plugins/DigitalLibrary/resources/qml/DigitalFactoryOpenDialog.qml new file mode 100644 index 0000000000..58958e0069 --- /dev/null +++ b/plugins/DigitalLibrary/resources/qml/DigitalFactoryOpenDialog.qml @@ -0,0 +1,61 @@ +// Copyright (C) 2021 Ultimaker B.V. + +import QtQuick 2.10 +import QtQuick.Window 2.2 +import QtQuick.Controls 1.4 as OldControls // TableView doesn't exist in the QtQuick Controls 2.x in 5.10, so use the old one +import QtQuick.Controls 2.3 +import QtQuick.Controls.Styles 1.4 + +import UM 1.2 as UM +import Cura 1.6 as Cura + +import DigitalFactory 1.0 as DF + +Window +{ + id: digitalFactoryOpenDialogBase + title: "Open file from Library" + + modality: Qt.ApplicationModal + width: 800 * screenScaleFactor + height: 600 * screenScaleFactor + minimumWidth: 800 * screenScaleFactor + minimumHeight: 600 * screenScaleFactor + + Shortcut + { + sequence: "Esc" + onActivated: digitalFactoryOpenDialogBase.close() + } + color: UM.Theme.getColor("main_background") + + SelectProjectPage + { + visible: manager.selectedProjectIndex == -1 + createNewProjectButtonVisible: false + } + + OpenProjectFilesPage + { + visible: manager.selectedProjectIndex >= 0 + onOpenFilePressed: digitalFactoryOpenDialogBase.close() + } + + + BusyIndicator + { + // Shows up while Cura is waiting to receive the user's projects from the digital factory library + id: retrievingProjectsBusyIndicator + + anchors { + verticalCenter: parent.verticalCenter + horizontalCenter: parent.horizontalCenter + } + + width: parent.width / 4 + height: width + visible: manager.retrievingProjectsStatus == DF.RetrievalStatus.InProgress + running: visible + palette.dark: UM.Theme.getColor("text") + } +} diff --git a/plugins/DigitalLibrary/resources/qml/DigitalFactorySaveDialog.qml b/plugins/DigitalLibrary/resources/qml/DigitalFactorySaveDialog.qml new file mode 100644 index 0000000000..6d870d0c78 --- /dev/null +++ b/plugins/DigitalLibrary/resources/qml/DigitalFactorySaveDialog.qml @@ -0,0 +1,62 @@ +// Copyright (C) 2021 Ultimaker B.V. + +import QtQuick 2.10 +import QtQuick.Window 2.2 +import QtQuick.Controls 1.4 as OldControls // TableView doesn't exist in the QtQuick Controls 2.x in 5.10, so use the old one +import QtQuick.Controls 2.3 +import QtQuick.Controls.Styles 1.4 + +import UM 1.2 as UM +import Cura 1.6 as Cura + +import DigitalFactory 1.0 as DF + +Window +{ + id: digitalFactorySaveDialogBase + title: "Save Cura project to Library" + + modality: Qt.ApplicationModal + width: 800 * screenScaleFactor + height: 600 * screenScaleFactor + minimumWidth: 800 * screenScaleFactor + minimumHeight: 600 * screenScaleFactor + + Shortcut + { + sequence: "Esc" + onActivated: digitalFactorySaveDialogBase.close() + } + color: UM.Theme.getColor("main_background") + + SelectProjectPage + { + visible: manager.selectedProjectIndex == -1 + createNewProjectButtonVisible: true + } + + SaveProjectFilesPage + { + visible: manager.selectedProjectIndex >= 0 + onSavePressed: digitalFactorySaveDialogBase.close() + onSelectDifferentProjectPressed: manager.clearProjectSelection() + } + + + BusyIndicator + { + // Shows up while Cura is waiting to receive the user's projects from the digital factory library + id: retrievingProjectsBusyIndicator + + anchors { + verticalCenter: parent.verticalCenter + horizontalCenter: parent.horizontalCenter + } + + width: parent.width / 4 + height: width + visible: manager.retrievingProjectsStatus == DF.RetrievalStatus.InProgress + running: visible + palette.dark: UM.Theme.getColor("text") + } +} diff --git a/plugins/DigitalLibrary/resources/qml/LoadMoreProjectsCard.qml b/plugins/DigitalLibrary/resources/qml/LoadMoreProjectsCard.qml new file mode 100644 index 0000000000..45a0c6886d --- /dev/null +++ b/plugins/DigitalLibrary/resources/qml/LoadMoreProjectsCard.qml @@ -0,0 +1,129 @@ +// Copyright (C) 2021 Ultimaker B.V. +import QtQuick 2.10 +import QtQuick.Controls 2.3 + +import UM 1.2 as UM +import Cura 1.6 as Cura + +Cura.RoundedRectangle +{ + id: base + cornerSide: Cura.RoundedRectangle.Direction.All + border.color: UM.Theme.getColor("lining") + border.width: UM.Theme.getSize("default_lining").width + radius: UM.Theme.getSize("default_radius").width + signal clicked() + property var hasMoreProjectsToLoad + enabled: hasMoreProjectsToLoad + color: UM.Theme.getColor("main_background") + + MouseArea + { + id: cardMouseArea + anchors.fill: parent + hoverEnabled: true + } + + Row + { + id: projectInformationRow + anchors.horizontalCenter: parent.horizontalCenter + anchors.verticalCenter: parent.verticalCenter + + UM.RecolorImage + { + id: projectImage + anchors.verticalCenter: parent.verticalCenter + width: UM.Theme.getSize("section").height + height: width + color: UM.Theme.getColor("text_link") + source: "../images/arrow_down.svg" + } + + Label + { + id: displayNameLabel + anchors.verticalCenter: parent.verticalCenter + text: "Load more projects" + color: UM.Theme.getColor("text_link") + font: UM.Theme.getFont("medium_bold") + } + } + + Component.onCompleted: + { + cardMouseArea.clicked.connect(base.clicked) + } + + states: + [ + State + { + name: "canLoadMoreProjectsAndHovered"; + when: base.hasMoreProjectsToLoad && cardMouseArea.containsMouse + PropertyChanges + { + target: projectImage + color: UM.Theme.getColor("text_link") + source: "../images/arrow_down.svg" + } + PropertyChanges + { + target: displayNameLabel + color: UM.Theme.getColor("text_link") + text: "Load more projects" + } + PropertyChanges + { + target: base + color: UM.Theme.getColor("action_button_hovered") + } + }, + + State + { + name: "canLoadMoreProjectsAndNotHovered"; + when: base.hasMoreProjectsToLoad && !cardMouseArea.containsMouse + PropertyChanges + { + target: projectImage + color: UM.Theme.getColor("text_link") + source: "../images/arrow_down.svg" + } + PropertyChanges + { + target: displayNameLabel + color: UM.Theme.getColor("text_link") + text: "Load more projects" + } + PropertyChanges + { + target: base + color: UM.Theme.getColor("main_background") + } + }, + + State + { + name: "noMoreProjectsToLoad" + when: !base.hasMoreProjectsToLoad + PropertyChanges + { + target: projectImage + color: UM.Theme.getColor("action_button_disabled_text") + source: "../images/update.svg" + } + PropertyChanges + { + target: displayNameLabel + color: UM.Theme.getColor("action_button_disabled_text") + text: "No more projects to load" + } + PropertyChanges + { + target: base + color: UM.Theme.getColor("action_button_disabled") + } + } + ] +}
\ No newline at end of file diff --git a/plugins/DigitalLibrary/resources/qml/OpenProjectFilesPage.qml b/plugins/DigitalLibrary/resources/qml/OpenProjectFilesPage.qml new file mode 100644 index 0000000000..e1918b3da7 --- /dev/null +++ b/plugins/DigitalLibrary/resources/qml/OpenProjectFilesPage.qml @@ -0,0 +1,198 @@ +// Copyright (C) 2021 Ultimaker B.V. + +import QtQuick 2.10 +import QtQuick.Window 2.2 +import QtQuick.Controls 1.4 as OldControls // TableView doesn't exist in the QtQuick Controls 2.x in 5.10, so use the old one +import QtQuick.Controls 2.3 +import QtQuick.Controls.Styles 1.4 + +import UM 1.2 as UM +import Cura 1.6 as Cura + +import DigitalFactory 1.0 as DF + + +Item +{ + id: base + width: parent.width + height: parent.height + + property var fileModel: manager.digitalFactoryFileModel + + signal openFilePressed() + signal selectDifferentProjectPressed() + + anchors + { + fill: parent + margins: UM.Theme.getSize("default_margin").width + } + + ProjectSummaryCard + { + id: projectSummaryCard + + anchors.top: parent.top + + property var selectedItem: manager.digitalFactoryProjectModel.getItem(manager.selectedProjectIndex) + + imageSource: selectedItem.thumbnailUrl || "../images/placeholder.svg" + projectNameText: selectedItem.displayName || "" + projectUsernameText: selectedItem.username || "" + projectLastUpdatedText: "Last updated: " + selectedItem.lastUpdated + cardMouseAreaEnabled: false + } + + Rectangle + { + id: projectFilesContent + width: parent.width + anchors.top: projectSummaryCard.bottom + anchors.topMargin: UM.Theme.getSize("default_margin").width + anchors.bottom: selectDifferentProjectButton.top + anchors.bottomMargin: UM.Theme.getSize("default_margin").width + + color: UM.Theme.getColor("main_background") + border.width: UM.Theme.getSize("default_lining").width + border.color: UM.Theme.getColor("lining") + + + Cura.TableView + { + id: filesTableView + anchors.fill: parent + model: manager.digitalFactoryFileModel + visible: model.count != 0 && manager.retrievingFileStatus != DF.RetrievalStatus.InProgress + selectionMode: OldControls.SelectionMode.SingleSelection + + OldControls.TableViewColumn + { + id: fileNameColumn + role: "fileName" + title: "Name" + width: Math.round(filesTableView.width / 3) + } + + OldControls.TableViewColumn + { + id: usernameColumn + role: "username" + title: "Uploaded by" + width: Math.round(filesTableView.width / 3) + } + + OldControls.TableViewColumn + { + role: "uploadedAt" + title: "Uploaded at" + } + + Connections + { + target: filesTableView.selection + function onSelectionChanged() + { + let newSelection = []; + filesTableView.selection.forEach(function(rowIndex) { newSelection.push(rowIndex); }); + manager.setSelectedFileIndices(newSelection); + } + } + } + + Label + { + id: emptyProjectLabel + anchors.horizontalCenter: parent.horizontalCenter + anchors.verticalCenter: parent.verticalCenter + text: "Select a project to view its files." + font: UM.Theme.getFont("default") + color: UM.Theme.getColor("setting_category_text") + + Connections + { + target: manager + function onSelectedProjectIndexChanged(newProjectIndex) + { + emptyProjectLabel.visible = (newProjectIndex == -1) + } + } + } + + Label + { + id: noFilesInProjectLabel + anchors.horizontalCenter: parent.horizontalCenter + anchors.verticalCenter: parent.verticalCenter + visible: (manager.digitalFactoryFileModel.count == 0 && !emptyProjectLabel.visible && !retrievingFilesBusyIndicator.visible) + text: "No supported files in this project." + font: UM.Theme.getFont("default") + color: UM.Theme.getColor("setting_category_text") + } + + BusyIndicator + { + // Shows up while Cura is waiting to receive the files of a project from the digital factory library + id: retrievingFilesBusyIndicator + + anchors + { + verticalCenter: parent.verticalCenter + horizontalCenter: parent.horizontalCenter + } + + width: parent.width / 4 + height: width + visible: manager.retrievingFilesStatus == DF.RetrievalStatus.InProgress + running: visible + palette.dark: UM.Theme.getColor("text") + } + + Connections + { + target: manager.digitalFactoryFileModel + + function onItemsChanged() + { + // Make sure no files are selected when the file model changes + filesTableView.currentRow = -1 + filesTableView.selection.clear() + } + } + } + Cura.SecondaryButton + { + id: selectDifferentProjectButton + + anchors.bottom: parent.bottom + anchors.left: parent.left + text: "Change Library project" + + onClicked: + { + manager.clearProjectSelection() + } + busy: false + } + + Cura.PrimaryButton + { + id: openFilesButton + + anchors.bottom: parent.bottom + anchors.right: parent.right + text: "Open" + enabled: filesTableView.selection.count > 0 + onClicked: + { + manager.openSelectedFiles() + } + busy: false + } + + Component.onCompleted: + { + openFilesButton.clicked.connect(base.openFilePressed) + selectDifferentProjectButton.clicked.connect(base.selectDifferentProjectPressed) + } +}
\ No newline at end of file diff --git a/plugins/DigitalLibrary/resources/qml/ProjectSummaryCard.qml b/plugins/DigitalLibrary/resources/qml/ProjectSummaryCard.qml new file mode 100644 index 0000000000..4374b2f998 --- /dev/null +++ b/plugins/DigitalLibrary/resources/qml/ProjectSummaryCard.qml @@ -0,0 +1,92 @@ +// Copyright (C) 2021 Ultimaker B.V. +import QtQuick 2.10 +import QtQuick.Controls 2.3 + +import UM 1.2 as UM +import Cura 1.6 as Cura + +Cura.RoundedRectangle +{ + id: base + width: parent.width + height: projectImage.height + 2 * UM.Theme.getSize("default_margin").width + cornerSide: Cura.RoundedRectangle.Direction.All + border.color: UM.Theme.getColor("lining") + border.width: UM.Theme.getSize("default_lining").width + radius: UM.Theme.getSize("default_radius").width + color: UM.Theme.getColor("main_background") + signal clicked() + property alias imageSource: projectImage.source + property alias projectNameText: displayNameLabel.text + property alias projectUsernameText: usernameLabel.text + property alias projectLastUpdatedText: lastUpdatedLabel.text + property alias cardMouseAreaEnabled: cardMouseArea.enabled + + onVisibleChanged: color = UM.Theme.getColor("main_background") + + MouseArea + { + id: cardMouseArea + anchors.fill: parent + hoverEnabled: true + onEntered: base.color = UM.Theme.getColor("action_button_hovered") + onExited: base.color = UM.Theme.getColor("main_background") + onClicked: base.clicked() + } + Row + { + id: projectInformationRow + width: parent.width + padding: UM.Theme.getSize("default_margin").width + spacing: UM.Theme.getSize("default_margin").width + + Image + { + id: projectImage + anchors.verticalCenter: parent.verticalCenter + width: UM.Theme.getSize("toolbox_thumbnail_small").width + height: Math.round(width * 3/4) + sourceSize.width: width + sourceSize.height: height + fillMode: Image.PreserveAspectFit + mipmap: true + } + Column + { + id: projectLabelsColumn + height: projectImage.height + width: parent.width - x - UM.Theme.getSize("default_margin").width + anchors.verticalCenter: parent.verticalCenter + + Label + { + id: displayNameLabel + width: parent.width + height: Math.round(parent.height / 3) + elide: Text.ElideRight + color: UM.Theme.getColor("text") + font: UM.Theme.getFont("default_bold") + } + + Label + { + id: usernameLabel + width: parent.width + height: Math.round(parent.height / 3) + elide: Text.ElideRight + color: UM.Theme.getColor("small_button_text") + font: UM.Theme.getFont("default") + } + + Label + { + id: lastUpdatedLabel + width: parent.width + height: Math.round(parent.height / 3) + elide: Text.ElideRight + color: UM.Theme.getColor("small_button_text") + font: UM.Theme.getFont("default") + } + } + } +}
\ No newline at end of file diff --git a/plugins/DigitalLibrary/resources/qml/SaveProjectFilesPage.qml b/plugins/DigitalLibrary/resources/qml/SaveProjectFilesPage.qml new file mode 100644 index 0000000000..03bd655957 --- /dev/null +++ b/plugins/DigitalLibrary/resources/qml/SaveProjectFilesPage.qml @@ -0,0 +1,259 @@ +// Copyright (C) 2021 Ultimaker B.V. + +import QtQuick 2.10 +import QtQuick.Window 2.2 +import QtQuick.Controls 1.4 as OldControls // TableView doesn't exist in the QtQuick Controls 2.x in 5.10, so use the old one +import QtQuick.Controls 2.3 +import QtQuick.Controls.Styles 1.4 + +import UM 1.2 as UM +import Cura 1.6 as Cura + +import DigitalFactory 1.0 as DF + + +Item +{ + id: base + width: parent.width + height: parent.height + property var fileModel: manager.digitalFactoryFileModel + + signal savePressed() + signal selectDifferentProjectPressed() + + anchors + { + fill: parent + margins: UM.Theme.getSize("default_margin").width + } + + ProjectSummaryCard + { + id: projectSummaryCard + + anchors.top: parent.top + + property var selectedItem: manager.digitalFactoryProjectModel.getItem(manager.selectedProjectIndex) + + imageSource: selectedItem.thumbnailUrl || "../images/placeholder.svg" + projectNameText: selectedItem.displayName || "" + projectUsernameText: selectedItem.username || "" + projectLastUpdatedText: "Last updated: " + selectedItem.lastUpdated + cardMouseAreaEnabled: false + } + + Label + { + id: fileNameLabel + anchors.top: projectSummaryCard.bottom + anchors.topMargin: UM.Theme.getSize("default_margin").height + text: "Cura project name" + font: UM.Theme.getFont("medium") + color: UM.Theme.getColor("text") + } + + + Cura.TextField + { + id: dfFilenameTextfield + width: parent.width + anchors.left: parent.left + anchors.top: fileNameLabel.bottom + anchors.topMargin: UM.Theme.getSize("thin_margin").height + validator: RegExpValidator + { + regExp: /^[^\\\/\*\?\|\[\]]{0,96}$/ + } + + text: PrintInformation.jobName + font: UM.Theme.getFont("medium") + placeholderText: "Enter the name of the file." + onAccepted: { if (saveButton.enabled) {saveButton.clicked()}} + } + + + Rectangle + { + id: projectFilesContent + width: parent.width + anchors.top: dfFilenameTextfield.bottom + anchors.topMargin: UM.Theme.getSize("wide_margin").height + anchors.bottom: selectDifferentProjectButton.top + anchors.bottomMargin: UM.Theme.getSize("default_margin").width + + color: UM.Theme.getColor("main_background") + border.width: UM.Theme.getSize("default_lining").width + border.color: UM.Theme.getColor("lining") + + + Cura.TableView + { + id: filesTableView + anchors.fill: parent + model: manager.digitalFactoryFileModel + visible: model.count != 0 && manager.retrievingFileStatus != DF.RetrievalStatus.InProgress + selectionMode: OldControls.SelectionMode.NoSelection + + OldControls.TableViewColumn + { + id: fileNameColumn + role: "fileName" + title: "@tableViewColumn:title", "Name" + width: Math.round(filesTableView.width / 3) + } + + OldControls.TableViewColumn + { + id: usernameColumn + role: "username" + title: "Uploaded by" + width: Math.round(filesTableView.width / 3) + } + + OldControls.TableViewColumn + { + role: "uploadedAt" + title: "Uploaded at" + } + } + + Label + { + id: emptyProjectLabel + anchors.horizontalCenter: parent.horizontalCenter + anchors.verticalCenter: parent.verticalCenter + text: "Select a project to view its files." + font: UM.Theme.getFont("default") + color: UM.Theme.getColor("setting_category_text") + + Connections + { + target: manager + function onSelectedProjectIndexChanged() + { + emptyProjectLabel.visible = (manager.newProjectIndex == -1) + } + } + } + + Label + { + id: noFilesInProjectLabel + anchors.horizontalCenter: parent.horizontalCenter + anchors.verticalCenter: parent.verticalCenter + visible: (manager.digitalFactoryFileModel.count == 0 && !emptyProjectLabel.visible && !retrievingFilesBusyIndicator.visible) + text: "No supported files in this project." + font: UM.Theme.getFont("default") + color: UM.Theme.getColor("setting_category_text") + } + + BusyIndicator + { + // Shows up while Cura is waiting to receive the files of a project from the digital factory library + id: retrievingFilesBusyIndicator + + anchors + { + verticalCenter: parent.verticalCenter + horizontalCenter: parent.horizontalCenter + } + + width: parent.width / 4 + height: width + visible: manager.retrievingFilesStatus == DF.RetrievalStatus.InProgress + running: visible + palette.dark: UM.Theme.getColor("text") + } + + Connections + { + target: manager.digitalFactoryFileModel + + function onItemsChanged() + { + // Make sure no files are selected when the file model changes + filesTableView.currentRow = -1 + filesTableView.selection.clear() + } + } + } + Cura.SecondaryButton + { + id: selectDifferentProjectButton + + anchors.bottom: parent.bottom + anchors.left: parent.left + text: "Change Library project" + + onClicked: + { + manager.selectedProjectIndex = -1 + } + busy: false + } + + Cura.PrimaryButton + { + id: saveButton + + anchors.bottom: parent.bottom + anchors.right: parent.right + text: "Save" + enabled: (asProjectCheckbox.checked || asSlicedCheckbox.checked) && dfFilenameTextfield.text != "" + + onClicked: + { + let saveAsFormats = []; + if (asProjectCheckbox.checked) + { + saveAsFormats.push("3mf"); + } + if (asSlicedCheckbox.checked) + { + saveAsFormats.push("ufp"); + } + manager.saveFileToSelectedProject(dfFilenameTextfield.text, saveAsFormats); + } + busy: false + } + + Row + { + + id: saveAsFormatRow + anchors.verticalCenter: saveButton.verticalCenter + anchors.right: saveButton.left + anchors.rightMargin: UM.Theme.getSize("thin_margin").height + width: childrenRect.width + spacing: UM.Theme.getSize("default_margin").width + + Cura.CheckBox + { + id: asProjectCheckbox + height: UM.Theme.getSize("checkbox").height + anchors.verticalCenter: parent.verticalCenter + checked: true + text: "Save Cura project" + font: UM.Theme.getFont("medium") + } + + Cura.CheckBox + { + id: asSlicedCheckbox + height: UM.Theme.getSize("checkbox").height + anchors.verticalCenter: parent.verticalCenter + + enabled: UM.Backend.state == UM.Backend.Done + checked: UM.Backend.state == UM.Backend.Done + text: "Save print file" + font: UM.Theme.getFont("medium") + } + } + + Component.onCompleted: + { + saveButton.clicked.connect(base.savePressed) + selectDifferentProjectButton.clicked.connect(base.selectDifferentProjectPressed) + } +} diff --git a/plugins/DigitalLibrary/resources/qml/SelectProjectPage.qml b/plugins/DigitalLibrary/resources/qml/SelectProjectPage.qml new file mode 100644 index 0000000000..2de0e78cc7 --- /dev/null +++ b/plugins/DigitalLibrary/resources/qml/SelectProjectPage.qml @@ -0,0 +1,202 @@ +// Copyright (C) 2021 Ultimaker B.V. + +import QtQuick 2.10 +import QtQuick.Window 2.2 +import QtQuick.Controls 1.4 as OldControls // TableView doesn't exist in the QtQuick Controls 2.x in 5.10, so use the old one +import QtQuick.Controls 2.3 +import QtQuick.Controls.Styles 1.4 + +import UM 1.2 as UM +import Cura 1.6 as Cura + +import DigitalFactory 1.0 as DF + + +Item +{ + id: base + + width: parent.width + height: parent.height + property alias createNewProjectButtonVisible: createNewProjectButton.visible + + anchors + { + top: parent.top + bottom: parent.bottom + left: parent.left + right: parent.right + margins: UM.Theme.getSize("default_margin").width + } + + Label + { + id: selectProjectLabel + + text: "Select Project" + font: UM.Theme.getFont("medium") + color: UM.Theme.getColor("small_button_text") + anchors.top: parent.top + anchors.left: parent.left + visible: projectListContainer.visible + } + + Cura.SecondaryButton + { + id: createNewProjectButton + + anchors.verticalCenter: selectProjectLabel.verticalCenter + anchors.right: parent.right + text: "New Library project" + + onClicked: + { + createNewProjectPopup.open() + } + busy: manager.creatingNewProjectStatus == DF.RetrievalStatus.InProgress + } + + Item + { + id: noLibraryProjectsContainer + anchors + { + top: parent.top + bottom: parent.bottom + left: parent.left + right: parent.right + } + visible: manager.digitalFactoryProjectModel.count == 0 && (manager.retrievingProjectsStatus == DF.RetrievalStatus.Success || manager.retrievingProjectsStatus == DF.RetrievalStatus.Failed) + + Column + { + anchors.centerIn: parent + spacing: UM.Theme.getSize("thin_margin").height + Image + { + id: digitalFactoryImage + anchors.horizontalCenter: parent.horizontalCenter + source: "../images/digital_factory.svg" + fillMode: Image.PreserveAspectFit + width: parent.width - 2 * UM.Theme.getSize("thick_margin").width + sourceSize.width: width + sourceSize.height: height + } + + Label + { + id: noLibraryProjectsLabel + anchors.horizontalCenter: parent.horizontalCenter + text: "It appears that you don't have any projects in the Library yet." + font: UM.Theme.getFont("medium") + } + + Cura.TertiaryButton + { + id: visitDigitalLibraryButton + anchors.horizontalCenter: parent.horizontalCenter + text: "Visit Digital Library" + onClicked: Qt.openUrlExternally(CuraApplication.ultimakerDigitalFactoryUrl + "/app/library") + } + } + } + + Item + { + id: projectListContainer + anchors + { + top: selectProjectLabel.bottom + topMargin: UM.Theme.getSize("default_margin").height + bottom: parent.bottom + left: parent.left + right: parent.right + } + visible: manager.digitalFactoryProjectModel.count > 0 + + // Use a flickable and a column with a repeater instead of a ListView in a ScrollView, because the ScrollView cannot + // have additional children (aside from the view inside it), which wouldn't allow us to add the LoadMoreProjectsCard + // in it. + Flickable + { + id: flickableView + clip: true + contentWidth: parent.width + contentHeight: projectsListView.implicitHeight + anchors.fill: parent + + ScrollBar.vertical: ScrollBar + { + // Vertical ScrollBar, styled similarly to the scrollBar in the settings panel + id: verticalScrollBar + visible: flickableView.contentHeight > flickableView.height + + background: Rectangle + { + implicitWidth: UM.Theme.getSize("scrollbar").width + radius: Math.round(implicitWidth / 2) + color: UM.Theme.getColor("scrollbar_background") + } + + contentItem: Rectangle + { + id: scrollViewHandle + implicitWidth: UM.Theme.getSize("scrollbar").width + radius: Math.round(implicitWidth / 2) + + color: verticalScrollBar.pressed ? UM.Theme.getColor("scrollbar_handle_down") : verticalScrollBar.hovered ? UM.Theme.getColor("scrollbar_handle_hover") : UM.Theme.getColor("scrollbar_handle") + Behavior on color { ColorAnimation { duration: 50; } } + } + } + + Column + { + id: projectsListView + width: verticalScrollBar.visible ? parent.width - verticalScrollBar.width - UM.Theme.getSize("default_margin").width : parent.width + anchors.top: parent.top + spacing: UM.Theme.getSize("narrow_margin").width + + Repeater + { + model: manager.digitalFactoryProjectModel + delegate: ProjectSummaryCard + { + id: projectSummaryCard + imageSource: model.thumbnailUrl || "../images/placeholder.svg" + projectNameText: model.displayName + projectUsernameText: model.username + projectLastUpdatedText: "Last updated: " + model.lastUpdated + + onClicked: + { + manager.selectedProjectIndex = index + } + } + } + + LoadMoreProjectsCard + { + id: loadMoreProjectsCard + height: UM.Theme.getSize("toolbox_thumbnail_small").height + width: parent.width + visible: manager.digitalFactoryProjectModel.count > 0 + hasMoreProjectsToLoad: manager.hasMoreProjectsToLoad + + onClicked: + { + manager.loadMoreProjects() + } + } + } + } + } + + CreateNewProjectPopup + { + id: createNewProjectPopup + width: 400 * screenScaleFactor + height: 220 * screenScaleFactor + x: Math.round((parent.width - width) / 2) + y: Math.round((parent.height - height) / 2) + } +}
\ No newline at end of file diff --git a/plugins/DigitalLibrary/src/BaseModel.py b/plugins/DigitalLibrary/src/BaseModel.py new file mode 100644 index 0000000000..5bfd14feba --- /dev/null +++ b/plugins/DigitalLibrary/src/BaseModel.py @@ -0,0 +1,74 @@ +# Copyright (c) 2021 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. + +from datetime import datetime, timezone +from typing import TypeVar, Dict, List, Any, Type, Union + + +# Type variable used in the parse methods below, which should be a subclass of BaseModel. +T = TypeVar("T", bound="BaseModel") + + +class BaseModel: + + def __init__(self, **kwargs) -> None: + self.__dict__.update(kwargs) + self.validate() + + # Validates the model, raising an exception if the model is invalid. + def validate(self) -> None: + pass + + def __eq__(self, other): + """Checks whether the two models are equal. + + :param other: The other model. + :return: True if they are equal, False if they are different. + """ + return type(self) == type(other) and self.toDict() == other.toDict() + + def __ne__(self, other) -> bool: + """Checks whether the two models are different. + + :param other: The other model. + :return: True if they are different, False if they are the same. + """ + return type(self) != type(other) or self.toDict() != other.toDict() + + def toDict(self) -> Dict[str, Any]: + """Converts the model into a serializable dictionary""" + + return self.__dict__ + + @staticmethod + def parseModel(model_class: Type[T], values: Union[T, Dict[str, Any]]) -> T: + """Parses a single model. + + :param model_class: The model class. + :param values: The value of the model, which is usually a dictionary, but may also be already parsed. + :return: An instance of the model_class given. + """ + if isinstance(values, dict): + return model_class(**values) + return values + + @classmethod + def parseModels(cls, model_class: Type[T], values: List[Union[T, Dict[str, Any]]]) -> List[T]: + """Parses a list of models. + + :param model_class: The model class. + :param values: The value of the list. Each value is usually a dictionary, but may also be already parsed. + :return: A list of instances of the model_class given. + """ + return [cls.parseModel(model_class, value) for value in values] + + @staticmethod + def parseDate(date: Union[str, datetime]) -> datetime: + """Parses the given date string. + + :param date: The date to parse. + :return: The parsed date. + """ + if isinstance(date, datetime): + return date + return datetime.strptime(date, "%Y-%m-%dT%H:%M:%S.%fZ").replace(tzinfo=timezone.utc) diff --git a/plugins/DigitalLibrary/src/CloudError.py b/plugins/DigitalLibrary/src/CloudError.py new file mode 100644 index 0000000000..3c3f5eece2 --- /dev/null +++ b/plugins/DigitalLibrary/src/CloudError.py @@ -0,0 +1,31 @@ +# Copyright (c) 2021 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +from typing import Dict, Optional, Any + +from .BaseModel import BaseModel + + +class CloudError(BaseModel): + """Class representing errors generated by the servers, according to the JSON-API standard.""" + + def __init__(self, id: str, code: str, title: str, http_status: str, detail: Optional[str] = None, + meta: Optional[Dict[str, Any]] = None, **kwargs) -> None: + """Creates a new error object. + + :param id: Unique identifier for this particular occurrence of the problem. + :param title: A short, human-readable summary of the problem that SHOULD NOT change from occurrence to occurrence + of the problem, except for purposes of localization. + :param code: An application-specific error code, expressed as a string value. + :param detail: A human-readable explanation specific to this occurrence of the problem. Like title, this field's + value can be localized. + :param http_status: The HTTP status code applicable to this problem, converted to string. + :param meta: Non-standard meta-information about the error, depending on the error code. + """ + + self.id = id + self.code = code + self.http_status = http_status + self.title = title + self.detail = detail + self.meta = meta + super().__init__(**kwargs) diff --git a/plugins/DigitalLibrary/src/DFFileExportAndUploadManager.py b/plugins/DigitalLibrary/src/DFFileExportAndUploadManager.py new file mode 100644 index 0000000000..c991f96633 --- /dev/null +++ b/plugins/DigitalLibrary/src/DFFileExportAndUploadManager.py @@ -0,0 +1,361 @@ +# Copyright (c) 2021 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +import json +import threading +from typing import List, Dict, Any, Callable, Union, Optional + +from PyQt5.QtCore import QUrl +from PyQt5.QtGui import QDesktopServices +from PyQt5.QtNetwork import QNetworkReply + +from UM.FileHandler.FileHandler import FileHandler +from UM.Logger import Logger +from UM.Message import Message +from UM.Scene.SceneNode import SceneNode +from cura.CuraApplication import CuraApplication +from .DFLibraryFileUploadRequest import DFLibraryFileUploadRequest +from .DFLibraryFileUploadResponse import DFLibraryFileUploadResponse +from .DFPrintJobUploadRequest import DFPrintJobUploadRequest +from .DFPrintJobUploadResponse import DFPrintJobUploadResponse +from .DigitalFactoryApiClient import DigitalFactoryApiClient +from .ExportFileJob import ExportFileJob + + +class DFFileExportAndUploadManager: + """ + Class responsible for exporting the scene and uploading the exported data to the Digital Factory Library. Since 3mf + and UFP files may need to be uploaded at the same time, this class keeps a single progress and success message for + both files and updates those messages according to the progress of both the file job uploads. + """ + def __init__(self, file_handlers: Dict[str, FileHandler], + nodes: List[SceneNode], + library_project_id: str, + library_project_name: str, + file_name: str, + formats: List[str], + on_upload_error: Callable[[], Any], + on_upload_success: Callable[[], Any], + on_upload_finished: Callable[[], Any] , + on_upload_progress: Callable[[int], Any]) -> None: + + self._file_handlers = file_handlers # type: Dict[str, FileHandler] + self._nodes = nodes # type: List[SceneNode] + self._library_project_id = library_project_id # type: str + self._library_project_name = library_project_name # type: str + self._file_name = file_name # type: str + + self._formats = formats # type: List[str] + self._api = DigitalFactoryApiClient(application = CuraApplication.getInstance(), on_error = lambda error: Logger.log("e", str(error))) + + # Functions of the parent class that should be called based on the upload process output + self._on_upload_error = on_upload_error + self._on_upload_success = on_upload_success + self._on_upload_finished = on_upload_finished + self._on_upload_progress = on_upload_progress + + # Lock used for updating the progress message (since the progress is changed by two parallel upload jobs) or + # show the success message (once both upload jobs are done) + self._message_lock = threading.Lock() + + self._file_upload_job_metadata = self.initializeFileUploadJobMetadata() # type: Dict[str, Dict[str, Any]] + + self.progress_message = Message( + title = "Uploading...", + text = "Uploading files to '{}'".format(self._library_project_name), + progress = -1, + lifetime = 0, + dismissable = False, + use_inactivity_timer = False + ) + + self._generic_success_message = Message( + text = "Your {} uploaded to '{}'.".format("file was" if len(self._file_upload_job_metadata) <= 1 else "files were", self._library_project_name), + title = "Upload successful", + lifetime = 0, + ) + self._generic_success_message.addAction( + "open_df_project", + "Open project", + "open-folder", "Open the project containing the file in Digital Library" + ) + self._generic_success_message.actionTriggered.connect(self._onMessageActionTriggered) + + def _onCuraProjectFileExported(self, job: ExportFileJob) -> None: + """Handler for when the DF Library workspace file (3MF) has been created locally. + + It can now be sent over the Digital Factory API. + """ + if not job.getOutput(): + self._onJobExportError(job.getFileName()) + return + self._file_upload_job_metadata[job.getFileName()]["export_job_output"] = job.getOutput() + request = DFLibraryFileUploadRequest( + content_type = job.getMimeType(), + file_name = job.getFileName(), + file_size = len(job.getOutput()), + library_project_id = self._library_project_id + ) + self._api.requestUpload3MF(request, on_finished = self._uploadFileData, on_error = self._onRequestUploadCuraProjectFileFailed) + + def _onPrintFileExported(self, job: ExportFileJob) -> None: + """Handler for when the DF Library print job file (UFP) has been created locally. + + It can now be sent over the Digital Factory API. + """ + if not job.getOutput(): + self._onJobExportError(job.getFileName()) + return + self._file_upload_job_metadata[job.getFileName()]["export_job_output"] = job.getOutput() + request = DFPrintJobUploadRequest( + content_type = job.getMimeType(), + job_name = job.getFileName(), + file_size = len(job.getOutput()), + library_project_id = self._library_project_id + ) + self._api.requestUploadUFP(request, on_finished = self._uploadFileData, on_error = self._onRequestUploadPrintFileFailed) + + def _uploadFileData(self, file_upload_response: Union[DFLibraryFileUploadResponse, DFPrintJobUploadResponse]) -> None: + """Uploads the exported file data after the file or print job upload has been registered at the Digital Factory + Library API. + + :param file_upload_response: The response received from the Digital Factory Library API. + """ + if isinstance(file_upload_response, DFLibraryFileUploadResponse): + file_name = file_upload_response.file_name + elif isinstance(file_upload_response, DFPrintJobUploadResponse): + file_name = file_upload_response.job_name + else: + Logger.log("e", "Wrong response type received. Aborting uploading file to the Digital Library") + return + with self._message_lock: + self.progress_message.show() + self._file_upload_job_metadata[file_name]["file_upload_response"] = file_upload_response + job_output = self._file_upload_job_metadata[file_name]["export_job_output"] + + with self._message_lock: + self._file_upload_job_metadata[file_name]["upload_status"] = "uploading" + + self._api.uploadExportedFileData(file_upload_response, + job_output, + on_finished = self._onFileUploadFinished, + on_success = self._onUploadSuccess, + on_progress = self._onUploadProgress, + on_error = self._onUploadError) + + def _onUploadProgress(self, filename: str, progress: int) -> None: + """ + Updates the progress message according to the total progress of the two files and displays it to the user. It is + made thread-safe with a lock, since the progress can be updated by two separate upload jobs + + :param filename: The name of the file for which we have progress (including the extension). + :param progress: The progress percentage + """ + with self._message_lock: + self._file_upload_job_metadata[filename]["upload_progress"] = progress + self._file_upload_job_metadata[filename]["upload_status"] = "uploading" + total_progress = self.getTotalProgress() + self.progress_message.setProgress(total_progress) + self.progress_message.show() + self._on_upload_progress(progress) + + def _onUploadSuccess(self, filename: str) -> None: + """ + Sets the upload status to success and the progress of the file with the given filename to 100%. This function is + should be called only if the file has uploaded all of its data successfully (i.e. no error occurred during the + upload process). + + :param filename: The name of the file that was uploaded successfully (including the extension). + """ + with self._message_lock: + self._file_upload_job_metadata[filename]["upload_status"] = "success" + self._file_upload_job_metadata[filename]["upload_progress"] = 100 + self._on_upload_success() + + def _onFileUploadFinished(self, filename: str) -> None: + """ + Callback that makes sure the correct messages are displayed according to the statuses of the individual jobs. + + This function is called whenever an upload job has finished, regardless if it had errors or was successful. + Both jobs have to have finished for the messages to show. + + :param filename: The name of the file that has finished uploading (including the extension). + """ + with self._message_lock: + + # All files have finished their uploading process + if all([(file_upload_job["upload_progress"] == 100 and file_upload_job["upload_status"] != "uploading") for file_upload_job in self._file_upload_job_metadata.values()]): + + # Reset and hide the progress message + self.progress_message.setProgress(-1) + self.progress_message.hide() + + # All files were successfully uploaded. + if all([(file_upload_job["upload_status"] == "success") for file_upload_job in self._file_upload_job_metadata.values()]): + # Show a single generic success message for all files + self._generic_success_message.show() + else: # One or more files failed to upload. + # Show individual messages for each file, according to their statuses + for filename, upload_job_metadata in self._file_upload_job_metadata.items(): + if upload_job_metadata["upload_status"] == "success": + upload_job_metadata["file_upload_success_message"].show() + else: + upload_job_metadata["file_upload_failed_message"].show() + + # Call the parent's finished function + self._on_upload_finished() + + def _onJobExportError(self, filename: str) -> None: + """ + Displays an appropriate message when the process to export a file fails. + + :param filename: The name of the file that failed to be exported (including the extension). + """ + Logger.log("d", "Error while exporting file '{}'".format(filename)) + with self._message_lock: + # Set the progress to 100% when the upload job fails, to avoid having the progress message stuck + self._file_upload_job_metadata[filename]["upload_status"] = "failed" + self._file_upload_job_metadata[filename]["upload_progress"] = 100 + self._file_upload_job_metadata[filename]["file_upload_failed_message"] = Message( + text = "Failed to export the file '{}'. The upload process is aborted.".format(filename), + title = "Export error", + lifetime = 0 + ) + self._on_upload_error() + self._onFileUploadFinished(filename) + + def _onRequestUploadCuraProjectFileFailed(self, reply: "QNetworkReply", network_error: "QNetworkReply.NetworkError") -> None: + """ + Displays an appropriate message when the request to upload the Cura project file (.3mf) to the Digital Library fails. + This means that something went wrong with the initial request to create a "file" entry in the digital library. + """ + reply_string = bytes(reply.readAll()).decode() + filename_3mf = self._file_name + ".3mf" + Logger.log("d", "An error occurred while uploading the Cura project file '{}' to the Digital Library project '{}': {}".format(filename_3mf, self._library_project_id, reply_string)) + with self._message_lock: + # Set the progress to 100% when the upload job fails, to avoid having the progress message stuck + self._file_upload_job_metadata[filename_3mf]["upload_status"] = "failed" + self._file_upload_job_metadata[filename_3mf]["upload_progress"] = 100 + + human_readable_error = self.extractErrorTitle(reply_string) + self._file_upload_job_metadata[filename_3mf]["file_upload_failed_message"] = Message( + text = "Failed to upload the file '{}' to '{}'. {}".format(filename_3mf, self._library_project_name, human_readable_error), + title = "File upload error", + lifetime = 0 + ) + self._on_upload_error() + self._onFileUploadFinished(filename_3mf) + + def _onRequestUploadPrintFileFailed(self, reply: "QNetworkReply", network_error: "QNetworkReply.NetworkError") -> None: + """ + Displays an appropriate message when the request to upload the print file (.ufp) to the Digital Library fails. + This means that something went wrong with the initial request to create a "file" entry in the digital library. + """ + reply_string = bytes(reply.readAll()).decode() + filename_ufp = self._file_name + ".ufp" + Logger.log("d", "An error occurred while uploading the print job file '{}' to the Digital Library project '{}': {}".format(filename_ufp, self._library_project_id, reply_string)) + with self._message_lock: + # Set the progress to 100% when the upload job fails, to avoid having the progress message stuck + self._file_upload_job_metadata[filename_ufp]["upload_status"] = "failed" + self._file_upload_job_metadata[filename_ufp]["upload_progress"] = 100 + + human_readable_error = self.extractErrorTitle(reply_string) + self._file_upload_job_metadata[filename_ufp]["file_upload_failed_message"] = Message( + title = "File upload error", + text = "Failed to upload the file '{}' to '{}'. {}".format(filename_ufp, self._library_project_name, human_readable_error), + lifetime = 0 + ) + self._on_upload_error() + self._onFileUploadFinished(filename_ufp) + + @staticmethod + def extractErrorTitle(reply_body: Optional[str]) -> str: + error_title = "" + if reply_body: + reply_dict = json.loads(reply_body) + if "errors" in reply_dict and len(reply_dict["errors"]) >= 1 and "title" in reply_dict["errors"][0]: + error_title = reply_dict["errors"][0]["title"] # type: str + return error_title + + def _onUploadError(self, filename: str, reply: "QNetworkReply", error: "QNetworkReply.NetworkError") -> None: + """ + Displays the given message if uploading the mesh has failed due to a generic error (i.e. lost connection). + If one of the two files fail, this error function will set its progress as finished, to make sure that the + progress message doesn't get stuck. + + :param filename: The name of the file that failed to upload (including the extension). + """ + reply_string = bytes(reply.readAll()).decode() + Logger.log("d", "Error while uploading '{}' to the Digital Library project '{}'. Reply: {}".format(filename, self._library_project_id, reply_string)) + with self._message_lock: + # Set the progress to 100% when the upload job fails, to avoid having the progress message stuck + self._file_upload_job_metadata[filename]["upload_status"] = "failed" + self._file_upload_job_metadata[filename]["upload_progress"] = 100 + human_readable_error = self.extractErrorTitle(reply_string) + self._file_upload_job_metadata[filename]["file_upload_failed_message"] = Message( + title = "File upload error", + text = "Failed to upload the file '{}' to '{}'. {}".format(self._file_name, self._library_project_name, human_readable_error), + lifetime = 0 + ) + + self._on_upload_error() + + def getTotalProgress(self) -> int: + """ + Returns the total upload progress of all the upload jobs + + :return: The average progress percentage + """ + return int(sum([file_upload_job["upload_progress"] for file_upload_job in self._file_upload_job_metadata.values()]) / len(self._file_upload_job_metadata.values())) + + def _onMessageActionTriggered(self, message, action): + if action == "open_df_project": + project_url = "{}/app/library/project/{}?wait_for_new_files=true".format(CuraApplication.getInstance().ultimakerDigitalFactoryUrl, self._library_project_id) + QDesktopServices.openUrl(QUrl(project_url)) + message.hide() + + def initializeFileUploadJobMetadata(self) -> Dict[str, Any]: + metadata = {} + if "3mf" in self._formats and "3mf" in self._file_handlers and self._file_handlers["3mf"]: + filename_3mf = self._file_name + ".3mf" + metadata[filename_3mf] = { + "export_job_output" : None, + "upload_progress" : -1, + "upload_status" : "", + "file_upload_response": None, + "file_upload_success_message": Message( + text = "'{}' was uploaded to '{}'.".format(filename_3mf, self._library_project_name), + title = "Upload successful", + lifetime = 0, + ), + "file_upload_failed_message": Message( + text = "Failed to upload the file '{}' to '{}'.".format(filename_3mf, self._library_project_name), + title = "File upload error", + lifetime = 0 + ) + } + job_3mf = ExportFileJob(self._file_handlers["3mf"], self._nodes, self._file_name, "3mf") + job_3mf.finished.connect(self._onCuraProjectFileExported) + job_3mf.start() + + if "ufp" in self._formats and "ufp" in self._file_handlers and self._file_handlers["ufp"]: + filename_ufp = self._file_name + ".ufp" + metadata[filename_ufp] = { + "export_job_output" : None, + "upload_progress" : -1, + "upload_status" : "", + "file_upload_response": None, + "file_upload_success_message": Message( + text = "'{}' was uploaded to '{}'.".format(filename_ufp, self._library_project_name), + title = "Upload successful", + lifetime = 0, + ), + "file_upload_failed_message": Message( + text = "Failed to upload the file '{}' to '{}'.".format(filename_ufp, self._library_project_name), + title = "File upload error", + lifetime = 0 + ) + } + job_ufp = ExportFileJob(self._file_handlers["ufp"], self._nodes, self._file_name, "ufp") + job_ufp.finished.connect(self._onPrintFileExported) + job_ufp.start() + return metadata diff --git a/plugins/DigitalLibrary/src/DFFileUploader.py b/plugins/DigitalLibrary/src/DFFileUploader.py new file mode 100644 index 0000000000..9c5356255e --- /dev/null +++ b/plugins/DigitalLibrary/src/DFFileUploader.py @@ -0,0 +1,147 @@ +# Copyright (c) 2021 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. + +from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply +from typing import Callable, Any, cast, Optional, Union + +from UM.Logger import Logger +from UM.TaskManagement.HttpRequestManager import HttpRequestManager +from .DFLibraryFileUploadResponse import DFLibraryFileUploadResponse +from .DFPrintJobUploadResponse import DFPrintJobUploadResponse + + +class DFFileUploader: + """Class responsible for uploading meshes to the the digital factory library in separate requests.""" + + # The maximum amount of times to retry if the server returns one of the RETRY_HTTP_CODES + MAX_RETRIES = 10 + + # The HTTP codes that should trigger a retry. + RETRY_HTTP_CODES = {500, 502, 503, 504} + + def __init__(self, + http: HttpRequestManager, + df_file: Union[DFLibraryFileUploadResponse, DFPrintJobUploadResponse], + data: bytes, + on_finished: Callable[[str], Any], + on_success: Callable[[str], Any], + on_progress: Callable[[str, int], Any], + on_error: Callable[[str, "QNetworkReply", "QNetworkReply.NetworkError"], Any] + ) -> None: + """Creates a mesh upload object. + + :param http: The network access manager that will handle the HTTP requests. + :param df_file: The file response that was received by the Digital Factory after registering the upload. + :param data: The mesh bytes to be uploaded. + :param on_finished: The method to be called when done. + :param on_success: The method to be called when the upload is successful. + :param on_progress: The method to be called when the progress changes (receives a percentage 0-100). + :param on_error: The method to be called when an error occurs. + """ + + self._http = http # type: HttpRequestManager + self._df_file = df_file # type: Union[DFLibraryFileUploadResponse, DFPrintJobUploadResponse] + self._file_name = "" + if isinstance(self._df_file, DFLibraryFileUploadResponse): + self._file_name = self._df_file.file_name + elif isinstance(self._df_file, DFPrintJobUploadResponse): + if self._df_file.job_name is not None: + self._file_name = self._df_file.job_name + else: + self._file_name = "" + else: + raise TypeError("Incorrect input type") + self._data = data # type: bytes + + self._on_finished = on_finished + self._on_success = on_success + self._on_progress = on_progress + self._on_error = on_error + + self._retries = 0 + self._finished = False + + def start(self) -> None: + """Starts uploading the mesh.""" + + if self._finished: + # reset state. + self._retries = 0 + self._finished = False + self._upload() + + def stop(self): + """Stops uploading the mesh, marking it as finished.""" + + Logger.log("i", "Finished uploading") + self._finished = True # Signal to any ongoing retries that we should stop retrying. + self._on_finished(self._file_name) + + def _upload(self) -> None: + """ + Uploads the file to the Digital Factory Library project + """ + if self._finished: + raise ValueError("The upload is already finished") + + Logger.log("i", "Uploading DF file to project '{library_project_id}' via link '{upload_url}'".format(library_project_id = self._df_file.library_project_id, upload_url = self._df_file.upload_url)) + self._http.put( + url = cast(str, self._df_file.upload_url), + headers_dict = {"Content-Type": cast(str, self._df_file.content_type)}, + data = self._data, + callback = self._onUploadFinished, + error_callback = self._onUploadError, + upload_progress_callback = self._onUploadProgressChanged + ) + + def _onUploadProgressChanged(self, bytes_sent: int, bytes_total: int) -> None: + """Handles an update to the upload progress + + :param bytes_sent: The amount of bytes sent in the current request. + :param bytes_total: The amount of bytes to send in the current request. + """ + Logger.debug("Cloud upload progress %s / %s", bytes_sent, bytes_total) + if bytes_total: + self._on_progress(self._file_name, int(bytes_sent / len(self._data) * 100)) + + def _onUploadError(self, reply: QNetworkReply, error: QNetworkReply.NetworkError) -> None: + """Handles an error uploading.""" + + body = bytes(reply.peek(reply.bytesAvailable())).decode() + Logger.log("e", "Received error while uploading: %s", body) + self._on_error(self._file_name, reply, error) + self.stop() + + def _onUploadFinished(self, reply: QNetworkReply) -> None: + """ + Checks whether a chunk of data was uploaded successfully, starting the next chunk if needed. + """ + + Logger.log("i", "Finished callback %s %s", + reply.attribute(QNetworkRequest.HttpStatusCodeAttribute), reply.url().toString()) + + status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) # type: Optional[int] + if not status_code: + Logger.log("e", "Reply contained no status code.") + self._onUploadError(reply, None) + return + + # check if we should retry the last chunk + if self._retries < self.MAX_RETRIES and status_code in self.RETRY_HTTP_CODES: + self._retries += 1 + Logger.log("i", "Retrying %s/%s request %s", self._retries, self.MAX_RETRIES, reply.url().toString()) + try: + self._upload() + except ValueError: # Asynchronously it could have completed in the meanwhile. + pass + return + + # Http codes that are not to be retried are assumed to be errors. + if status_code > 308: + self._onUploadError(reply, None) + return + + Logger.log("d", "status_code: %s, Headers: %s, body: %s", status_code, + [bytes(header).decode() for header in reply.rawHeaderList()], bytes(reply.readAll()).decode()) + self._on_success(self._file_name) + self.stop() diff --git a/plugins/DigitalLibrary/src/DFLibraryFileUploadRequest.py b/plugins/DigitalLibrary/src/DFLibraryFileUploadRequest.py new file mode 100644 index 0000000000..d9f1af1490 --- /dev/null +++ b/plugins/DigitalLibrary/src/DFLibraryFileUploadRequest.py @@ -0,0 +1,16 @@ +# Copyright (c) 2021 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. + +# Model that represents the request to upload a file to a DF Library project +from .BaseModel import BaseModel + + +class DFLibraryFileUploadRequest(BaseModel): + + def __init__(self, content_type: str, file_name: str, file_size: int, library_project_id: str, **kwargs) -> None: + + self.content_type = content_type + self.file_name = file_name + self.file_size = file_size + self.library_project_id = library_project_id + super().__init__(**kwargs) diff --git a/plugins/DigitalLibrary/src/DFLibraryFileUploadResponse.py b/plugins/DigitalLibrary/src/DFLibraryFileUploadResponse.py new file mode 100644 index 0000000000..3093c39076 --- /dev/null +++ b/plugins/DigitalLibrary/src/DFLibraryFileUploadResponse.py @@ -0,0 +1,49 @@ +# Copyright (c) 2021 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +from datetime import datetime +from typing import Optional + +from .BaseModel import BaseModel + + +class DFLibraryFileUploadResponse(BaseModel): + """ + Model that represents the response received from the Digital Factory after requesting to upload a file in a Library project + """ + + def __init__(self, client_id: str, content_type: str, file_id: str, file_name: str, library_project_id: str, + status: str, uploaded_at: str, user_id: str, username: str, download_url: Optional[str] = None, + file_size: Optional[int] = None, status_description: Optional[str] = None, + upload_url: Optional[str] = None, **kwargs) -> None: + + """ + :param client_id: The ID of the OAuth2 client that uploaded this file + :param content_type: The content type of the Digital Library project file + :param file_id: The ID of the library project file + :param file_name: The name of the file + :param library_project_id: The ID of the library project, in which the file will be uploaded + :param status: The status of the Digital Library project file + :param uploaded_at: The time on which the file was uploaded + :param user_id: The ID of the user that uploaded this file + :param username: The user's unique username + :param download_url: A signed URL to download the resulting file. Only available when the job is finished + :param file_size: The size of the uploaded file (in bytes) + :param status_description: Contains more details about the status, e.g. the cause of failures + :param upload_url: The one-time use URL where the file must be uploaded to (only if status is uploading) + :param kwargs: Other keyword arguments that may be included in the response + """ + + self.client_id = client_id # type: str + self.content_type = content_type # type: str + self.file_id = file_id # type: str + self.file_name = file_name # type: str + self.library_project_id = library_project_id # type: str + self.status = status # type: str + self.uploaded_at = self.parseDate(uploaded_at) # type: datetime + self.user_id = user_id # type: str + self.username = username # type: str + self.download_url = download_url # type: Optional[str] + self.file_size = file_size # type: Optional[int] + self.status_description = status_description # type: Optional[str] + self.upload_url = upload_url # type: Optional[str] + super().__init__(**kwargs) diff --git a/plugins/DigitalLibrary/src/DFPrintJobUploadRequest.py b/plugins/DigitalLibrary/src/DFPrintJobUploadRequest.py new file mode 100644 index 0000000000..ab434e3f04 --- /dev/null +++ b/plugins/DigitalLibrary/src/DFPrintJobUploadRequest.py @@ -0,0 +1,21 @@ +# Copyright (c) 2021 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +from .BaseModel import BaseModel + + +# Model that represents the request to upload a print job to the cloud +class DFPrintJobUploadRequest(BaseModel): + + def __init__(self, job_name: str, file_size: int, content_type: str, library_project_id: str, **kwargs) -> None: + """Creates a new print job upload request. + + :param job_name: The name of the print job. + :param file_size: The size of the file in bytes. + :param content_type: The content type of the print job (e.g. text/plain or application/gzip) + """ + + self.job_name = job_name + self.file_size = file_size + self.content_type = content_type + self.library_project_id = library_project_id + super().__init__(**kwargs) diff --git a/plugins/DigitalLibrary/src/DFPrintJobUploadResponse.py b/plugins/DigitalLibrary/src/DFPrintJobUploadResponse.py new file mode 100644 index 0000000000..35819645de --- /dev/null +++ b/plugins/DigitalLibrary/src/DFPrintJobUploadResponse.py @@ -0,0 +1,35 @@ +# Copyright (c) 2021 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +from typing import Optional + +from .BaseModel import BaseModel + + +# Model that represents the response received from the cloud after requesting to upload a print job +class DFPrintJobUploadResponse(BaseModel): + + def __init__(self, job_id: str, status: str, download_url: Optional[str] = None, job_name: Optional[str] = None, + upload_url: Optional[str] = None, content_type: Optional[str] = None, + status_description: Optional[str] = None, slicing_details: Optional[dict] = None, **kwargs) -> None: + """Creates a new print job response model. + + :param job_id: The job unique ID, e.g. 'kBEeZWEifXbrXviO8mRYLx45P8k5lHVGs43XKvRniPg='. + :param status: The status of the print job. + :param status_description: Contains more details about the status, e.g. the cause of failures. + :param download_url: A signed URL to download the resulting status. Only available when the job is finished. + :param job_name: The name of the print job. + :param slicing_details: Model for slice information. + :param upload_url: The one-time use URL where the toolpath must be uploaded to (only if status is uploading). + :param content_type: The content type of the print job (e.g. text/plain or application/gzip) + :param generated_time: The datetime when the object was generated on the server-side. + """ + + self.job_id = job_id + self.status = status + self.download_url = download_url + self.job_name = job_name + self.upload_url = upload_url + self.content_type = content_type + self.status_description = status_description + self.slicing_details = slicing_details + super().__init__(**kwargs) diff --git a/plugins/DigitalLibrary/src/DigitalFactoryApiClient.py b/plugins/DigitalLibrary/src/DigitalFactoryApiClient.py new file mode 100644 index 0000000000..4342d2623e --- /dev/null +++ b/plugins/DigitalLibrary/src/DigitalFactoryApiClient.py @@ -0,0 +1,317 @@ +# Copyright (c) 2021 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. + +import json +from json import JSONDecodeError +import re +from time import time +from typing import List, Any, Optional, Union, Type, Tuple, Dict, cast, TypeVar, Callable + +from PyQt5.QtNetwork import QNetworkReply, QNetworkRequest + +from UM.Logger import Logger +from UM.TaskManagement.HttpRequestManager import HttpRequestManager +from UM.TaskManagement.HttpRequestScope import JsonDecoratorScope +from cura.CuraApplication import CuraApplication +from cura.UltimakerCloud import UltimakerCloudConstants +from cura.UltimakerCloud.UltimakerCloudScope import UltimakerCloudScope +from .DFPrintJobUploadResponse import DFPrintJobUploadResponse +from .BaseModel import BaseModel +from .CloudError import CloudError +from .DFFileUploader import DFFileUploader +from .DFLibraryFileUploadRequest import DFLibraryFileUploadRequest +from .DFLibraryFileUploadResponse import DFLibraryFileUploadResponse +from .DFPrintJobUploadRequest import DFPrintJobUploadRequest +from .DigitalFactoryFileResponse import DigitalFactoryFileResponse +from .DigitalFactoryProjectResponse import DigitalFactoryProjectResponse +from .PaginationManager import PaginationManager + +CloudApiClientModel = TypeVar("CloudApiClientModel", bound=BaseModel) +"""The generic type variable used to document the methods below.""" + + +class DigitalFactoryApiClient: + # The URL to access the digital factory. + ROOT_PATH = UltimakerCloudConstants.CuraCloudAPIRoot + CURA_API_ROOT = "{}/cura/v1".format(ROOT_PATH) + + DEFAULT_REQUEST_TIMEOUT = 10 # seconds + + # In order to avoid garbage collection we keep the callbacks in this list. + _anti_gc_callbacks = [] # type: List[Callable[[Any], None]] + + def __init__(self, application: CuraApplication, on_error: Callable[[List[CloudError]], None], projects_limit_per_page: Optional[int] = None) -> None: + """Initializes a new digital factory API client. + + :param application: + :param on_error: The callback to be called whenever we receive errors from the server. + """ + super().__init__() + self._application = application + self._account = application.getCuraAPI().account + self._scope = JsonDecoratorScope(UltimakerCloudScope(application)) + self._http = HttpRequestManager.getInstance() + self._on_error = on_error + self._file_uploader = None # type: Optional[DFFileUploader] + + self._projects_pagination_mgr = PaginationManager(limit = projects_limit_per_page) if projects_limit_per_page else None # type: Optional[PaginationManager] + + def getProject(self, library_project_id: str, on_finished: Callable[[DigitalFactoryProjectResponse], Any], failed: Callable) -> None: + """ + Retrieves a digital factory project by its library project id. + + :param library_project_id: The id of the library project + :param on_finished: The function to be called after the result is parsed. + :param failed: The function to be called if the request fails. + """ + url = "{}/projects/{}".format(self.CURA_API_ROOT, library_project_id) + + self._http.get(url, + scope = self._scope, + callback = self._parseCallback(on_finished, DigitalFactoryProjectResponse, failed), + error_callback = failed, + timeout = self.DEFAULT_REQUEST_TIMEOUT) + + def getProjectsFirstPage(self, on_finished: Callable[[List[DigitalFactoryProjectResponse]], Any], failed: Callable) -> None: + """ + Retrieves digital factory projects for the user that is currently logged in. + + If a projects pagination manager exists, then it attempts to get the first page of the paginated projects list, + according to the limit set in the pagination manager. If there is no projects pagination manager, this function + leaves the project limit to the default set on the server side (999999). + + :param on_finished: The function to be called after the result is parsed. + :param failed: The function to be called if the request fails. + """ + url = "{}/projects".format(self.CURA_API_ROOT) + if self._projects_pagination_mgr: + self._projects_pagination_mgr.reset() # reset to clear all the links and response metadata + url += "?limit={}".format(self._projects_pagination_mgr.limit) + + self._http.get(url, + scope = self._scope, + callback = self._parseCallback(on_finished, DigitalFactoryProjectResponse, failed, pagination_manager = self._projects_pagination_mgr), + error_callback = failed, + timeout = self.DEFAULT_REQUEST_TIMEOUT) + + def getMoreProjects(self, + on_finished: Callable[[List[DigitalFactoryProjectResponse]], Any], + failed: Callable) -> None: + """Retrieves the next page of the paginated projects list from the API, provided that there is any. + + :param on_finished: The function to be called after the result is parsed. + :param failed: The function to be called if the request fails. + """ + + if self.hasMoreProjectsToLoad(): + url = self._projects_pagination_mgr.links.next_page + self._http.get(url, + scope = self._scope, + callback = self._parseCallback(on_finished, DigitalFactoryProjectResponse, failed, pagination_manager = self._projects_pagination_mgr), + error_callback = failed, + timeout = self.DEFAULT_REQUEST_TIMEOUT) + else: + Logger.log("d", "There are no more projects to load.") + + def hasMoreProjectsToLoad(self) -> bool: + """ + Determines whether the client can get more pages of projects list from the API. + + :return: Whether there are more pages in the projects list available to be retrieved from the API. + """ + return self._projects_pagination_mgr and self._projects_pagination_mgr.links and self._projects_pagination_mgr.links.next_page is not None + + def getListOfFilesInProject(self, library_project_id: str, on_finished: Callable[[List[DigitalFactoryFileResponse]], Any], failed: Callable) -> None: + """Retrieves the list of files contained in the project with library_project_id from the Digital Factory Library. + + :param library_project_id: The id of the digital factory library project in which the files are included + :param on_finished: The function to be called after the result is parsed. + :param failed: The function to be called if the request fails. + """ + + url = "{}/projects/{}/files".format(self.CURA_API_ROOT, library_project_id) + self._http.get(url, + scope = self._scope, + callback = self._parseCallback(on_finished, DigitalFactoryFileResponse, failed), + error_callback = failed, + timeout = self.DEFAULT_REQUEST_TIMEOUT) + + def _parseCallback(self, + on_finished: Union[Callable[[CloudApiClientModel], Any], + Callable[[List[CloudApiClientModel]], Any]], + model: Type[CloudApiClientModel], + on_error: Optional[Callable] = None, + pagination_manager: Optional[PaginationManager] = None) -> Callable[[QNetworkReply], None]: + + """ + Creates a callback function so that it includes the parsing of the response into the correct model. + The callback is added to the 'finished' signal of the reply. If a paginated request was made and a pagination + manager is given, the pagination metadata will be held there. + + :param on_finished: The callback in case the response is successful. Depending on the endpoint it will be either + a list or a single item. + :param model: The type of the model to convert the response to. + :param on_error: The callback in case the response is ... less successful. + :param pagination_manager: Holds the pagination links and metadata contained in paginated responses. + If no pagination manager is provided, the pagination metadata is ignored. + """ + + def parse(reply: QNetworkReply) -> None: + + self._anti_gc_callbacks.remove(parse) + + # Don't try to parse the reply if we didn't get one + if reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) is None: + if on_error is not None: + on_error() + return + + status_code, response = self._parseReply(reply) + if status_code >= 300 and on_error is not None: + on_error() + else: + self._parseModels(response, on_finished, model, pagination_manager = pagination_manager) + + self._anti_gc_callbacks.append(parse) + return parse + + @staticmethod + def _parseReply(reply: QNetworkReply) -> Tuple[int, Dict[str, Any]]: + """Parses the given JSON network reply into a status code and a dictionary, handling unexpected errors as well. + + :param reply: The reply from the server. + :return: A tuple with a status code and a dictionary. + """ + + status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) + try: + response = bytes(reply.readAll()).decode() + return status_code, json.loads(response) + except (UnicodeDecodeError, JSONDecodeError, ValueError) as err: + error = CloudError(code = type(err).__name__, title = str(err), http_code = str(status_code), + id = str(time()), http_status = "500") + Logger.logException("e", "Could not parse the stardust response: %s", error.toDict()) + return status_code, {"errors": [error.toDict()]} + + def _parseModels(self, + response: Dict[str, Any], + on_finished: Union[Callable[[CloudApiClientModel], Any], + Callable[[List[CloudApiClientModel]], Any]], + model_class: Type[CloudApiClientModel], + pagination_manager: Optional[PaginationManager] = None) -> None: + """Parses the given models and calls the correct callback depending on the result. + + :param response: The response from the server, after being converted to a dict. + :param on_finished: The callback in case the response is successful. + :param model_class: The type of the model to convert the response to. It may either be a single record or a list. + :param pagination_manager: Holds the pagination links and metadata contained in paginated responses. + If no pagination manager is provided, the pagination metadata is ignored. + """ + + if "data" in response: + data = response["data"] + if "meta" in response and pagination_manager: + pagination_manager.setResponseMeta(response["meta"]) + if "links" in response and pagination_manager: + pagination_manager.setLinks(response["links"]) + if isinstance(data, list): + results = [model_class(**c) for c in data] # type: List[CloudApiClientModel] + on_finished_list = cast(Callable[[List[CloudApiClientModel]], Any], on_finished) + on_finished_list(results) + else: + result = model_class(**data) # type: CloudApiClientModel + on_finished_item = cast(Callable[[CloudApiClientModel], Any], on_finished) + on_finished_item(result) + elif "errors" in response: + self._on_error([CloudError(**error) for error in response["errors"]]) + else: + Logger.log("e", "Cannot find data or errors in the cloud response: %s", response) + + def requestUpload3MF(self, request: DFLibraryFileUploadRequest, + on_finished: Callable[[DFLibraryFileUploadResponse], Any], + on_error: Optional[Callable[["QNetworkReply", "QNetworkReply.NetworkError"], None]] = None) -> None: + + """Requests the Digital Factory to register the upload of a file in a library project. + + :param request: The request object. + :param on_finished: The function to be called after the result is parsed. + :param on_error: The callback in case the request fails. + """ + + url = "{}/files/upload".format(self.CURA_API_ROOT) + data = json.dumps({"data": request.toDict()}).encode() + + self._http.put(url, + scope = self._scope, + data = data, + callback = self._parseCallback(on_finished, DFLibraryFileUploadResponse), + error_callback = on_error, + timeout = self.DEFAULT_REQUEST_TIMEOUT) + + def requestUploadUFP(self, request: DFPrintJobUploadRequest, + on_finished: Callable[[DFPrintJobUploadResponse], Any], + on_error: Optional[Callable[["QNetworkReply", "QNetworkReply.NetworkError"], None]] = None) -> None: + """Requests the Digital Factory to register the upload of a file in a library project. + + :param request: The request object. + :param on_finished: The function to be called after the result is parsed. + :param on_error: The callback in case the request fails. + """ + + url = "{}/jobs/upload".format(self.CURA_API_ROOT) + data = json.dumps({"data": request.toDict()}).encode() + + self._http.put(url, + scope = self._scope, + data = data, + callback = self._parseCallback(on_finished, DFPrintJobUploadResponse), + error_callback = on_error, + timeout = self.DEFAULT_REQUEST_TIMEOUT) + + def uploadExportedFileData(self, + df_file_upload_response: Union[DFLibraryFileUploadResponse, DFPrintJobUploadResponse], + mesh: bytes, + on_finished: Callable[[str], Any], + on_success: Callable[[str], Any], + on_progress: Callable[[str, int], Any], + on_error: Callable[[str, "QNetworkReply", "QNetworkReply.NetworkError"], Any]) -> None: + + """Uploads an exported file (in bytes) to the Digital Factory Library. + + :param df_file_upload_response: The response received after requesting an upload with `self.requestUpload`. + :param mesh: The mesh data (in bytes) to be uploaded. + :param on_finished: The function to be called after the upload has finished. Called both after on_success and on_error. + It receives the name of the file that has finished uploading. + :param on_success: The function to be called if the upload was successful. + It receives the name of the file that was uploaded successfully. + :param on_progress: A function to be called during upload progress. It receives a percentage (0-100). + It receives the name of the file for which the upload progress should be updated. + :param on_error: A function to be called if the upload fails. + It receives the name of the file that produced errors during the upload process. + """ + + self._file_uploader = DFFileUploader(self._http, df_file_upload_response, mesh, on_finished, on_success, on_progress, on_error) + self._file_uploader.start() + + def createNewProject(self, project_name: str, on_finished: Callable[[CloudApiClientModel], Any], on_error: Callable) -> None: + """ Create a new project in the Digital Factory. + + :param project_name: Name of the new to be created project. + :param on_finished: The function to be called after the result is parsed. + :param on_error: The function to be called if anything goes wrong. + """ + + display_name = re.sub(r"[^a-zA-Z0-9- ./™®ö+']", " ", project_name) + Logger.log("i", "Attempt to create new DF project '{}'.".format(display_name)) + + url = "{}/projects".format(self.CURA_API_ROOT) + data = json.dumps({"data": {"display_name": display_name}}).encode() + self._http.put(url, + scope = self._scope, + data = data, + callback = self._parseCallback(on_finished, DigitalFactoryProjectResponse), + error_callback = on_error, + timeout = self.DEFAULT_REQUEST_TIMEOUT) + + def clear(self) -> None: + self._projects_pagination_mgr.reset() diff --git a/plugins/DigitalLibrary/src/DigitalFactoryController.py b/plugins/DigitalLibrary/src/DigitalFactoryController.py new file mode 100644 index 0000000000..cd20479611 --- /dev/null +++ b/plugins/DigitalLibrary/src/DigitalFactoryController.py @@ -0,0 +1,563 @@ +# Copyright (c) 2021 Ultimaker B.V. +import json +import math +import os +import tempfile +import threading +from enum import IntEnum +from pathlib import Path +from typing import Optional, List, Dict, Any + +from PyQt5.QtCore import pyqtSignal, QObject, pyqtSlot, pyqtProperty, Q_ENUMS, QUrl +from PyQt5.QtNetwork import QNetworkReply +from PyQt5.QtQml import qmlRegisterType, qmlRegisterUncreatableType + +from UM.FileHandler.FileHandler import FileHandler +from UM.Logger import Logger +from UM.Message import Message +from UM.Scene.SceneNode import SceneNode +from UM.Signal import Signal +from UM.TaskManagement.HttpRequestManager import HttpRequestManager +from cura.API import Account +from cura.CuraApplication import CuraApplication +from cura.UltimakerCloud.UltimakerCloudScope import UltimakerCloudScope +from .DFFileExportAndUploadManager import DFFileExportAndUploadManager +from .DigitalFactoryApiClient import DigitalFactoryApiClient +from .DigitalFactoryFileModel import DigitalFactoryFileModel +from .DigitalFactoryFileResponse import DigitalFactoryFileResponse +from .DigitalFactoryProjectModel import DigitalFactoryProjectModel +from .DigitalFactoryProjectResponse import DigitalFactoryProjectResponse + + +class RetrievalStatus(IntEnum): + """ + The status of an http get request. + + This is not an enum, because we want to use it in QML and QML doesn't recognize Python enums. + """ + Idle = 0 + InProgress = 1 + Success = 2 + Failed = 3 + + +class DFRetrievalStatus(QObject): + """ + Used as an intermediate QObject that registers the RetrievalStatus as a recognizable enum in QML, so that it can + be used within QML objects as DigitalFactory.RetrievalStatus.<status> + """ + + Q_ENUMS(RetrievalStatus) + + +class DigitalFactoryController(QObject): + + DISK_WRITE_BUFFER_SIZE = 256 * 1024 # 256 KB + + selectedProjectIndexChanged = pyqtSignal(int, arguments = ["newProjectIndex"]) + """Signal emitted whenever the selected project is changed in the projects dropdown menu""" + + selectedFileIndicesChanged = pyqtSignal("QList<int>", arguments = ["newFileIndices"]) + """Signal emitted whenever the selected file is changed in the files table""" + + retrievingProjectsStatusChanged = pyqtSignal(int, arguments = ["status"]) + """Signal emitted whenever the status of the 'retrieving projects' http get request is changed""" + + retrievingFilesStatusChanged = pyqtSignal(int, arguments = ["status"]) + """Signal emitted whenever the status of the 'retrieving files in project' http get request is changed""" + + creatingNewProjectStatusChanged = pyqtSignal(int, arguments = ["status"]) + """Signal emitted whenever the status of the 'create new library project' http get request is changed""" + + hasMoreProjectsToLoadChanged = pyqtSignal() + """Signal emitted whenever the variable hasMoreProjectsToLoad is changed. This variable is used to determine if + the paginated list of projects has more pages to show""" + + preselectedProjectChanged = pyqtSignal() + """Signal emitted whenever a preselected project is set. Whenever there is a preselected project, it means that it is + the only project in the ProjectModel. When the preselected project is invalidated, the ProjectsModel needs to be + retrieved again.""" + + projectCreationErrorTextChanged = pyqtSignal() + """Signal emitted whenever the creation of a new project fails and a specific error message is returned from the + server.""" + + """Signals to inform about the process of the file upload""" + uploadStarted = Signal() + uploadFileProgress = Signal() + uploadFileSuccess = Signal() + uploadFileError = Signal() + uploadFileFinished = Signal() + + def __init__(self, application: CuraApplication) -> None: + super().__init__(parent = None) + + self._application = application + self._dialog = None # type: Optional["QObject"] + + self.file_handlers = {} # type: Dict[str, FileHandler] + self.nodes = None # type: Optional[List[SceneNode]] + self.file_upload_manager = None + self._has_preselected_project = False # type: bool + + self._api = DigitalFactoryApiClient(self._application, on_error = lambda error: Logger.log("e", str(error)), projects_limit_per_page = 20) + + # Indicates whether there are more pages of projects that can be loaded from the API + self._has_more_projects_to_load = False + + self._account = self._application.getInstance().getCuraAPI().account # type: Account + self._current_workspace_information = CuraApplication.getInstance().getCurrentWorkspaceInformation() + + # Initialize the project model + self._project_model = DigitalFactoryProjectModel() + self._selected_project_idx = -1 + self._project_creation_error_text = "Something went wrong while creating a new project. Please try again." + + # Initialize the file model + self._file_model = DigitalFactoryFileModel() + self._selected_file_indices = [] # type: List[int] + + # Filled after the application has been initialized + self._supported_file_types = {} # type: Dict[str, str] + + # For cleaning up the files afterwards: + self._erase_temp_files_lock = threading.Lock() + + # The statuses which indicate whether Cura is waiting for a response from the DigitalFactory API + self.retrieving_files_status = RetrievalStatus.Idle + self.retrieving_projects_status = RetrievalStatus.Idle + self.creating_new_project_status = RetrievalStatus.Idle + + self._application.engineCreatedSignal.connect(self._onEngineCreated) + self._application.initializationFinished.connect(self._applicationInitializationFinished) + + def clear(self) -> None: + self._project_model.clearProjects() + self._api.clear() + self._has_preselected_project = False + self.preselectedProjectChanged.emit() + + self.setRetrievingFilesStatus(RetrievalStatus.Idle) + self.setRetrievingProjectsStatus(RetrievalStatus.Idle) + self.setCreatingNewProjectStatus(RetrievalStatus.Idle) + + self.setSelectedProjectIndex(-1) + + def userAccountHasLibraryAccess(self) -> bool: + """ + Checks whether the currently logged in user account has access to the Digital Library + + :return: True if the user account has Digital Library access, else False + """ + subscriptions = [] # type: Optional[List[Dict[str, Any]]] + if self._account.userProfile: + subscriptions = self._account.userProfile.get("subscriptions", []) + return len(subscriptions) > 0 + + def initialize(self, preselected_project_id: Optional[str] = None) -> None: + self.clear() + + if self._account.isLoggedIn and self.userAccountHasLibraryAccess(): + self.setRetrievingProjectsStatus(RetrievalStatus.InProgress) + if preselected_project_id: + self._api.getProject(preselected_project_id, on_finished = self.setProjectAsPreselected, failed = self._onGetProjectFailed) + else: + self._api.getProjectsFirstPage(on_finished = self._onGetProjectsFirstPageFinished, failed = self._onGetProjectsFailed) + + def setProjectAsPreselected(self, df_project: DigitalFactoryProjectResponse) -> None: + """ + Sets the received df_project as the preselected one. When a project is preselected, it should be the only + project inside the model, so this function first makes sure to clear the projects model. + + :param df_project: The library project intended to be set as preselected + """ + self._project_model.clearProjects() + self._project_model.setProjects([df_project]) + self.setSelectedProjectIndex(0) + self.setHasPreselectedProject(True) + self.setRetrievingProjectsStatus(RetrievalStatus.Success) + self.setCreatingNewProjectStatus(RetrievalStatus.Success) + + def _onGetProjectFailed(self, reply: QNetworkReply, error: QNetworkReply.NetworkError) -> None: + reply_string = bytes(reply.readAll()).decode() + self.setHasPreselectedProject(False) + Logger.log("w", "Something went wrong while trying to retrieve a the preselected Digital Library project. Error: {}".format(reply_string)) + + def _onGetProjectsFirstPageFinished(self, df_projects: List[DigitalFactoryProjectResponse]) -> None: + """ + Set the first page of projects received from the digital factory library in the project model. Called whenever + the retrieval of the first page of projects is successful. + + :param df_projects: A list of all the Digital Factory Library projects linked to the user's account + """ + self.setHasMoreProjectsToLoad(self._api.hasMoreProjectsToLoad()) + self._project_model.setProjects(df_projects) + self.setRetrievingProjectsStatus(RetrievalStatus.Success) + + @pyqtSlot() + def loadMoreProjects(self) -> None: + """ + Initiates the process of retrieving the next page of the projects list from the API. + """ + self._api.getMoreProjects(on_finished = self.loadMoreProjectsFinished, failed = self._onGetProjectsFailed) + self.setRetrievingProjectsStatus(RetrievalStatus.InProgress) + + def loadMoreProjectsFinished(self, df_projects: List[DigitalFactoryProjectResponse]) -> None: + """ + Set the projects received from the digital factory library in the project model. Called whenever the retrieval + of the projects is successful. + + :param df_projects: A list of all the Digital Factory Library projects linked to the user's account + """ + self.setHasMoreProjectsToLoad(self._api.hasMoreProjectsToLoad()) + self._project_model.extendProjects(df_projects) + self.setRetrievingProjectsStatus(RetrievalStatus.Success) + + def _onGetProjectsFailed(self, reply: QNetworkReply, error: QNetworkReply.NetworkError) -> None: + """ + Error function, called whenever the retrieval of projects fails. + """ + self.setRetrievingProjectsStatus(RetrievalStatus.Failed) + Logger.log("w", "Failed to retrieve the list of projects from the Digital Library. Error encountered: {}".format(error)) + + def getProjectFilesFinished(self, df_files_in_project: List[DigitalFactoryFileResponse]) -> None: + """ + Set the files received from the digital factory library in the file model. The files are filtered to only + contain the files which can be opened by Cura. + Called whenever the retrieval of the files is successful. + + :param df_files_in_project: A list of all the Digital Factory Library files that exist in a library project + """ + # Filter to show only the files that can be opened in Cura + self._file_model.setFilters({"file_name": lambda x: Path(x).suffix[1:].lower() in self._supported_file_types}) # the suffix is in format '.xyz', so omit the dot at the start + self._file_model.setFiles(df_files_in_project) + self.setRetrievingFilesStatus(RetrievalStatus.Success) + + def getProjectFilesFailed(self, reply: QNetworkReply, error: QNetworkReply.NetworkError) -> None: + """ + Error function, called whenever the retrieval of the files in a library project fails. + """ + Logger.log("w", "Failed to retrieve the list of files in project '{}' from the Digital Library".format(self._project_model._projects[self._selected_project_idx])) + self.setRetrievingFilesStatus(RetrievalStatus.Failed) + + @pyqtSlot() + def clearProjectSelection(self) -> None: + """ + Clear the selected project. + """ + if self._has_preselected_project: + self.setHasPreselectedProject(False) + else: + self.setSelectedProjectIndex(-1) + + @pyqtSlot(int) + def setSelectedProjectIndex(self, project_idx: int) -> None: + """ + Sets the index of the project which is currently selected in the dropdown menu. Then, it uses the project_id of + that project to retrieve the list of files included in that project and display it in the interface. + + :param project_idx: The index of the currently selected project + """ + if project_idx < -1 or project_idx >= len(self._project_model.items): + Logger.log("w", "The selected project index is invalid.") + project_idx = -1 # -1 is a valid index for the combobox and it is handled as "nothing is selected" + self._selected_project_idx = project_idx + self.selectedProjectIndexChanged.emit(project_idx) + + # Clear the files from the previously-selected project and refresh the files model with the newly-selected- + # project's files + self._file_model.clearFiles() + self.selectedFileIndicesChanged.emit([]) + if 0 <= project_idx < len(self._project_model.items): + library_project_id = self._project_model.items[project_idx]["libraryProjectId"] + self.setRetrievingFilesStatus(RetrievalStatus.InProgress) + self._api.getListOfFilesInProject(library_project_id, on_finished = self.getProjectFilesFinished, failed = self.getProjectFilesFailed) + + @pyqtProperty(int, fset = setSelectedProjectIndex, notify = selectedProjectIndexChanged) + def selectedProjectIndex(self) -> int: + return self._selected_project_idx + + @pyqtSlot("QList<int>") + def setSelectedFileIndices(self, file_indices: List[int]) -> None: + """ + Sets the index of the file which is currently selected in the list of files. + + :param file_indices: The index of the currently selected file + """ + if file_indices != self._selected_file_indices: + self._selected_file_indices = file_indices + self.selectedFileIndicesChanged.emit(file_indices) + + @pyqtProperty(QObject, constant = True) + def digitalFactoryProjectModel(self) -> "DigitalFactoryProjectModel": + return self._project_model + + @pyqtProperty(QObject, constant = True) + def digitalFactoryFileModel(self) -> "DigitalFactoryFileModel": + return self._file_model + + def setHasMoreProjectsToLoad(self, has_more_projects_to_load: bool) -> None: + """ + Set the value that indicates whether there are more pages of projects that can be loaded from the API + + :param has_more_projects_to_load: Whether there are more pages of projects + """ + if has_more_projects_to_load != self._has_more_projects_to_load: + self._has_more_projects_to_load = has_more_projects_to_load + self.hasMoreProjectsToLoadChanged.emit() + + @pyqtProperty(bool, fset = setHasMoreProjectsToLoad, notify = hasMoreProjectsToLoadChanged) + def hasMoreProjectsToLoad(self) -> bool: + """ + :return: whether there are more pages for projects that can be loaded from the API + """ + return self._has_more_projects_to_load + + @pyqtSlot(str) + def createLibraryProjectAndSetAsPreselected(self, project_name: Optional[str]) -> None: + """ + Creates a new project with the given name in the Digital Library. + + :param project_name: The name that will be used for the new project + """ + if project_name: + self._api.createNewProject(project_name, self.setProjectAsPreselected, self._createNewLibraryProjectFailed) + self.setCreatingNewProjectStatus(RetrievalStatus.InProgress) + else: + Logger.log("w", "No project name provided while attempting to create a new project. Aborting the project creation.") + + def _createNewLibraryProjectFailed(self, reply: QNetworkReply, error: QNetworkReply.NetworkError) -> None: + reply_string = bytes(reply.readAll()).decode() + + self._project_creation_error_text = "Something went wrong while creating the new project. Please try again." + if reply_string: + reply_dict = json.loads(reply_string) + if "errors" in reply_dict and len(reply_dict["errors"]) >= 1 and "title" in reply_dict["errors"][0]: + self._project_creation_error_text = "Error while creating the new project: {}".format(reply_dict["errors"][0]["title"]) + self.projectCreationErrorTextChanged.emit() + + self.setCreatingNewProjectStatus(RetrievalStatus.Failed) + Logger.log("e", "Something went wrong while trying to create a new a project. Error: {}".format(reply_string)) + + # The new_status type is actually "RetrievalStatus" but since the RetrievalStatus cannot be an enum, we leave it as int + def setRetrievingProjectsStatus(self, new_status: int) -> None: + """ + Sets the status of the "retrieving library projects" http call. + + :param new_status: The new status + """ + self.retrieving_projects_status = new_status + self.retrievingProjectsStatusChanged.emit(new_status) + + @pyqtProperty(int, fset = setRetrievingProjectsStatus, notify = retrievingProjectsStatusChanged) + def retrievingProjectsStatus(self) -> int: + return self.retrieving_projects_status + + # The new_status type is actually "RetrievalStatus" but since the RetrievalStatus cannot be an enum, we leave it as int + def setRetrievingFilesStatus(self, new_status: int) -> None: + """ + Sets the status of the "retrieving files list in the selected library project" http call. + + :param new_status: The new status + """ + self.retrieving_files_status = new_status + self.retrievingFilesStatusChanged.emit(new_status) + + @pyqtProperty(int, fset = setRetrievingFilesStatus, notify = retrievingFilesStatusChanged) + def retrievingFilesStatus(self) -> int: + return self.retrieving_files_status + + # The new_status type is actually "RetrievalStatus" but since the RetrievalStatus cannot be an enum, we leave it as int + def setCreatingNewProjectStatus(self, new_status: int) -> None: + """ + Sets the status of the "creating new library project" http call. + + :param new_status: The new status + """ + self.creating_new_project_status = new_status + self.creatingNewProjectStatusChanged.emit(new_status) + + @pyqtProperty(int, fset = setCreatingNewProjectStatus, notify = creatingNewProjectStatusChanged) + def creatingNewProjectStatus(self) -> int: + return self.creating_new_project_status + + @staticmethod + def _onEngineCreated() -> None: + qmlRegisterUncreatableType(DFRetrievalStatus, "DigitalFactory", 1, 0, "RetrievalStatus", "Could not create RetrievalStatus enum type") + + def _applicationInitializationFinished(self) -> None: + self._supported_file_types = self._application.getInstance().getMeshFileHandler().getSupportedFileTypesRead() + + @pyqtSlot("QList<int>") + def setSelectedFileIndices(self, file_indices: List[int]) -> None: + """ + Sets the index of the file which is currently selected in the list of files. + + :param file_indices: A list of the indices of the currently selected files + """ + self._selected_file_indices = file_indices + self.selectedFileIndicesChanged.emit(file_indices) + + @pyqtSlot() + def openSelectedFiles(self) -> None: + """ Downloads, then opens all files selected in the Qt frontend open dialog. + """ + + temp_dir = tempfile.mkdtemp() + if temp_dir is None or temp_dir == "": + Logger.error("Digital Library: Couldn't create temporary directory to store to-be downloaded files.") + return + + if self._selected_project_idx < 0 or len(self._selected_file_indices) < 1: + Logger.error("Digital Library: No project or no file selected on open action.") + return + + to_erase_on_done_set = { + os.path.join(temp_dir, self._file_model.getItem(i)["fileName"]).replace('\\', '/') + for i in self._selected_file_indices} + + def onLoadedCallback(filename_done: str) -> None: + filename_done = os.path.join(temp_dir, filename_done).replace('\\', '/') + with self._erase_temp_files_lock: + if filename_done in to_erase_on_done_set: + try: + os.remove(filename_done) + to_erase_on_done_set.remove(filename_done) + if len(to_erase_on_done_set) < 1 and os.path.exists(temp_dir): + os.rmdir(temp_dir) + except (IOError, OSError) as ex: + Logger.error("Can't erase temporary (in) {0} because {1}.", temp_dir, str(ex)) + + # Save the project id to make sure it will be preselected the next time the user opens the save dialog + CuraApplication.getInstance().getCurrentWorkspaceInformation().setEntryToStore("digital_factory", "library_project_id", library_project_id) + + # Disconnect the signals so that they are not fired every time another (project) file is loaded + app.fileLoaded.disconnect(onLoadedCallback) + app.workspaceLoaded.disconnect(onLoadedCallback) + + app = CuraApplication.getInstance() + app.fileLoaded.connect(onLoadedCallback) # fired when non-project files are loaded + app.workspaceLoaded.connect(onLoadedCallback) # fired when project files are loaded + + project_name = self._project_model.getItem(self._selected_project_idx)["displayName"] + for file_index in self._selected_file_indices: + file_item = self._file_model.getItem(file_index) + file_name = file_item["fileName"] + download_url = file_item["downloadUrl"] + library_project_id = file_item["libraryProjectId"] + self._openSelectedFile(temp_dir, project_name, file_name, download_url) + + def _openSelectedFile(self, temp_dir: str, project_name: str, file_name: str, download_url: str) -> None: + """ Downloads, then opens, the single specified file. + + :param temp_dir: The already created temporary directory where the files will be stored. + :param project_name: Name of the project the file belongs to (used for error reporting). + :param file_name: Name of the file to be downloaded and opened (used for error reporting). + :param download_url: This url will be downloaded, then the downloaded file will be opened in Cura. + """ + if not download_url: + Logger.log("e", "No download url for file '{}'".format(file_name)) + return + + progress_message = Message(text = "{0}/{1}".format(project_name, file_name), dismissable = False, lifetime = 0, + progress = 0, title = "Downloading...") + progress_message.setProgress(0) + progress_message.show() + + def progressCallback(rx: int, rt: int) -> None: + progress_message.setProgress(math.floor(rx * 100.0 / rt)) + + def finishedCallback(reply: QNetworkReply) -> None: + progress_message.hide() + try: + with open(os.path.join(temp_dir, file_name), "wb+") as temp_file: + bytes_read = reply.read(self.DISK_WRITE_BUFFER_SIZE) + while bytes_read: + temp_file.write(bytes_read) + bytes_read = reply.read(self.DISK_WRITE_BUFFER_SIZE) + CuraApplication.getInstance().processEvents() + temp_file_name = temp_file.name + except IOError as ex: + Logger.logException("e", "Can't write Digital Library file {0}/{1} download to temp-directory {2}.", + ex, project_name, file_name, temp_dir) + Message( + text = "Failed to write to temporary file for '{}'.".format(file_name), + title = "File-system error", + lifetime = 10 + ).show() + return + + CuraApplication.getInstance().readLocalFile( + QUrl.fromLocalFile(temp_file_name), add_to_recent_files = False) + + def errorCallback(reply: QNetworkReply, error: QNetworkReply.NetworkError, p = project_name, + f = file_name) -> None: + progress_message.hide() + Logger.error("An error {0} {1} occurred while downloading {2}/{3}".format(str(error), str(reply), p, f)) + Message( + text = "Failed Digital Library download for '{}'.".format(f), + title = "Network error {}".format(error), + lifetime = 10 + ).show() + + download_manager = HttpRequestManager.getInstance() + download_manager.get(download_url, callback = finishedCallback, download_progress_callback = progressCallback, + error_callback = errorCallback, scope = UltimakerCloudScope(CuraApplication.getInstance())) + + def setHasPreselectedProject(self, new_has_preselected_project: bool) -> None: + if not new_has_preselected_project: + # The preselected project was the only one in the model, at index 0, so when we set the has_preselected_project to + # false, we also need to clean it from the projects model + self._project_model.clearProjects() + self.setSelectedProjectIndex(-1) + self._api.getProjectsFirstPage(on_finished = self._onGetProjectsFirstPageFinished, failed = self._onGetProjectsFailed) + self.setRetrievingProjectsStatus(RetrievalStatus.InProgress) + self._has_preselected_project = new_has_preselected_project + self.preselectedProjectChanged.emit() + + @pyqtProperty(bool, fset = setHasPreselectedProject, notify = preselectedProjectChanged) + def hasPreselectedProject(self) -> bool: + return self._has_preselected_project + + @pyqtSlot(str, "QStringList") + def saveFileToSelectedProject(self, filename: str, formats: List[str]) -> None: + """ + Function triggered whenever the Save button is pressed. + + :param filename: The name (without the extension) that will be used for the files + :param formats: List of the formats the scene will be exported to. Can include 3mf, ufp, or both + """ + if self._selected_project_idx == -1: + Logger.log("e", "No DF Library project is selected.") + return + + if filename == "": + Logger.log("w", "The file name cannot be empty.") + Message(text = "Cannot upload file with an empty name to the Digital Library", title = "Empty file name provided", lifetime = 0).show() + return + + self._saveFileToSelectedProjectHelper(filename, formats) + + def _saveFileToSelectedProjectHelper(self, filename: str, formats: List[str]) -> None: + # Indicate we have started sending a job. + self.uploadStarted.emit() + + library_project_id = self._project_model.items[self._selected_project_idx]["libraryProjectId"] + library_project_name = self._project_model.items[self._selected_project_idx]["displayName"] + + # Use the file upload manager to export and upload the 3mf and/or ufp files to the DF Library project + self.file_upload_manager = DFFileExportAndUploadManager(file_handlers = self.file_handlers, nodes = self.nodes, + library_project_id = library_project_id, + library_project_name = library_project_name, + file_name = filename, formats = formats, + on_upload_error = self.uploadFileError.emit, + on_upload_success = self.uploadFileSuccess.emit, + on_upload_finished = self.uploadFileFinished.emit, + on_upload_progress = self.uploadFileProgress.emit) + + # Save the project id to make sure it will be preselected the next time the user opens the save dialog + self._current_workspace_information.setEntryToStore("digital_factory", "library_project_id", library_project_id) + + @pyqtProperty(str, notify = projectCreationErrorTextChanged) + def projectCreationErrorText(self) -> str: + return self._project_creation_error_text diff --git a/plugins/DigitalLibrary/src/DigitalFactoryFileModel.py b/plugins/DigitalLibrary/src/DigitalFactoryFileModel.py new file mode 100644 index 0000000000..718bd11cd2 --- /dev/null +++ b/plugins/DigitalLibrary/src/DigitalFactoryFileModel.py @@ -0,0 +1,115 @@ +# Copyright (c) 2021 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +from typing import List, Dict, Callable + +from PyQt5.QtCore import Qt, pyqtSignal + +from UM.Logger import Logger +from UM.Qt.ListModel import ListModel +from .DigitalFactoryFileResponse import DigitalFactoryFileResponse + + +DIGITAL_FACTORY_DISPLAY_DATETIME_FORMAT = "%d-%m-%Y %H:%M" + + +class DigitalFactoryFileModel(ListModel): + FileNameRole = Qt.UserRole + 1 + FileIdRole = Qt.UserRole + 2 + FileSizeRole = Qt.UserRole + 3 + LibraryProjectIdRole = Qt.UserRole + 4 + DownloadUrlRole = Qt.UserRole + 5 + UsernameRole = Qt.UserRole + 6 + UploadedAtRole = Qt.UserRole + 7 + + dfFileModelChanged = pyqtSignal() + + def __init__(self, parent = None): + super().__init__(parent) + + self.addRoleName(self.FileNameRole, "fileName") + self.addRoleName(self.FileIdRole, "fileId") + self.addRoleName(self.FileSizeRole, "fileSize") + self.addRoleName(self.LibraryProjectIdRole, "libraryProjectId") + self.addRoleName(self.DownloadUrlRole, "downloadUrl") + self.addRoleName(self.UsernameRole, "username") + self.addRoleName(self.UploadedAtRole, "uploadedAt") + + self._files = [] # type: List[DigitalFactoryFileResponse] + self._filters = {} # type: Dict[str, Callable] + + def setFiles(self, df_files_in_project: List[DigitalFactoryFileResponse]) -> None: + if self._files == df_files_in_project: + return + self._files = df_files_in_project + self._update() + + def clearFiles(self) -> None: + self.clear() + self._files.clear() + self.dfFileModelChanged.emit() + + def _update(self) -> None: + filtered_files_list = self.getFilteredFilesList() + + for file in filtered_files_list: + self.appendItem({ + "fileName" : file.file_name, + "fileId" : file.file_id, + "fileSize": file.file_size, + "libraryProjectId": file.library_project_id, + "downloadUrl": file.download_url, + "username": file.username, + "uploadedAt": file.uploaded_at.strftime(DIGITAL_FACTORY_DISPLAY_DATETIME_FORMAT) + }) + + self.dfFileModelChanged.emit() + + def setFilters(self, filters: Dict[str, Callable]) -> None: + """ + Sets the filters and updates the files model to contain only the files that meet all of the filters. + + :param filters: The filters to be applied + example: + { + "attribute_name1": function_to_be_applied_on_DigitalFactoryFileResponse_attribute1, + "attribute_name2": function_to_be_applied_on_DigitalFactoryFileResponse_attribute2 + } + """ + self.clear() + self._filters = filters + self._update() + + def clearFilters(self) -> None: + """ + Clears all the model filters + """ + self.setFilters({}) + + def getFilteredFilesList(self) -> List[DigitalFactoryFileResponse]: + """ + Lists the files that meet all the filters specified in the self._filters. This is achieved by applying each + filter function on the corresponding attribute for all the filters in the self._filters. If all of them are + true, the file is added to the filtered files list. + In order for this to work, the self._filters should be in the format: + { + "attribute_name": function_to_be_applied_on_the_DigitalFactoryFileResponse_attribute + } + + :return: The list of files that meet all the specified filters + """ + if not self._filters: + return self._files + + filtered_files_list = [] + for file in self._files: + filter_results = [] + for attribute, filter_func in self._filters.items(): + try: + filter_results.append(filter_func(getattr(file, attribute))) + except AttributeError: + Logger.log("w", "Attribute '{}' doesn't exist in objects of type '{}'".format(attribute, type(file))) + all_filters_met = all(filter_results) + if all_filters_met: + filtered_files_list.append(file) + + return filtered_files_list diff --git a/plugins/DigitalLibrary/src/DigitalFactoryFileProvider.py b/plugins/DigitalLibrary/src/DigitalFactoryFileProvider.py new file mode 100644 index 0000000000..c2fe6b969c --- /dev/null +++ b/plugins/DigitalLibrary/src/DigitalFactoryFileProvider.py @@ -0,0 +1,58 @@ +# Copyright (c) 2021 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +import os + +from UM.FileProvider import FileProvider +from UM.Logger import Logger +from cura.API import Account +from cura.CuraApplication import CuraApplication +from .DigitalFactoryController import DigitalFactoryController + + +class DigitalFactoryFileProvider(FileProvider): + + def __init__(self, df_controller: DigitalFactoryController) -> None: + super().__init__() + self._controller = df_controller + + self.menu_item_display_text = "From Digital Library" + self.shortcut = "Ctrl+Shift+O" + plugin_path = os.path.dirname(os.path.dirname(__file__)) + self._dialog_path = os.path.join(plugin_path, "resources", "qml", "DigitalFactoryOpenDialog.qml") + self._dialog = None + + self._account = CuraApplication.getInstance().getCuraAPI().account # type: Account + self._account.loginStateChanged.connect(self._onLoginStateChanged) + self.enabled = self._account.isLoggedIn and self._controller.userAccountHasLibraryAccess() + self.priority = 10 + + def run(self) -> None: + """ + Function called every time the 'From Digital Factory' option of the 'Open File(s)' submenu is triggered + """ + self.loadWindow() + + if self._account.isLoggedIn and self._controller.userAccountHasLibraryAccess(): + self._controller.initialize() + self._dialog.show() + + def loadWindow(self) -> None: + """ + Create the GUI window for the Digital Library Open dialog. If the window is already open, bring the focus on it. + """ + + if self._dialog: # Dialogue is already open. + self._dialog.requestActivate() # Bring the focus on the dialogue. + return + + self._dialog = CuraApplication.getInstance().createQmlComponent(self._dialog_path, {"manager": self._controller}) + if not self._dialog: + Logger.log("e", "Unable to create the Digital Library Open dialog.") + + def _onLoginStateChanged(self, logged_in: bool) -> None: + """ + Sets the enabled status of the DigitalFactoryFileProvider according to the account's login status + :param logged_in: The new login status + """ + self.enabled = logged_in and self._controller.userAccountHasLibraryAccess() + self.enabledChanged.emit() diff --git a/plugins/DigitalLibrary/src/DigitalFactoryFileResponse.py b/plugins/DigitalLibrary/src/DigitalFactoryFileResponse.py new file mode 100644 index 0000000000..eb7e71fbb6 --- /dev/null +++ b/plugins/DigitalLibrary/src/DigitalFactoryFileResponse.py @@ -0,0 +1,57 @@ +# Copyright (c) 2021 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +from datetime import datetime +from typing import Optional + +from .BaseModel import BaseModel + +DIGITAL_FACTORY_RESPONSE_DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%S.%fZ" + + +class DigitalFactoryFileResponse(BaseModel): + """Class representing a file in a digital factory project.""" + + def __init__(self, client_id: str, content_type: str, file_id: str, file_name: str, library_project_id: str, + status: str, user_id: str, username: str, uploaded_at: str, download_url: Optional[str] = "", status_description: Optional[str] = "", + file_size: Optional[int] = 0, upload_url: Optional[str] = "", **kwargs) -> None: + """ + Creates a new DF file response object + + :param client_id: + :param content_type: + :param file_id: + :param file_name: + :param library_project_id: + :param status: + :param user_id: + :param username: + :param download_url: + :param status_description: + :param file_size: + :param upload_url: + :param kwargs: + """ + + self.client_id = client_id + self.content_type = content_type + self.download_url = download_url + self.file_id = file_id + self.file_name = file_name + self.file_size = file_size + self.library_project_id = library_project_id + self.status = status + self.status_description = status_description + self.upload_url = upload_url + self.user_id = user_id + self.username = username + self.uploaded_at = datetime.strptime(uploaded_at, DIGITAL_FACTORY_RESPONSE_DATETIME_FORMAT) + super().__init__(**kwargs) + + def __repr__(self) -> str: + return "File: {}, from: {}, File ID: {}, Project ID: {}, Download URL: {}".format(self.file_name, self.username, self.file_id, self.library_project_id, self.download_url) + + # Validates the model, raising an exception if the model is invalid. + def validate(self) -> None: + super().validate() + if not self.file_id: + raise ValueError("file_id is required in Digital Library file") diff --git a/plugins/DigitalLibrary/src/DigitalFactoryOutputDevice.py b/plugins/DigitalLibrary/src/DigitalFactoryOutputDevice.py new file mode 100644 index 0000000000..852c565b5e --- /dev/null +++ b/plugins/DigitalLibrary/src/DigitalFactoryOutputDevice.py @@ -0,0 +1,114 @@ +# Copyright (c) 2021 Ultimaker B.V. +# Uranium is released under the terms of the LGPLv3 or higher. +import os +from typing import Optional, List + +from UM.FileHandler.FileHandler import FileHandler +from UM.Logger import Logger +from UM.OutputDevice import OutputDeviceError +from UM.OutputDevice.ProjectOutputDevice import ProjectOutputDevice +from UM.Scene.SceneNode import SceneNode +from cura.API import Account +from cura.CuraApplication import CuraApplication +from .DigitalFactoryController import DigitalFactoryController + + +class DigitalFactoryOutputDevice(ProjectOutputDevice): + """Implements an OutputDevice that supports saving to the digital factory library.""" + + def __init__(self, plugin_id, df_controller: DigitalFactoryController, add_to_output_devices: bool = False, parent = None) -> None: + super().__init__(device_id = "digital_factory", add_to_output_devices = add_to_output_devices, parent = parent) + + self.setName("Digital Library") # Doesn't need to be translated + self.setShortDescription("Save to Library") + self.setDescription("Save to Library") + self.setIconName("save") + self.menu_entry_text = "To Digital Library" + self.shortcut = "Ctrl+Shift+S" + self._plugin_id = plugin_id + self._controller = df_controller + + plugin_path = os.path.dirname(os.path.dirname(__file__)) + self._dialog_path = os.path.join(plugin_path, "resources", "qml", "DigitalFactorySaveDialog.qml") + self._dialog = None + + # Connect the write signals + self._controller.uploadStarted.connect(self._onWriteStarted) + self._controller.uploadFileProgress.connect(self.writeProgress.emit) + self._controller.uploadFileError.connect(self._onWriteError) + self._controller.uploadFileSuccess.connect(self.writeSuccess.emit) + self._controller.uploadFileFinished.connect(self._onWriteFinished) + + self._priority = -1 # Negative value to ensure that it will have less priority than the LocalFileOutputDevice (which has 0) + self._application = CuraApplication.getInstance() + + self._writing = False + + self._account = CuraApplication.getInstance().getCuraAPI().account # type: Account + self._account.loginStateChanged.connect(self._onLoginStateChanged) + self.enabled = self._account.isLoggedIn and self._controller.userAccountHasLibraryAccess() + + self._current_workspace_information = CuraApplication.getInstance().getCurrentWorkspaceInformation() + + def requestWrite(self, nodes: List[SceneNode], file_name: Optional[str] = None, limit_mimetypes: bool = False, file_handler: Optional[FileHandler] = None, **kwargs) -> None: + """Request the specified nodes to be written. + + Function called every time the 'To Digital Factory' option of the 'Save Project' submenu is triggered or when the + "Save to Library" action button is pressed (upon slicing). + + :param nodes: A collection of scene nodes that should be written to the file. + :param file_name: A suggestion for the file name to write to. + :param limit_mimetypes: Limit the possible mimetypes to use for writing to these types. + :param file_handler: The handler responsible for reading and writing mesh files. + :param kwargs: Keyword arguments. + """ + + if self._writing: + raise OutputDeviceError.DeviceBusyError() + self.loadWindow() + + if self._account.isLoggedIn and self._controller.userAccountHasLibraryAccess(): + self._controller.nodes = nodes + + df_workspace_information = self._current_workspace_information.getPluginMetadata("digital_factory") + self._controller.initialize(preselected_project_id = df_workspace_information.get("library_project_id")) + self._dialog.show() + + def loadWindow(self) -> None: + """ + Create the GUI window for the Digital Library Save dialog. If the window is already open, bring the focus on it. + """ + + if self._dialog: # Dialogue is already open. + self._dialog.requestActivate() # Bring the focus on the dialogue. + return + + if not self._controller.file_handlers: + self._controller.file_handlers = { + "3mf": CuraApplication.getInstance().getWorkspaceFileHandler(), + "ufp": CuraApplication.getInstance().getMeshFileHandler() + } + + self._dialog = CuraApplication.getInstance().createQmlComponent(self._dialog_path, {"manager": self._controller}) + if not self._dialog: + Logger.log("e", "Unable to create the Digital Library Save dialog.") + + def _onLoginStateChanged(self, logged_in: bool) -> None: + """ + Sets the enabled status of the DigitalFactoryOutputDevice according to the account's login status + :param logged_in: The new login status + """ + self.enabled = logged_in and self._controller.userAccountHasLibraryAccess() + self.enabledChanged.emit() + + def _onWriteStarted(self) -> None: + self._writing = True + self.writeStarted.emit(self) + + def _onWriteFinished(self) -> None: + self._writing = False + self.writeFinished.emit(self) + + def _onWriteError(self) -> None: + self._writing = False + self.writeError.emit(self) diff --git a/plugins/DigitalLibrary/src/DigitalFactoryOutputDevicePlugin.py b/plugins/DigitalLibrary/src/DigitalFactoryOutputDevicePlugin.py new file mode 100644 index 0000000000..1a0e4f2772 --- /dev/null +++ b/plugins/DigitalLibrary/src/DigitalFactoryOutputDevicePlugin.py @@ -0,0 +1,18 @@ +# Copyright (c) 2021 Ultimaker B.V. +# Uranium is released under the terms of the LGPLv3 or higher. + +from UM.OutputDevice.OutputDevicePlugin import OutputDevicePlugin +from .DigitalFactoryOutputDevice import DigitalFactoryOutputDevice +from .DigitalFactoryController import DigitalFactoryController + + +class DigitalFactoryOutputDevicePlugin(OutputDevicePlugin): + def __init__(self, df_controller: DigitalFactoryController) -> None: + super().__init__() + self.df_controller = df_controller + + def start(self) -> None: + self.getOutputDeviceManager().addProjectOutputDevice(DigitalFactoryOutputDevice(plugin_id = self.getPluginId(), df_controller = self.df_controller, add_to_output_devices = True)) + + def stop(self) -> None: + self.getOutputDeviceManager().removeProjectOutputDevice("digital_factory") diff --git a/plugins/DigitalLibrary/src/DigitalFactoryProjectModel.py b/plugins/DigitalLibrary/src/DigitalFactoryProjectModel.py new file mode 100644 index 0000000000..b35e760998 --- /dev/null +++ b/plugins/DigitalLibrary/src/DigitalFactoryProjectModel.py @@ -0,0 +1,71 @@ +# Copyright (c) 2021 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +from typing import List, Optional + +from PyQt5.QtCore import Qt, pyqtSignal + +from UM.Logger import Logger +from UM.Qt.ListModel import ListModel +from .DigitalFactoryProjectResponse import DigitalFactoryProjectResponse + +PROJECT_UPDATED_AT_DATETIME_FORMAT = "%d-%m-%Y" + + +class DigitalFactoryProjectModel(ListModel): + DisplayNameRole = Qt.UserRole + 1 + LibraryProjectIdRole = Qt.UserRole + 2 + DescriptionRole = Qt.UserRole + 3 + ThumbnailUrlRole = Qt.UserRole + 5 + UsernameRole = Qt.UserRole + 6 + LastUpdatedRole = Qt.UserRole + 7 + + dfProjectModelChanged = pyqtSignal() + + def __init__(self, parent = None): + super().__init__(parent) + self.addRoleName(self.DisplayNameRole, "displayName") + self.addRoleName(self.LibraryProjectIdRole, "libraryProjectId") + self.addRoleName(self.DescriptionRole, "description") + self.addRoleName(self.ThumbnailUrlRole, "thumbnailUrl") + self.addRoleName(self.UsernameRole, "username") + self.addRoleName(self.LastUpdatedRole, "lastUpdated") + self._projects = [] # type: List[DigitalFactoryProjectResponse] + + def setProjects(self, df_projects: List[DigitalFactoryProjectResponse]) -> None: + if self._projects == df_projects: + return + self._items.clear() + self._projects = df_projects + # self.sortProjectsBy("display_name") + self._update(df_projects) + + def extendProjects(self, df_projects: List[DigitalFactoryProjectResponse]) -> None: + if not df_projects: + return + self._projects.extend(df_projects) + # self.sortProjectsBy("display_name") + self._update(df_projects) + + def sortProjectsBy(self, sort_by: Optional[str]): + if sort_by: + try: + self._projects.sort(key = lambda p: getattr(p, sort_by)) + except AttributeError: + Logger.log("e", "The projects cannot be sorted by '{}'. No such attribute exists.".format(sort_by)) + + def clearProjects(self) -> None: + self.clear() + self._projects.clear() + self.dfProjectModelChanged.emit() + + def _update(self, df_projects: List[DigitalFactoryProjectResponse]) -> None: + for project in df_projects: + self.appendItem({ + "displayName" : project.display_name, + "libraryProjectId" : project.library_project_id, + "description": project.description, + "thumbnailUrl": project.thumbnail_url, + "username": project.username, + "lastUpdated": project.last_updated.strftime(PROJECT_UPDATED_AT_DATETIME_FORMAT) if project.last_updated else "", + }) + self.dfProjectModelChanged.emit() diff --git a/plugins/DigitalLibrary/src/DigitalFactoryProjectResponse.py b/plugins/DigitalLibrary/src/DigitalFactoryProjectResponse.py new file mode 100644 index 0000000000..a511a11bd5 --- /dev/null +++ b/plugins/DigitalLibrary/src/DigitalFactoryProjectResponse.py @@ -0,0 +1,65 @@ +# Copyright (c) 2021 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +from datetime import datetime +from typing import Optional, List, Dict, Any + +from .BaseModel import BaseModel +from .DigitalFactoryFileResponse import DIGITAL_FACTORY_RESPONSE_DATETIME_FORMAT + + +class DigitalFactoryProjectResponse(BaseModel): + """Class representing a cloud project.""" + + def __init__(self, + library_project_id: str, + display_name: str, + username: str, + organization_shared: bool, + last_updated: Optional[str] = None, + created_at: Optional[str] = None, + thumbnail_url: Optional[str] = None, + organization_id: Optional[str] = None, + created_by_user_id: Optional[str] = None, + description: Optional[str] = "", + tags: Optional[List[str]] = None, + team_ids: Optional[List[str]] = None, + status: Optional[str] = None, + technical_requirements: Optional[Dict[str, Any]] = None, + **kwargs) -> None: + """ + Creates a new digital factory project response object + :param library_project_id: + :param display_name: + :param username: + :param organization_shared: + :param thumbnail_url: + :param created_by_user_id: + :param description: + :param tags: + :param kwargs: + """ + + self.library_project_id = library_project_id + self.display_name = display_name + self.description = description + self.username = username + self.organization_shared = organization_shared + self.organization_id = organization_id + self.created_by_user_id = created_by_user_id + self.thumbnail_url = thumbnail_url + self.tags = tags + self.team_ids = team_ids + self.created_at = datetime.strptime(created_at, DIGITAL_FACTORY_RESPONSE_DATETIME_FORMAT) if created_at else None + self.last_updated = datetime.strptime(last_updated, DIGITAL_FACTORY_RESPONSE_DATETIME_FORMAT) if last_updated else None + self.status = status + self.technical_requirements = technical_requirements + super().__init__(**kwargs) + + def __str__(self) -> str: + return "Project: {}, Id: {}, from: {}".format(self.display_name, self.library_project_id, self.username) + + # Validates the model, raising an exception if the model is invalid. + def validate(self) -> None: + super().validate() + if not self.library_project_id: + raise ValueError("library_project_id is required on cloud project") diff --git a/plugins/DigitalLibrary/src/ExportFileJob.py b/plugins/DigitalLibrary/src/ExportFileJob.py new file mode 100644 index 0000000000..3e4c6dfea2 --- /dev/null +++ b/plugins/DigitalLibrary/src/ExportFileJob.py @@ -0,0 +1,55 @@ +# Copyright (c) 2021 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. + +import io +from typing import List, Optional, Union + +from UM.FileHandler.FileHandler import FileHandler +from UM.FileHandler.FileWriter import FileWriter +from UM.FileHandler.WriteFileJob import WriteFileJob +from UM.Logger import Logger +from UM.MimeTypeDatabase import MimeTypeDatabase +from UM.OutputDevice import OutputDeviceError +from UM.Scene.SceneNode import SceneNode + + +class ExportFileJob(WriteFileJob): + """Job that exports the build plate to the correct file format for the Digital Factory Library project.""" + + def __init__(self, file_handler: FileHandler, nodes: List[SceneNode], job_name: str, extension: str) -> None: + file_types = file_handler.getSupportedFileTypesWrite() + if len(file_types) == 0: + Logger.log("e", "There are no file types available to write with!") + raise OutputDeviceError.WriteRequestFailedError("There are no file types available to write with!") + + mode = None + file_writer = None + for file_type in file_types: + if file_type["extension"] == extension: + file_writer = file_handler.getWriter(file_type["id"]) + mode = file_type.get("mode") + super().__init__(file_writer, self.createStream(mode = mode), nodes, mode) + + # Determine the filename. + self.setFileName("{}.{}".format(job_name, extension)) + + def getOutput(self) -> bytes: + """Get the job result as bytes as that is what we need to upload to the Digital Factory Library.""" + + output = self.getStream().getvalue() + if isinstance(output, str): + output = output.encode("utf-8") + return output + + def getMimeType(self) -> str: + """Get the mime type of the selected export file type.""" + return MimeTypeDatabase.getMimeTypeForFile(self.getFileName()).name + + @staticmethod + def createStream(mode) -> Union[io.BytesIO, io.StringIO]: + """Creates the right kind of stream based on the preferred format.""" + + if mode == FileWriter.OutputMode.TextMode: + return io.StringIO() + else: + return io.BytesIO() diff --git a/plugins/DigitalLibrary/src/PaginationLinks.py b/plugins/DigitalLibrary/src/PaginationLinks.py new file mode 100644 index 0000000000..06ed183944 --- /dev/null +++ b/plugins/DigitalLibrary/src/PaginationLinks.py @@ -0,0 +1,30 @@ +# Copyright (c) 2021 Ultimaker B.V. + +from typing import Optional + + +class PaginationLinks: + """Model containing pagination links.""" + + def __init__(self, + first: Optional[str] = None, + last: Optional[str] = None, + next: Optional[str] = None, + prev: Optional[str] = None, + **kwargs) -> None: + """ + Creates a new digital factory project response object + :param first: The URL for the first page. + :param last: The URL for the last page. + :param next: The URL for the next page. + :param prev: The URL for the prev page. + :param kwargs: + """ + + self.first_page = first + self.last_page = last + self.next_page = next + self.prev_page = prev + + def __str__(self) -> str: + return "Pagination Links | First: {}, Last: {}, Next: {}, Prev: {}".format(self.first_page, self.last_page, self.next_page, self.prev_page) diff --git a/plugins/DigitalLibrary/src/PaginationManager.py b/plugins/DigitalLibrary/src/PaginationManager.py new file mode 100644 index 0000000000..f2b7c8f5bd --- /dev/null +++ b/plugins/DigitalLibrary/src/PaginationManager.py @@ -0,0 +1,43 @@ +# Copyright (c) 2021 Ultimaker B.V. + +from typing import Optional, Dict, Any + +from .PaginationLinks import PaginationLinks +from .PaginationMetadata import PaginationMetadata +from .ResponseMeta import ResponseMeta + + +class PaginationManager: + + def __init__(self, limit: int) -> None: + self.limit = limit # The limit of items per page + self.meta = None # type: Optional[ResponseMeta] # The metadata of the paginated response + self.links = None # type: Optional[PaginationLinks] # The pagination-related links + + def setResponseMeta(self, meta: Optional[Dict[str, Any]]) -> None: + self.meta = None + + if meta: + page = None + if "page" in meta: + page = PaginationMetadata(**meta["page"]) + self.meta = ResponseMeta(page) + + def setLinks(self, links: Optional[Dict[str, str]]) -> None: + self.links = PaginationLinks(**links) if links else None + + def setLimit(self, new_limit: int) -> None: + """ + Sets the limit of items per page. + + :param new_limit: The new limit of items per page + """ + self.limit = new_limit + self.reset() + + def reset(self) -> None: + """ + Sets the metadata and links to None. + """ + self.meta = None + self.links = None diff --git a/plugins/DigitalLibrary/src/PaginationMetadata.py b/plugins/DigitalLibrary/src/PaginationMetadata.py new file mode 100644 index 0000000000..7f11e43d30 --- /dev/null +++ b/plugins/DigitalLibrary/src/PaginationMetadata.py @@ -0,0 +1,25 @@ +# Copyright (c) 2021 Ultimaker B.V. + +from typing import Optional + + +class PaginationMetadata: + """Class representing the metadata related to pagination.""" + + def __init__(self, + total_count: Optional[int] = None, + total_pages: Optional[int] = None, + **kwargs) -> None: + """ + Creates a new digital factory project response object + :param total_count: The total count of items. + :param total_pages: The total number of pages when pagination is applied. + :param kwargs: + """ + + self.total_count = total_count + self.total_pages = total_pages + self.__dict__.update(kwargs) + + def __str__(self) -> str: + return "PaginationMetadata | Total Count: {}, Total Pages: {}".format(self.total_count, self.total_pages) diff --git a/plugins/DigitalLibrary/src/ResponseMeta.py b/plugins/DigitalLibrary/src/ResponseMeta.py new file mode 100644 index 0000000000..a1dbc949db --- /dev/null +++ b/plugins/DigitalLibrary/src/ResponseMeta.py @@ -0,0 +1,24 @@ +# Copyright (c) 2021 Ultimaker B.V. + +from typing import Optional + +from .PaginationMetadata import PaginationMetadata + + +class ResponseMeta: + """Class representing the metadata included in a Digital Library response (if any)""" + + def __init__(self, + page: Optional[PaginationMetadata] = None, + **kwargs) -> None: + """ + Creates a new digital factory project response object + :param page: Metadata related to pagination + :param kwargs: + """ + + self.page = page + self.__dict__.update(kwargs) + + def __str__(self) -> str: + return "Response Meta | {}".format(self.page) diff --git a/plugins/DigitalLibrary/src/__init__.py b/plugins/DigitalLibrary/src/__init__.py new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/plugins/DigitalLibrary/src/__init__.py |