Merge pull request #4960 from Ultimaker/CURA-6005_cura_drive_plugin

[4.0] CURA-6005 Added CuraDrive plugin to Cura build
This commit is contained in:
Remco Burema 2019-01-10 14:58:00 +01:00 committed by GitHub
commit 980953b00c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 1080 additions and 66 deletions

1
.gitignore vendored
View File

@ -42,7 +42,6 @@ plugins/cura-siemensnx-plugin
plugins/CuraBlenderPlugin
plugins/CuraCloudPlugin
plugins/CuraDrivePlugin
plugins/CuraDrive
plugins/CuraLiveScriptingPlugin
plugins/CuraOpenSCADPlugin
plugins/CuraPrintProfileCreator

View File

@ -6,6 +6,7 @@ from PyQt5.QtCore import QObject, pyqtSignal, pyqtSlot, pyqtProperty
from UM.i18n import i18nCatalog
from UM.Message import Message
from cura import UltimakerCloudAuthentication
from cura.OAuth2.AuthorizationService import AuthorizationService
from cura.OAuth2.Models import OAuth2Settings
@ -37,15 +38,16 @@ class Account(QObject):
self._logged_in = False
self._callback_port = 32118
self._oauth_root = "https://account.ultimaker.com"
self._cloud_api_root = "https://api.ultimaker.com"
self._oauth_root = UltimakerCloudAuthentication.CuraCloudAccountAPIRoot
self._oauth_settings = OAuth2Settings(
OAUTH_SERVER_URL= self._oauth_root,
CALLBACK_PORT=self._callback_port,
CALLBACK_URL="http://localhost:{}/callback".format(self._callback_port),
CLIENT_ID="um----------------------------ultimaker_cura",
CLIENT_SCOPES="account.user.read drive.backup.read drive.backup.write packages.download packages.rating.read packages.rating.write",
CLIENT_SCOPES="account.user.read drive.backup.read drive.backup.write packages.download "
"packages.rating.read packages.rating.write connect.cluster.read connect.cluster.write "
"cura.printjob.read cura.printjob.write cura.mesh.read cura.mesh.write",
AUTH_DATA_PREFERENCE_KEY="general/ultimaker_auth_data",
AUTH_SUCCESS_REDIRECT="{}/app/auth-success".format(self._oauth_root),
AUTH_FAILED_REDIRECT="{}/app/auth-error".format(self._oauth_root)

View File

@ -1,6 +1,6 @@
# Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from typing import Tuple, Optional, TYPE_CHECKING
from typing import Tuple, Optional, TYPE_CHECKING, Dict, Any
from cura.Backups.BackupsManager import BackupsManager
@ -24,12 +24,12 @@ class Backups:
## Create a new back-up using the BackupsManager.
# \return Tuple containing a ZIP file with the back-up data and a dict
# with metadata about the back-up.
def createBackup(self) -> Tuple[Optional[bytes], Optional[dict]]:
def createBackup(self) -> Tuple[Optional[bytes], Optional[Dict[str, Any]]]:
return self.manager.createBackup()
## Restore a back-up using the BackupsManager.
# \param zip_file A ZIP file containing the actual back-up data.
# \param meta_data Some metadata needed for restoring a back-up, like the
# Cura version number.
def restoreBackup(self, zip_file: bytes, meta_data: dict) -> None:
def restoreBackup(self, zip_file: bytes, meta_data: Dict[str, Any]) -> None:
return self.manager.restoreBackup(zip_file, meta_data)

View File

@ -0,0 +1,36 @@
# Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
# ---------
# Genearl constants used in Cura
# ---------
DEFAULT_CURA_DISPLAY_NAME = "Ultimaker Cura"
DEFAULT_CURA_VERSION = "master"
DEFAULT_CURA_BUILD_TYPE = ""
DEFAULT_CURA_DEBUG_MODE = False
DEFAULT_CURA_SDK_VERSION = "6.0.0"
try:
from cura.CuraVersion import CuraAppDisplayName # type: ignore
except ImportError:
CuraAppDisplayName = DEFAULT_CURA_DISPLAY_NAME
try:
from cura.CuraVersion import CuraVersion # type: ignore
except ImportError:
CuraVersion = DEFAULT_CURA_VERSION # [CodeStyle: Reflecting imported value]
try:
from cura.CuraVersion import CuraBuildType # type: ignore
except ImportError:
CuraBuildType = DEFAULT_CURA_BUILD_TYPE
try:
from cura.CuraVersion import CuraDebugMode # type: ignore
except ImportError:
CuraDebugMode = DEFAULT_CURA_DEBUG_MODE
try:
from cura.CuraVersion import CuraSDKVersion # type: ignore
except ImportError:
CuraSDKVersion = DEFAULT_CURA_SDK_VERSION

View File

@ -117,6 +117,8 @@ from cura.ObjectsModel import ObjectsModel
from cura.PrinterOutputDevice import PrinterOutputDevice
from cura.PrinterOutput.NetworkMJPGImage import NetworkMJPGImage
from cura import ApplicationMetadata
from UM.FlameProfiler import pyqtSlot
from UM.Decorators import override
@ -164,11 +166,11 @@ class CuraApplication(QtApplication):
def __init__(self, *args, **kwargs):
super().__init__(name = "cura",
app_display_name = CuraAppDisplayName,
version = CuraVersion,
api_version = CuraSDKVersion,
buildtype = CuraBuildType,
is_debug_mode = CuraDebugMode,
app_display_name = ApplicationMetadata.CuraAppDisplayName,
version = ApplicationMetadata.CuraVersion,
api_version = ApplicationMetadata.CuraSDKVersion,
buildtype = ApplicationMetadata.CuraBuildType,
is_debug_mode = ApplicationMetadata.CuraDebugMode,
tray_icon_name = "cura-icon-32.png",
**kwargs)
@ -954,7 +956,7 @@ class CuraApplication(QtApplication):
engine.rootContext().setContextProperty("CuraApplication", self)
engine.rootContext().setContextProperty("PrintInformation", self._print_information)
engine.rootContext().setContextProperty("CuraActions", self._cura_actions)
engine.rootContext().setContextProperty("CuraSDKVersion", CuraSDKVersion)
engine.rootContext().setContextProperty("CuraSDKVersion", ApplicationMetadata.CuraSDKVersion)
qmlRegisterUncreatableType(CuraApplication, "Cura", 1, 0, "ResourceTypes", "Just an Enum type")

View File

@ -8,3 +8,4 @@ CuraDebugMode = True if "@_cura_debugmode@" == "ON" else False
CuraSDKVersion = "@CURA_SDK_VERSION@"
CuraCloudAPIRoot = "@CURA_CLOUD_API_ROOT@"
CuraCloudAPIVersion = "@CURA_CLOUD_API_VERSION@"
CuraCloudAccountAPIRoot = "@CURA_CLOUD_ACCOUNT_API_ROOT@"

View File

@ -0,0 +1,24 @@
# Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
# ---------
# Constants used for the Cloud API
# ---------
DEFAULT_CLOUD_API_ROOT = "https://api.ultimaker.com" # type: str
DEFAULT_CLOUD_API_VERSION = 1 # type: int
DEFAULT_CLOUD_ACCOUNT_API_ROOT = "https://account.ultimaker.com" # type: str
try:
from cura.CuraVersion import CuraCloudAPIRoot # type: ignore
except ImportError:
CuraCloudAPIRoot = DEFAULT_CLOUD_API_ROOT
try:
from cura.CuraVersion import CuraCloudAPIVersion # type: ignore
except ImportError:
CuraCloudAPIVersion = DEFAULT_CLOUD_API_VERSION
try:
from cura.CuraVersion import CuraCloudAccountAPIRoot # type: ignore
except ImportError:
CuraCloudAccountAPIRoot = DEFAULT_CLOUD_ACCOUNT_API_ROOT

View File

@ -0,0 +1,12 @@
# Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from .src.DrivePluginExtension import DrivePluginExtension
def getMetaData():
return {}
def register(app):
return {"extension": DrivePluginExtension()}

View File

@ -0,0 +1,8 @@
{
"name": "Cura Backups",
"author": "Ultimaker B.V.",
"description": "Backup and restore your configuration.",
"version": "1.2.0",
"api": 6,
"i18n-catalog": "cura"
}

View File

@ -0,0 +1,168 @@
# Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
import base64
import hashlib
from datetime import datetime
from tempfile import NamedTemporaryFile
from typing import Any, Optional, List, Dict
import requests
from UM.Logger import Logger
from UM.Message import Message
from UM.Signal import Signal, signalemitter
from cura.CuraApplication import CuraApplication
from .UploadBackupJob import UploadBackupJob
from .Settings import Settings
from UM.i18n import i18nCatalog
catalog = i18nCatalog("cura")
## The DriveApiService is responsible for interacting with the CuraDrive API and Cura's backup handling.
@signalemitter
class DriveApiService:
BACKUP_URL = "{}/backups".format(Settings.DRIVE_API_URL)
# Emit signal when restoring backup started or finished.
restoringStateChanged = Signal()
# Emit signal when creating backup started or finished.
creatingStateChanged = Signal()
def __init__(self) -> None:
self._cura_api = CuraApplication.getInstance().getCuraAPI()
def getBackups(self) -> List[Dict[str, Any]]:
access_token = self._cura_api.account.accessToken
if not access_token:
Logger.log("w", "Could not get access token.")
return []
backup_list_request = requests.get(self.BACKUP_URL, headers = {
"Authorization": "Bearer {}".format(access_token)
})
# HTTP status 300s mean redirection. 400s and 500s are errors.
# Technically 300s are not errors, but the use case here relies on "requests" to handle redirects automatically.
if backup_list_request.status_code >= 300:
Logger.log("w", "Could not get backups list from remote: %s", backup_list_request.text)
Message(catalog.i18nc("@info:backup_status", "There was an error listing your backups."), title = catalog.i18nc("@info:title", "Backup")).show()
return []
return backup_list_request.json()["data"]
def createBackup(self) -> None:
self.creatingStateChanged.emit(is_creating = True)
# Create the backup.
backup_zip_file, backup_meta_data = self._cura_api.backups.createBackup()
if not backup_zip_file or not backup_meta_data:
self.creatingStateChanged.emit(is_creating = False, error_message ="Could not create backup.")
return
# Create an upload entry for the backup.
timestamp = datetime.now().isoformat()
backup_meta_data["description"] = "{}.backup.{}.cura.zip".format(timestamp, backup_meta_data["cura_release"])
backup_upload_url = self._requestBackupUpload(backup_meta_data, len(backup_zip_file))
if not backup_upload_url:
self.creatingStateChanged.emit(is_creating = False, error_message ="Could not upload backup.")
return
# Upload the backup to storage.
upload_backup_job = UploadBackupJob(backup_upload_url, backup_zip_file)
upload_backup_job.finished.connect(self._onUploadFinished)
upload_backup_job.start()
def _onUploadFinished(self, job: "UploadBackupJob") -> None:
if job.backup_upload_error_message != "":
# If the job contains an error message we pass it along so the UI can display it.
self.creatingStateChanged.emit(is_creating = False, error_message = job.backup_upload_error_message)
else:
self.creatingStateChanged.emit(is_creating = False)
def restoreBackup(self, backup: Dict[str, Any]) -> None:
self.restoringStateChanged.emit(is_restoring = True)
download_url = backup.get("download_url")
if not download_url:
# If there is no download URL, we can't restore the backup.
return self._emitRestoreError()
download_package = requests.get(download_url, stream = True)
if download_package.status_code >= 300:
# Something went wrong when attempting to download the backup.
Logger.log("w", "Could not download backup from url %s: %s", download_url, download_package.text)
return self._emitRestoreError()
# We store the file in a temporary path fist to ensure integrity.
temporary_backup_file = NamedTemporaryFile(delete = False)
with open(temporary_backup_file.name, "wb") as write_backup:
for chunk in download_package:
write_backup.write(chunk)
if not self._verifyMd5Hash(temporary_backup_file.name, backup.get("md5_hash", "")):
# Don't restore the backup if the MD5 hashes do not match.
# This can happen if the download was interrupted.
Logger.log("w", "Remote and local MD5 hashes do not match, not restoring backup.")
return self._emitRestoreError()
# Tell Cura to place the backup back in the user data folder.
with open(temporary_backup_file.name, "rb") as read_backup:
self._cura_api.backups.restoreBackup(read_backup.read(), backup.get("metadata", {}))
self.restoringStateChanged.emit(is_restoring = False)
def _emitRestoreError(self) -> None:
self.restoringStateChanged.emit(is_restoring = False,
error_message = catalog.i18nc("@info:backup_status",
"There was an error trying to restore your backup."))
# Verify the MD5 hash of a file.
# \param file_path Full path to the file.
# \param known_hash The known MD5 hash of the file.
# \return: Success or not.
@staticmethod
def _verifyMd5Hash(file_path: str, known_hash: str) -> bool:
with open(file_path, "rb") as read_backup:
local_md5_hash = base64.b64encode(hashlib.md5(read_backup.read()).digest(), altchars = b"_-").decode("utf-8")
return known_hash == local_md5_hash
def deleteBackup(self, backup_id: str) -> bool:
access_token = self._cura_api.account.accessToken
if not access_token:
Logger.log("w", "Could not get access token.")
return False
delete_backup = requests.delete("{}/{}".format(self.BACKUP_URL, backup_id), headers = {
"Authorization": "Bearer {}".format(access_token)
})
if delete_backup.status_code >= 300:
Logger.log("w", "Could not delete backup: %s", delete_backup.text)
return False
return True
# Request a backup upload slot from the API.
# \param backup_metadata: A dict containing some meta data about the backup.
# \param backup_size The size of the backup file in bytes.
# \return: The upload URL for the actual backup file if successful, otherwise None.
def _requestBackupUpload(self, backup_metadata: Dict[str, Any], backup_size: int) -> Optional[str]:
access_token = self._cura_api.account.accessToken
if not access_token:
Logger.log("w", "Could not get access token.")
return None
backup_upload_request = requests.put(self.BACKUP_URL, json = {
"data": {
"backup_size": backup_size,
"metadata": backup_metadata
}
}, headers = {
"Authorization": "Bearer {}".format(access_token)
})
# Any status code of 300 or above indicates an error.
if backup_upload_request.status_code >= 300:
Logger.log("w", "Could not request backup upload: %s", backup_upload_request.text)
return None
return backup_upload_request.json()["data"]["upload_url"]

View File

@ -0,0 +1,162 @@
# Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
import os
from datetime import datetime
from typing import Optional, List, Dict, Any
from PyQt5.QtCore import QObject, pyqtSlot, pyqtProperty, pyqtSignal
from UM.Extension import Extension
from UM.Logger import Logger
from UM.Message import Message
from cura.CuraApplication import CuraApplication
from .Settings import Settings
from .DriveApiService import DriveApiService
from UM.i18n import i18nCatalog
catalog = i18nCatalog("cura")
# The DivePluginExtension provides functionality to backup and restore your Cura configuration to Ultimaker's cloud.
class DrivePluginExtension(QObject, Extension):
# Signal emitted when the list of backups changed.
backupsChanged = pyqtSignal()
# Signal emitted when restoring has started. Needed to prevent parallel restoring.
restoringStateChanged = pyqtSignal()
# Signal emitted when creating has started. Needed to prevent parallel creation of backups.
creatingStateChanged = pyqtSignal()
# Signal emitted when preferences changed (like auto-backup).
preferencesChanged = pyqtSignal()
DATE_FORMAT = "%d/%m/%Y %H:%M:%S"
def __init__(self) -> None:
QObject.__init__(self, None)
Extension.__init__(self)
# Local data caching for the UI.
self._drive_window = None # type: Optional[QObject]
self._backups = [] # type: List[Dict[str, Any]]
self._is_restoring_backup = False
self._is_creating_backup = False
# Initialize services.
preferences = CuraApplication.getInstance().getPreferences()
self._drive_api_service = DriveApiService()
# Attach signals.
CuraApplication.getInstance().getCuraAPI().account.loginStateChanged.connect(self._onLoginStateChanged)
self._drive_api_service.restoringStateChanged.connect(self._onRestoringStateChanged)
self._drive_api_service.creatingStateChanged.connect(self._onCreatingStateChanged)
# Register preferences.
preferences.addPreference(Settings.AUTO_BACKUP_ENABLED_PREFERENCE_KEY, False)
preferences.addPreference(Settings.AUTO_BACKUP_LAST_DATE_PREFERENCE_KEY,
datetime.now().strftime(self.DATE_FORMAT))
# Register the menu item
self.addMenuItem(catalog.i18nc("@item:inmenu", "Manage backups"), self.showDriveWindow)
# Make auto-backup on boot if required.
CuraApplication.getInstance().engineCreatedSignal.connect(self._autoBackup)
def showDriveWindow(self) -> None:
if not self._drive_window:
plugin_dir_path = CuraApplication.getInstance().getPluginRegistry().getPluginPath("CuraDrive")
path = os.path.join(plugin_dir_path, "src", "qml", "main.qml")
self._drive_window = CuraApplication.getInstance().createQmlComponent(path, {"CuraDrive": self})
self.refreshBackups()
if self._drive_window:
self._drive_window.show()
def _autoBackup(self) -> None:
preferences = CuraApplication.getInstance().getPreferences()
if preferences.getValue(Settings.AUTO_BACKUP_ENABLED_PREFERENCE_KEY) and self._isLastBackupTooLongAgo():
self.createBackup()
def _isLastBackupTooLongAgo(self) -> bool:
current_date = datetime.now()
last_backup_date = self._getLastBackupDate()
date_diff = current_date - last_backup_date
return date_diff.days > 1
def _getLastBackupDate(self) -> "datetime":
preferences = CuraApplication.getInstance().getPreferences()
last_backup_date = preferences.getValue(Settings.AUTO_BACKUP_LAST_DATE_PREFERENCE_KEY)
return datetime.strptime(last_backup_date, self.DATE_FORMAT)
def _storeBackupDate(self) -> None:
backup_date = datetime.now().strftime(self.DATE_FORMAT)
preferences = CuraApplication.getInstance().getPreferences()
preferences.setValue(Settings.AUTO_BACKUP_LAST_DATE_PREFERENCE_KEY, backup_date)
def _onLoginStateChanged(self, logged_in: bool = False) -> None:
if logged_in:
self.refreshBackups()
def _onRestoringStateChanged(self, is_restoring: bool = False, error_message: str = None) -> None:
self._is_restoring_backup = is_restoring
self.restoringStateChanged.emit()
if error_message:
Message(error_message, title = catalog.i18nc("@info:title", "Backup")).show()
def _onCreatingStateChanged(self, is_creating: bool = False, error_message: str = None) -> None:
self._is_creating_backup = is_creating
self.creatingStateChanged.emit()
if error_message:
Message(error_message, title = catalog.i18nc("@info:title", "Backup")).show()
else:
self._storeBackupDate()
if not is_creating and not error_message:
# We've finished creating a new backup, to the list has to be updated.
self.refreshBackups()
@pyqtSlot(bool, name = "toggleAutoBackup")
def toggleAutoBackup(self, enabled: bool) -> None:
preferences = CuraApplication.getInstance().getPreferences()
preferences.setValue(Settings.AUTO_BACKUP_ENABLED_PREFERENCE_KEY, enabled)
@pyqtProperty(bool, notify = preferencesChanged)
def autoBackupEnabled(self) -> bool:
preferences = CuraApplication.getInstance().getPreferences()
return bool(preferences.getValue(Settings.AUTO_BACKUP_ENABLED_PREFERENCE_KEY))
@pyqtProperty("QVariantList", notify = backupsChanged)
def backups(self) -> List[Dict[str, Any]]:
return self._backups
@pyqtSlot(name = "refreshBackups")
def refreshBackups(self) -> None:
self._backups = self._drive_api_service.getBackups()
self.backupsChanged.emit()
@pyqtProperty(bool, notify = restoringStateChanged)
def isRestoringBackup(self) -> bool:
return self._is_restoring_backup
@pyqtProperty(bool, notify = creatingStateChanged)
def isCreatingBackup(self) -> bool:
return self._is_creating_backup
@pyqtSlot(str, name = "restoreBackup")
def restoreBackup(self, backup_id: str) -> None:
for backup in self._backups:
if backup.get("backup_id") == backup_id:
self._drive_api_service.restoreBackup(backup)
return
Logger.log("w", "Unable to find backup with the ID %s", backup_id)
@pyqtSlot(name = "createBackup")
def createBackup(self) -> None:
self._drive_api_service.createBackup()
@pyqtSlot(str, name = "deleteBackup")
def deleteBackup(self, backup_id: str) -> None:
self._drive_api_service.deleteBackup(backup_id)
self.refreshBackups()

View File

@ -0,0 +1,13 @@
# Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from cura import UltimakerCloudAuthentication
class Settings:
# Keeps the plugin settings.
DRIVE_API_VERSION = 1
DRIVE_API_URL = "{}/cura-drive/v{}".format(UltimakerCloudAuthentication.CuraCloudAPIRoot, str(DRIVE_API_VERSION))
AUTO_BACKUP_ENABLED_PREFERENCE_KEY = "cura_drive/auto_backup_enabled"
AUTO_BACKUP_LAST_DATE_PREFERENCE_KEY = "cura_drive/auto_backup_date"

View File

@ -0,0 +1,41 @@
# Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
import requests
from UM.Job import Job
from UM.Logger import Logger
from UM.Message import Message
from UM.i18n import i18nCatalog
catalog = i18nCatalog("cura")
class UploadBackupJob(Job):
MESSAGE_TITLE = catalog.i18nc("@info:title", "Backups")
# This job is responsible for uploading the backup file to cloud storage.
# As it can take longer than some other tasks, we schedule this using a Cura Job.
def __init__(self, signed_upload_url: str, backup_zip: bytes) -> None:
super().__init__()
self._signed_upload_url = signed_upload_url
self._backup_zip = backup_zip
self._upload_success = False
self.backup_upload_error_message = ""
def run(self) -> None:
upload_message = Message(catalog.i18nc("@info:backup_status", "Uploading your backup..."), title = self.MESSAGE_TITLE, progress = -1)
upload_message.show()
backup_upload = requests.put(self._signed_upload_url, data = self._backup_zip)
upload_message.hide()
if backup_upload.status_code >= 300:
self.backup_upload_error_message = backup_upload.text
Logger.log("w", "Could not upload backup file: %s", backup_upload.text)
Message(catalog.i18nc("@info:backup_status", "There was an error while uploading your backup."), title = self.MESSAGE_TITLE).show()
else:
self._upload_success = True
Message(catalog.i18nc("@info:backup_status", "Your backup has finished uploading."), title = self.MESSAGE_TITLE).show()
self.finished.emit(self)

View File

View File

@ -0,0 +1,37 @@
// Copyright (c) 2018 Ultimaker B.V.
// Cura is released under the terms of the LGPLv3 or higher.
import QtQuick 2.7
import QtQuick.Controls 2.2
import QtQuick.Layouts 1.3
import UM 1.1 as UM
ScrollView
{
property alias model: backupList.model
width: parent.width
ListView
{
id: backupList
width: parent.width
delegate: Item
{
width: parent.width
height: childrenRect.height
BackupListItem
{
id: backupListItem
width: parent.width
}
Rectangle
{
id: divider
color: UM.Theme.getColor("lining")
height: UM.Theme.getSize("default_lining").height
}
}
}
}

View File

@ -0,0 +1,46 @@
// Copyright (c) 2018 Ultimaker B.V.
// Cura is released under the terms of the LGPLv3 or higher.
import QtQuick 2.7
import QtQuick.Controls 2.1
import QtQuick.Layouts 1.3
import UM 1.3 as UM
import Cura 1.0 as Cura
import "../components"
RowLayout
{
id: backupListFooter
width: parent.width
property bool showInfoButton: false
Cura.PrimaryButton
{
id: infoButton
text: catalog.i18nc("@button", "Want more?")
iconSource: UM.Theme.getIcon("info")
onClicked: Qt.openUrlExternally("https://goo.gl/forms/QACEP8pP3RV60QYG2")
visible: backupListFooter.showInfoButton
}
Cura.PrimaryButton
{
id: createBackupButton
text: catalog.i18nc("@button", "Backup Now")
iconSource: UM.Theme.getIcon("plus")
enabled: !CuraDrive.isCreatingBackup && !CuraDrive.isRestoringBackup && !backupListFooter.showInfoButton
onClicked: CuraDrive.createBackup()
busy: CuraDrive.isCreatingBackup
}
Cura.CheckBoxWithTooltip
{
id: autoBackupEnabled
checked: CuraDrive.autoBackupEnabled
onClicked: CuraDrive.toggleAutoBackup(autoBackupEnabled.checked)
text: catalog.i18nc("@checkbox:description", "Auto Backup")
tooltip: catalog.i18nc("@checkbox:description", "Automatically create a backup each day that Cura is started.")
}
}

View File

@ -0,0 +1,113 @@
// Copyright (c) 2018 Ultimaker B.V.
// Cura is released under the terms of the LGPLv3 or higher.
import QtQuick 2.7
import QtQuick.Controls 2.1
import QtQuick.Layouts 1.3
import QtQuick.Dialogs 1.1
import UM 1.1 as UM
import Cura 1.0 as Cura
Item
{
id: backupListItem
width: parent.width
height: showDetails ? dataRow.height + backupDetails.height : dataRow.height
property bool showDetails: false
// Backup details toggle animation.
Behavior on height
{
PropertyAnimation
{
duration: 70
}
}
RowLayout
{
id: dataRow
spacing: UM.Theme.getSize("wide_margin").width
width: parent.width
height: 50 * screenScaleFactor
UM.SimpleButton
{
width: UM.Theme.getSize("section_icon").width
height: UM.Theme.getSize("section_icon").height
color: UM.Theme.getColor("small_button_text")
hoverColor: UM.Theme.getColor("small_button_text_hover")
iconSource: UM.Theme.getIcon("info")
onClicked: backupListItem.showDetails = !backupListItem.showDetails
}
Label
{
text: new Date(modelData.generated_time).toLocaleString(UM.Preferences.getValue("general/language"))
color: UM.Theme.getColor("text")
elide: Text.ElideRight
Layout.minimumWidth: 100 * screenScaleFactor
Layout.maximumWidth: 500 * screenScaleFactor
Layout.fillWidth: true
font: UM.Theme.getFont("default")
renderType: Text.NativeRendering
}
Label
{
text: modelData.metadata.description
color: UM.Theme.getColor("text")
elide: Text.ElideRight
Layout.minimumWidth: 100 * screenScaleFactor
Layout.maximumWidth: 500 * screenScaleFactor
Layout.fillWidth: true
font: UM.Theme.getFont("default")
renderType: Text.NativeRendering
}
Cura.SecondaryButton
{
text: catalog.i18nc("@button", "Restore")
enabled: !CuraDrive.isCreatingBackup && !CuraDrive.isRestoringBackup
onClicked: confirmRestoreDialog.visible = true
}
UM.SimpleButton
{
width: UM.Theme.getSize("message_close").width
height: UM.Theme.getSize("message_close").height
color: UM.Theme.getColor("small_button_text")
hoverColor: UM.Theme.getColor("small_button_text_hover")
iconSource: UM.Theme.getIcon("cross1")
onClicked: confirmDeleteDialog.visible = true
}
}
BackupListItemDetails
{
id: backupDetails
backupDetailsData: modelData
width: parent.width
visible: parent.showDetails
anchors.top: dataRow.bottom
}
MessageDialog
{
id: confirmDeleteDialog
title: catalog.i18nc("@dialog:title", "Delete Backup")
text: catalog.i18nc("@dialog:info", "Are you sure you want to delete this backup? This cannot be undone.")
standardButtons: StandardButton.Yes | StandardButton.No
onYes: CuraDrive.deleteBackup(modelData.backup_id)
}
MessageDialog
{
id: confirmRestoreDialog
title: catalog.i18nc("@dialog:title", "Restore Backup")
text: catalog.i18nc("@dialog:info", "You will need to restart Cura before your backup is restored. Do you want to close Cura now?")
standardButtons: StandardButton.Yes | StandardButton.No
onYes: CuraDrive.restoreBackup(modelData.backup_id)
}
}

View File

@ -0,0 +1,63 @@
// Copyright (c) 2018 Ultimaker B.V.
// Cura is released under the terms of the LGPLv3 or higher.
import QtQuick 2.7
import QtQuick.Controls 2.1
import QtQuick.Layouts 1.3
import UM 1.1 as UM
ColumnLayout
{
id: backupDetails
width: parent.width
spacing: UM.Theme.getSize("default_margin").width
property var backupDetailsData
// Cura version
BackupListItemDetailsRow
{
iconSource: UM.Theme.getIcon("application")
label: catalog.i18nc("@backuplist:label", "Cura Version")
value: backupDetailsData.metadata.cura_release
}
// Machine count.
BackupListItemDetailsRow
{
iconSource: UM.Theme.getIcon("printer_single")
label: catalog.i18nc("@backuplist:label", "Machines")
value: backupDetailsData.metadata.machine_count
}
// Material count
BackupListItemDetailsRow
{
iconSource: UM.Theme.getIcon("category_material")
label: catalog.i18nc("@backuplist:label", "Materials")
value: backupDetailsData.metadata.material_count
}
// Profile count.
BackupListItemDetailsRow
{
iconSource: UM.Theme.getIcon("settings")
label: catalog.i18nc("@backuplist:label", "Profiles")
value: backupDetailsData.metadata.profile_count
}
// Plugin count.
BackupListItemDetailsRow
{
iconSource: UM.Theme.getIcon("plugin")
label: catalog.i18nc("@backuplist:label", "Plugins")
value: backupDetailsData.metadata.plugin_count
}
// Spacer.
Item
{
width: parent.width
height: UM.Theme.getSize("default_margin").height
}
}

View File

@ -0,0 +1,52 @@
// Copyright (c) 2018 Ultimaker B.V.
// Cura is released under the terms of the LGPLv3 or higher.
import QtQuick 2.7
import QtQuick.Controls 2.1
import QtQuick.Layouts 1.3
import UM 1.3 as UM
RowLayout
{
id: detailsRow
width: parent.width
height: 40 * screenScaleFactor
property alias iconSource: icon.source
property alias label: detailName.text
property alias value: detailValue.text
UM.RecolorImage
{
id: icon
width: 18 * screenScaleFactor
height: width
source: ""
color: UM.Theme.getColor("text")
}
Label
{
id: detailName
color: UM.Theme.getColor("text")
elide: Text.ElideRight
Layout.minimumWidth: 50 * screenScaleFactor
Layout.maximumWidth: 100 * screenScaleFactor
Layout.fillWidth: true
font: UM.Theme.getFont("default")
renderType: Text.NativeRendering
}
Label
{
id: detailValue
color: UM.Theme.getColor("text")
elide: Text.ElideRight
Layout.minimumWidth: 50 * screenScaleFactor
Layout.maximumWidth: 100 * screenScaleFactor
Layout.fillWidth: true
font: UM.Theme.getFont("default")
renderType: Text.NativeRendering
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

View File

@ -0,0 +1,44 @@
// Copyright (c) 2018 Ultimaker B.V.
// Cura is released under the terms of the LGPLv3 or higher.
import QtQuick 2.7
import QtQuick.Controls 2.1
import QtQuick.Window 2.2
import UM 1.3 as UM
import Cura 1.1 as Cura
import "components"
import "pages"
Window
{
id: curaDriveDialog
minimumWidth: Math.round(UM.Theme.getSize("modal_window_minimum").width)
minimumHeight: Math.round(UM.Theme.getSize("modal_window_minimum").height)
maximumWidth: Math.round(minimumWidth * 1.2)
maximumHeight: Math.round(minimumHeight * 1.2)
width: minimumWidth
height: minimumHeight
color: UM.Theme.getColor("main_background")
title: catalog.i18nc("@title:window", "Cura Backups")
// Globally available.
UM.I18nCatalog
{
id: catalog
name: "cura"
}
WelcomePage
{
id: welcomePage
visible: !Cura.API.account.isLoggedIn
}
BackupsPage
{
id: backupsPage
visible: Cura.API.account.isLoggedIn
}
}

View File

@ -0,0 +1,75 @@
// Copyright (c) 2018 Ultimaker B.V.
// Cura is released under the terms of the LGPLv3 or higher.
import QtQuick 2.7
import QtQuick.Controls 2.1
import QtQuick.Layouts 1.3
import UM 1.3 as UM
import Cura 1.1 as Cura
import "../components"
Item
{
id: backupsPage
anchors.fill: parent
anchors.margins: UM.Theme.getSize("wide_margin").width
ColumnLayout
{
spacing: UM.Theme.getSize("wide_margin").height
width: parent.width
anchors.fill: parent
Label
{
id: backupTitle
text: catalog.i18nc("@title", "My Backups")
font: UM.Theme.getFont("large")
color: UM.Theme.getColor("text")
Layout.fillWidth: true
renderType: Text.NativeRendering
}
Label
{
text: catalog.i18nc("@empty_state",
"You don't have any backups currently. Use the 'Backup Now' button to create one.")
width: parent.width
font: UM.Theme.getFont("default")
color: UM.Theme.getColor("text")
wrapMode: Label.WordWrap
visible: backupList.count == 0
Layout.fillWidth: true
Layout.fillHeight: true
renderType: Text.NativeRendering
}
BackupList
{
id: backupList
model: CuraDrive.backups
Layout.fillWidth: true
Layout.fillHeight: true
}
Label
{
text: catalog.i18nc("@backup_limit_info",
"During the preview phase, you'll be limited to 5 visible backups. Remove a backup to see older ones.")
width: parent.width
font: UM.Theme.getFont("default")
color: UM.Theme.getColor("text")
wrapMode: Label.WordWrap
visible: backupList.count > 4
renderType: Text.NativeRendering
}
BackupListFooter
{
id: backupListFooter
showInfoButton: backupList.count > 4
}
}
}

View File

@ -0,0 +1,56 @@
// Copyright (c) 2018 Ultimaker B.V.
// Cura is released under the terms of the LGPLv3 or higher.
import QtQuick 2.7
import QtQuick.Controls 2.1
import QtQuick.Window 2.2
import UM 1.3 as UM
import Cura 1.1 as Cura
import "../components"
Column
{
id: welcomePage
spacing: UM.Theme.getSize("wide_margin").height
width: parent.width
height: childrenRect.height
anchors.centerIn: parent
Image
{
id: profileImage
fillMode: Image.PreserveAspectFit
source: "../images/icon.png"
anchors.horizontalCenter: parent.horizontalCenter
width: Math.round(parent.width / 4)
}
Label
{
id: welcomeTextLabel
text: catalog.i18nc("@description", "Backup and synchronize your Cura settings.")
width: Math.round(parent.width / 2)
font: UM.Theme.getFont("default")
color: UM.Theme.getColor("text")
verticalAlignment: Text.AlignVCenter
horizontalAlignment: Text.AlignHCenter
anchors.horizontalCenter: parent.horizontalCenter
wrapMode: Label.WordWrap
renderType: Text.NativeRendering
}
Cura.PrimaryButton
{
id: loginButton
width: UM.Theme.getSize("account_button").width
height: UM.Theme.getSize("account_button").height
anchors.horizontalCenter: parent.horizontalCenter
text: catalog.i18nc("@button", "Sign in")
onClicked: Cura.API.account.login()
fixedWidthMode: true
}
}

View File

@ -16,7 +16,8 @@ from UM.Extension import Extension
from UM.i18n import i18nCatalog
from UM.Version import Version
import cura
from cura import ApplicationMetadata
from cura import UltimakerCloudAuthentication
from cura.CuraApplication import CuraApplication
from .AuthorsModel import AuthorsModel
@ -30,17 +31,14 @@ i18n_catalog = i18nCatalog("cura")
## The Toolbox class is responsible of communicating with the server through the API
class Toolbox(QObject, Extension):
DEFAULT_CLOUD_API_ROOT = "https://api.ultimaker.com" # type: str
DEFAULT_CLOUD_API_VERSION = 1 # type: int
def __init__(self, application: CuraApplication) -> None:
super().__init__()
self._application = application # type: CuraApplication
self._sdk_version = None # type: Optional[Union[str, int]]
self._cloud_api_version = None # type: Optional[int]
self._cloud_api_root = None # type: Optional[str]
self._sdk_version = ApplicationMetadata.CuraSDKVersion # type: Union[str, int]
self._cloud_api_version = UltimakerCloudAuthentication.CuraCloudAPIVersion # type: int
self._cloud_api_root = UltimakerCloudAuthentication.CuraCloudAPIRoot # type: str
self._api_url = None # type: Optional[str]
# Network:
@ -182,9 +180,6 @@ class Toolbox(QObject, Extension):
def _onAppInitialized(self) -> None:
self._plugin_registry = self._application.getPluginRegistry()
self._package_manager = self._application.getPackageManager()
self._sdk_version = self._getSDKVersion()
self._cloud_api_version = self._getCloudAPIVersion()
self._cloud_api_root = self._getCloudAPIRoot()
self._api_url = "{cloud_api_root}/cura-packages/v{cloud_api_version}/cura/v{sdk_version}".format(
cloud_api_root = self._cloud_api_root,
cloud_api_version = self._cloud_api_version,
@ -195,36 +190,6 @@ class Toolbox(QObject, Extension):
"packages": QUrl("{base_url}/packages".format(base_url = self._api_url))
}
# Get the API root for the packages API depending on Cura version settings.
def _getCloudAPIRoot(self) -> str:
if not hasattr(cura, "CuraVersion"):
return self.DEFAULT_CLOUD_API_ROOT
if not hasattr(cura.CuraVersion, "CuraCloudAPIRoot"): # type: ignore
return self.DEFAULT_CLOUD_API_ROOT
if not cura.CuraVersion.CuraCloudAPIRoot: # type: ignore
return self.DEFAULT_CLOUD_API_ROOT
return cura.CuraVersion.CuraCloudAPIRoot # type: ignore
# Get the cloud API version from CuraVersion
def _getCloudAPIVersion(self) -> int:
if not hasattr(cura, "CuraVersion"):
return self.DEFAULT_CLOUD_API_VERSION
if not hasattr(cura.CuraVersion, "CuraCloudAPIVersion"): # type: ignore
return self.DEFAULT_CLOUD_API_VERSION
if not cura.CuraVersion.CuraCloudAPIVersion: # type: ignore
return self.DEFAULT_CLOUD_API_VERSION
return cura.CuraVersion.CuraCloudAPIVersion # type: ignore
# Get the packages version depending on Cura version settings.
def _getSDKVersion(self) -> Union[int, str]:
if not hasattr(cura, "CuraVersion"):
return self._application.getAPIVersion().getMajor()
if not hasattr(cura.CuraVersion, "CuraSDKVersion"): # type: ignore
return self._application.getAPIVersion().getMajor()
if not cura.CuraVersion.CuraSDKVersion: # type: ignore
return self._application.getAPIVersion().getMajor()
return cura.CuraVersion.CuraSDKVersion # type: ignore
@pyqtSlot()
def browsePackages(self) -> None:
# Create the network manager:

View File

@ -50,6 +50,23 @@
}
}
},
"CuraDrive": {
"package_info": {
"package_id": "CuraDrive",
"package_type": "plugin",
"display_name": "Cura Backups",
"description": "Backup and restore your configuration.",
"package_version": "1.2.0",
"sdk_version": 6,
"website": "https://ultimaker.com",
"author": {
"author_id": "UltimakerPackages",
"display_name": "Ultimaker B.V.",
"email": "plugins@ultimaker.com",
"website": "https://ultimaker.com"
}
}
},
"CuraEngineBackend": {
"package_info": {
"package_id": "CuraEngineBackend",

View File

@ -4,6 +4,7 @@
import QtQuick 2.7
import QtQuick.Controls 2.1
import QtGraphicalEffects 1.0 // For the dropshadow
import UM 1.1 as UM
import Cura 1.0 as Cura
@ -30,6 +31,7 @@ Button
property color outlineDisabledColor: outlineColor
property alias shadowColor: shadow.color
property alias shadowEnabled: shadow.visible
property alias busy: busyIndicator.visible
property alias toolTipContentAlignment: tooltip.contentAlignment
@ -55,7 +57,7 @@ Button
width: visible ? height : 0
sourceSize.width: width
sourceSize.height: height
color: button.hovered ? button.textHoverColor : button.textColor
color: button.enabled ? (button.hovered ? button.textHoverColor : button.textColor) : button.textDisabledColor
visible: source != "" && !button.isIconOnRightSide
anchors.verticalCenter: parent.verticalCenter
}
@ -117,4 +119,16 @@ Button
id: tooltip
visible: button.hovered
}
BusyIndicator
{
id: busyIndicator
anchors.centerIn: parent
width: height
height: parent.height
visible: false
}
}

