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