Welcome to mirror list, hosted at ThFree Co, Russian Federation.

github.com/Ultimaker/Cura.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorKostas Karmas <konskarm@gmail.com>2021-04-20 17:09:54 +0300
committerKostas Karmas <konskarm@gmail.com>2021-04-20 17:09:54 +0300
commit5b74090dce6c4d2f268f869b03a7a39c3c4f20f2 (patch)
tree6f0b6e357219dfff30a11c7f5c8d4e2a56197258
parent196c8913311a8c6a2d033620df0b6d70d4d132b4 (diff)
parent0db033a6907b83e92645e4ac7b8f7532388745ca (diff)
Merge branch 'df49' into 4.94.9.0
-rw-r--r--plugins/DigitalLibrary/__init__.py17
-rw-r--r--plugins/DigitalLibrary/plugin.json8
-rw-r--r--plugins/DigitalLibrary/resources/images/arrow_down.svg6
-rw-r--r--plugins/DigitalLibrary/resources/images/digital_factory.svg1
-rw-r--r--plugins/DigitalLibrary/resources/images/placeholder.svg3
-rw-r--r--plugins/DigitalLibrary/resources/images/update.svg9
-rw-r--r--plugins/DigitalLibrary/resources/qml/CreateNewProjectPopup.qml159
-rw-r--r--plugins/DigitalLibrary/resources/qml/DigitalFactoryOpenDialog.qml61
-rw-r--r--plugins/DigitalLibrary/resources/qml/DigitalFactorySaveDialog.qml62
-rw-r--r--plugins/DigitalLibrary/resources/qml/LoadMoreProjectsCard.qml129
-rw-r--r--plugins/DigitalLibrary/resources/qml/OpenProjectFilesPage.qml198
-rw-r--r--plugins/DigitalLibrary/resources/qml/ProjectSummaryCard.qml92
-rw-r--r--plugins/DigitalLibrary/resources/qml/SaveProjectFilesPage.qml259
-rw-r--r--plugins/DigitalLibrary/resources/qml/SelectProjectPage.qml202
-rw-r--r--plugins/DigitalLibrary/src/BaseModel.py74
-rw-r--r--plugins/DigitalLibrary/src/CloudError.py31
-rw-r--r--plugins/DigitalLibrary/src/DFFileExportAndUploadManager.py361
-rw-r--r--plugins/DigitalLibrary/src/DFFileUploader.py147
-rw-r--r--plugins/DigitalLibrary/src/DFLibraryFileUploadRequest.py16
-rw-r--r--plugins/DigitalLibrary/src/DFLibraryFileUploadResponse.py49
-rw-r--r--plugins/DigitalLibrary/src/DFPrintJobUploadRequest.py21
-rw-r--r--plugins/DigitalLibrary/src/DFPrintJobUploadResponse.py35
-rw-r--r--plugins/DigitalLibrary/src/DigitalFactoryApiClient.py317
-rw-r--r--plugins/DigitalLibrary/src/DigitalFactoryController.py563
-rw-r--r--plugins/DigitalLibrary/src/DigitalFactoryFileModel.py115
-rw-r--r--plugins/DigitalLibrary/src/DigitalFactoryFileProvider.py58
-rw-r--r--plugins/DigitalLibrary/src/DigitalFactoryFileResponse.py57
-rw-r--r--plugins/DigitalLibrary/src/DigitalFactoryOutputDevice.py114
-rw-r--r--plugins/DigitalLibrary/src/DigitalFactoryOutputDevicePlugin.py18
-rw-r--r--plugins/DigitalLibrary/src/DigitalFactoryProjectModel.py71
-rw-r--r--plugins/DigitalLibrary/src/DigitalFactoryProjectResponse.py65
-rw-r--r--plugins/DigitalLibrary/src/ExportFileJob.py55
-rw-r--r--plugins/DigitalLibrary/src/PaginationLinks.py30
-rw-r--r--plugins/DigitalLibrary/src/PaginationManager.py43
-rw-r--r--plugins/DigitalLibrary/src/PaginationMetadata.py25
-rw-r--r--plugins/DigitalLibrary/src/ResponseMeta.py24
-rw-r--r--plugins/DigitalLibrary/src/__init__.py0
-rw-r--r--resources/bundled_packages/cura.json17
-rw-r--r--resources/images/whats_new/0.pngbin1023258 -> 158042 bytes
-rw-r--r--resources/images/whats_new/2.pngbin514323 -> 901170 bytes
-rw-r--r--resources/images/whats_new/3.gifbin0 -> 7565524 bytes
-rw-r--r--resources/texts/change_log.txt20
-rw-r--r--resources/texts/whats_new/0.html5
-rw-r--r--resources/texts/whats_new/2.html7
-rw-r--r--resources/texts/whats_new/3.html3
45 files changed, 3531 insertions, 16 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
diff --git a/resources/bundled_packages/cura.json b/resources/bundled_packages/cura.json
index 77d7d001ad..ccb301ce4a 100644
--- a/resources/bundled_packages/cura.json
+++ b/resources/bundled_packages/cura.json
@@ -118,6 +118,23 @@
}
}
},
+ "DigitalLibrary": {
+ "package_info": {
+ "package_id": "DigitalLibrary",
+ "package_type": "plugin",
+ "display_name": "Ultimaker Digital Library",
+ "description": "Connects to the Digital Library, allowing Cura to open files from and save files to the Digital Library.",
+ "package_version": "1.0.0",
+ "sdk_version": "7.5.0",
+ "website": "https://ultimaker.com",
+ "author": {
+ "author_id": "UltimakerPackages",
+ "display_name": "Ultimaker B.V.",
+ "email": "plugins@ultimaker.com",
+ "website": "https://ultimaker.com"
+ }
+ }
+ },
"FirmwareUpdateChecker": {
"package_info": {
"package_id": "FirmwareUpdateChecker",
diff --git a/resources/images/whats_new/0.png b/resources/images/whats_new/0.png
index 6fbc4f3f85..873ae03e0a 100644
--- a/resources/images/whats_new/0.png
+++ b/resources/images/whats_new/0.png
Binary files differ
diff --git a/resources/images/whats_new/2.png b/resources/images/whats_new/2.png
index 3f6559f29e..ccf694b67a 100644
--- a/resources/images/whats_new/2.png
+++ b/resources/images/whats_new/2.png
Binary files differ
diff --git a/resources/images/whats_new/3.gif b/resources/images/whats_new/3.gif
new file mode 100644
index 0000000000..8db3ef9666
--- /dev/null
+++ b/resources/images/whats_new/3.gif
Binary files differ
diff --git a/resources/texts/change_log.txt b/resources/texts/change_log.txt
index b9fd5c32ee..13d268767f 100644
--- a/resources/texts/change_log.txt
+++ b/resources/texts/change_log.txt
@@ -1,4 +1,8 @@
-[4.9.0 Beta]
+[4.9.0]
+<i>For an overview of the new features in Cura 4.9, please watch <a href="https://www.youtube.com/watch?app=desktop&v=q7IJ0I9Mi1I&feature=youtu.be">our video</a>.</i>
+
+* Digital factory integration.
+Now you can open files directly from Digital Library projects. Then, after preparation, quickly and easily export them back. This feature is available for all users with an Ultimaker Essentials, Professional, or Excellence subscription. <a href="https://ultimaker.com/software/enterprise-software"> Learn more</a>
* "Line type" is now the default color scheme.
When entering the Preview mode, you don't have to switch manually to line type.
@@ -20,19 +24,11 @@ The shell category was a mix of settings about walls and settings about top/bott
The ability to have thumbnails embedded. Contributed by Gravedigger7789.
* Add checkbox for Extruder Offsets.
-Ability to enable or disable the extruder offsets to gcode. This will be enabled by default, unless it is in the printer's def.json file. Contributed by RFBomb.
+Ability to enable or disable the extruder offsets to gcode. This will be enabled by default, unless it is in the printer's def.json file. Contributed by RFBomb.
* Cura should work properly on MacOS 'Big Sur' now, afforded by upgrades to Python (to 3.8) and Qt (to 5.15).
If you had (UX, visual, graphics card) problems, specifically on (newer) MacOS versions, like Big Sur, you should be able to use this new version.
-* Known UX issues that will be fixed before final in our current plan
-- Custom menu Materials and Nozzle menu now open at cursor position instead of under the menu button.
-- Visuals of Preference screen are large.
-- Drop downs in Preference screen don't react to mouse-scroll.
-- Default language not selected in Preference screen.
-- Changelog takes long too load.
-- Setting Visibility submenu items in the Preference screen are greyed-out and can't be selected on Mac OSX.
-
* Bug Fixes
- Fixed a security vulnerability on windows permitting the openssl library used to launch other programs. Thanks to Xavier Danest for raising this bug.
- Fixed Connect Top/Bottom Polygons.
@@ -63,7 +59,8 @@ If you had (UX, visual, graphics card) problems, specifically on (newer) MacOS v
- Fixed message for non manifold models.
- Fixed setting category arrows. Contributed by fieldOfView.
- Fixed metadate tags for 3MF files.
-- Fixed engine crash when using low-density Cross Infill
+- Fixed engine crash when using low-density Cross Infill.
+- Improved performance of loading .def.json files.
* Printer definitions, profiles and materials
- 3DFuel Pro PLA and SnapSupport materials, contributed by grk3010.
@@ -87,6 +84,7 @@ If you had (UX, visual, graphics card) problems, specifically on (newer) MacOS v
- TwoTrees Bluer, contributed by WashingtonJunior.
- Update Hellbot Magna 1 and Hellbot Magna dual, contributed by DevelopmentHellbot.
- Update Rigid3D and added Rigid3D Mucit2, contributed by mehmetsutas.
+- Update TPU profiles for 0.6mm nozzle of UM2+C.
- ZAV series, contributed by kimer2002.
[4.8.0]
diff --git a/resources/texts/whats_new/0.html b/resources/texts/whats_new/0.html
index ae9560362e..8d1868b043 100644
--- a/resources/texts/whats_new/0.html
+++ b/resources/texts/whats_new/0.html
@@ -1,4 +1,5 @@
-<h4>“Line type” is now the default color scheme.</h4>
+<h4>Seamless workflow with the Digital Library in Ultimaker Digital Factory</h4>
<p>
- This improves your user experience – as you will no longer have to manually switch to “line type” each time you enter Preview mode.
+ Now you can open files directly from Digital Library projects. Then, after preparation, quickly and easily export them back. This feature is available for all users with an Ultimaker Essentials, Professional, or Excellence subscription.
+ <a href="https://ultimaker.com/software/enterprise-software"> Learn more</a>
</p>
diff --git a/resources/texts/whats_new/2.html b/resources/texts/whats_new/2.html
index 55142cdbcd..ae9560362e 100644
--- a/resources/texts/whats_new/2.html
+++ b/resources/texts/whats_new/2.html
@@ -1,3 +1,4 @@
-<h4>There is a lot more..</h4>
-<p>Want more information on new features, bug fixes, and more for Ultimaker Cura 4.9 beta? </br>
-Read the full blog post <a href="https://ultimaker.com/learn/increased-first-time-right-with-ultimaker-cura-4-9-beta"> here</a>. And don't forget to give us your feedback on <a href="https://github.com/Ultimaker/Cura/issues"> Github!</a></p>
+<h4>“Line type” is now the default color scheme.</h4>
+<p>
+ This improves your user experience – as you will no longer have to manually switch to “line type” each time you enter Preview mode.
+</p>
diff --git a/resources/texts/whats_new/3.html b/resources/texts/whats_new/3.html
new file mode 100644
index 0000000000..9e90b01e2c
--- /dev/null
+++ b/resources/texts/whats_new/3.html
@@ -0,0 +1,3 @@
+<h4>Learn more</h4>
+<p>Want more information for Ultimaker Cura 4.9?</br>
+Read the <a href="https://ultimaker.com/learn/ultimaker-cura-4-9-seamless-and-efficient-with-digital-library-integration"> blog post</a> or watch the <a href="https://www.youtube.com/watch?app=desktop&v=q7IJ0I9Mi1I&feature=youtu.be"> video</a>. And don't forget to give us your <a href="https://github.com/Ultimaker/Cura/issues"> feedback</a>!</p>