View File

@ -0,0 +1,63 @@
// Copyright (c) 2018 Ultimaker B.V.
// Cura is released under the terms of the LGPLv3 or higher.
import QtQuick 2.7
import QtQuick.Controls 2.1
import UM 1.3 as UM
CheckBox
{
id: checkbox
hoverEnabled: true
property alias tooltip: tooltip.text
indicator: Rectangle
{
implicitWidth: UM.Theme.getSize("checkbox").width
implicitHeight: UM.Theme.getSize("checkbox").height
x: 0
anchors.verticalCenter: parent.verticalCenter
color: UM.Theme.getColor("main_background")
radius: UM.Theme.getSize("checkbox_radius").width
border.width: UM.Theme.getSize("default_lining").width
border.color: checkbox.hovered ? UM.Theme.getColor("checkbox_border_hover") : UM.Theme.getColor("checkbox_border")
UM.RecolorImage
{
anchors.verticalCenter: parent.verticalCenter
anchors.horizontalCenter: parent.horizontalCenter
width: Math.round(parent.width / 2.5)
height: Math.round(parent.height / 2.5)
sourceSize.height: width
color: UM.Theme.getColor("checkbox_mark")
source: UM.Theme.getIcon("check")
opacity: checkbox.checked
Behavior on opacity { NumberAnimation { duration: 100; } }
}
}
contentItem: Label
{
anchors
{
left: checkbox.indicator.right
leftMargin: UM.Theme.getSize("narrow_margin").width
}
text: checkbox.text
color: UM.Theme.getColor("checkbox_text")
font: UM.Theme.getFont("default")
renderType: Text.NativeRendering
elide: Text.ElideRight
verticalAlignment: Text.AlignVCenter
}
ToolTip
{
id: tooltip
text: ""
delay: 500
visible: text != "" && checkbox.hovered
}
}

View File

@ -124,16 +124,16 @@ UM.MainWindow
}
}
// This is a placehoder for adding a pattern in the header
Image
{
id: backgroundPattern
anchors.fill: parent
fillMode: Image.Tile
source: UM.Theme.getImage("header_pattern")
horizontalAlignment: Image.AlignLeft
verticalAlignment: Image.AlignTop
}
// This is a placehoder for adding a pattern in the header
Image
{
id: backgroundPattern
anchors.fill: parent
fillMode: Image.Tile
source: UM.Theme.getImage("header_pattern")
horizontalAlignment: Image.AlignLeft
verticalAlignment: Image.AlignTop
}
}
MainWindowHeader

View File

@ -15,4 +15,5 @@ ViewsSelector 1.0 ViewsSelector.qml
ToolbarButton 1.0 ToolbarButton.qml
SettingView 1.0 SettingView.qml
ProfileMenu 1.0 ProfileMenu.qml
ToolTip 1.0 ToolTip.qml
CheckBoxWithTooltip 1.0 CheckBoxWithTooltip.qml
ToolTip 1.0 ToolTip.qml

View File

@ -478,7 +478,7 @@ QtObject
color: (control.hovered || control._hovered) ? Theme.getColor("checkbox_hover") : (control.enabled ? Theme.getColor("checkbox") : Theme.getColor("checkbox_disabled"))
Behavior on color { ColorAnimation { duration: 50; } }
radius: control.exclusiveGroup ? Math.round(Theme.getSize("checkbox").width / 2) : UM.Theme.getSize("checkbox_radius").width
radius: control.exclusiveGroup ? Math.round(Theme.getSize("checkbox").width / 2) : Theme.getSize("checkbox_radius").width
border.width: Theme.getSize("default_lining").width
border.color: (control.hovered || control._hovered) ? Theme.getColor("checkbox_border_hover") : Theme.getColor("checkbox_border")