This commit is contained in:
Matt Jani 2020-01-28 15:28:26 +01:00
commit 155408d77d
25 changed files with 751 additions and 170 deletions

View file

@ -4,7 +4,8 @@
from configparser import ConfigParser
import zipfile
import os
from typing import cast, Dict, List, Optional, Tuple
import json
from typing import cast, Dict, List, Optional, Tuple, Any
import xml.etree.ElementTree as ET
@ -732,7 +733,25 @@ class ThreeMFWorkspaceReader(WorkspaceReader):
base_file_name = os.path.basename(file_name)
self.setWorkspaceName(base_file_name)
return nodes
return nodes, self._loadMetadata(file_name)
@staticmethod
def _loadMetadata(file_name: str) -> Dict[str, Dict[str, Any]]:
archive = zipfile.ZipFile(file_name, "r")
metadata_files = [name for name in archive.namelist() if name.endswith("plugin_metadata.json")]
result = dict()
for metadata_file in metadata_files:
try:
plugin_id = metadata_file.split("/")[0]
result[plugin_id] = json.loads(archive.open("%s/plugin_metadata.json" % plugin_id).read().decode("utf-8"))
except Exception:
Logger.logException("w", "Unable to retrieve metadata for %s", metadata_file)
return result
def _processQualityChanges(self, global_stack):
if self._machine_info.quality_changes_info is None:

View file

@ -73,11 +73,25 @@ class ThreeMFWorkspaceWriter(WorkspaceWriter):
version_config_parser.write(version_file_string)
archive.writestr(version_file, version_file_string.getvalue())
self._writePluginMetadataToArchive(archive)
# Close the archive & reset states.
archive.close()
mesh_writer.setStoreArchive(False)
return True
@staticmethod
def _writePluginMetadataToArchive(archive: zipfile.ZipFile) -> None:
file_name_template = "%s/plugin_metadata.json"
for plugin_id, metadata in Application.getInstance().getWorkspaceMetadataStorage().getAllData().items():
file_name = file_name_template % plugin_id
file_in_archive = zipfile.ZipInfo(file_name)
# We have to set the compress type of each file as well (it doesn't keep the type of the entire archive)
file_in_archive.compress_type = zipfile.ZIP_DEFLATED
import json
archive.writestr(file_in_archive, json.dumps(metadata, separators = (", ", ": "), indent = 4, skipkeys = True))
## Helper function that writes ContainerStacks, InstanceContainers and DefinitionContainers to the archive.
# \param container That follows the \type{ContainerInterface} to archive.
# \param archive The archive to write to.

View file

@ -1,5 +1,6 @@
# Copyright (c) 2015 Ultimaker B.V.
# Uranium is released under the terms of the LGPLv3 or higher.
from typing import Optional
from UM.Mesh.MeshWriter import MeshWriter
from UM.Math.Vector import Vector
@ -40,7 +41,7 @@ class ThreeMFWriter(MeshWriter):
}
self._unit_matrix_string = self._convertMatrixToString(Matrix())
self._archive = None
self._archive = None # type: Optional[zipfile.ZipFile]
self._store_archive = False
def _convertMatrixToString(self, matrix):

View file

@ -72,18 +72,25 @@ class DisplayFilenameAndLayerOnLCD(Script):
lcd_text = "M117 Printing " + name + " - Layer "
i = self.getSettingValueByKey("startNum")
for layer in data:
display_text = lcd_text + str(i) + " " + name
display_text = lcd_text + str(i)
layer_index = data.index(layer)
lines = layer.split("\n")
for line in lines:
if line.startswith(";LAYER_COUNT:"):
max_layer = line
max_layer = max_layer.split(":")[1]
if self.getSettingValueByKey("startNum") == 0:
max_layer = str(int(max_layer) - 1)
if line.startswith(";LAYER:"):
if self.getSettingValueByKey("maxlayer"):
display_text = display_text + " of " + max_layer
if not self.getSettingValueByKey("scroll"):
display_text = display_text + " " + name
else:
display_text = display_text + "!"
if not self.getSettingValueByKey("scroll"):
display_text = display_text + " " + name + "!"
else:
display_text = display_text + "!"
line_index = lines.index(line)
lines.insert(line_index + 1, display_text)
i += 1

View file

@ -20,6 +20,8 @@ UM.Dialog{
maximumHeight: minimumHeight
margin: 0
property string actionButtonText: subscribedPackagesModel.hasIncompatiblePackages && !subscribedPackagesModel.hasCompatiblePackages ? catalog.i18nc("@button", "Dismiss") : catalog.i18nc("@button", "Next")
Rectangle
{
id: root
@ -125,26 +127,6 @@ UM.Dialog{
color: UM.Theme.getColor("text")
elide: Text.ElideRight
}
UM.TooltipArea
{
width: childrenRect.width;
height: childrenRect.height;
text: catalog.i18nc("@info:tooltip", "Dismisses the package and won't be shown in this dialog anymore")
anchors.right: parent.right
anchors.verticalCenter: packageIcon.verticalCenter
Label
{
text: "(Dismiss)"
font: UM.Theme.getFont("small")
color: UM.Theme.getColor("text")
MouseArea
{
cursorShape: Qt.PointingHandCursor
anchors.fill: parent
onClicked: handler.dismissIncompatiblePackage(subscribedPackagesModel, model.package_id)
}
}
}
}
}
}
@ -158,7 +140,7 @@ UM.Dialog{
anchors.bottom: parent.bottom
anchors.right: parent.right
anchors.margins: UM.Theme.getSize("default_margin").height
text: catalog.i18nc("@button", "Next")
text: actionButtonText
onClicked: accept()
leftPadding: UM.Theme.getSize("dialog_primary_button_padding").width
rightPadding: UM.Theme.getSize("dialog_primary_button_padding").width

View file

@ -5,6 +5,7 @@ import QtQuick 2.10
import QtQuick.Dialogs 1.1
import QtQuick.Window 2.2
import QtQuick.Controls 1.4
import QtQuick.Layouts 1.3
import QtQuick.Controls.Styles 1.4
// TODO: Switch to QtQuick.Controls 2.x and remove QtQuick.Controls.Styles
@ -20,31 +21,63 @@ UM.Dialog
minimumHeight: UM.Theme.getSize("license_window_minimum").height
width: minimumWidth
height: minimumHeight
backgroundColor: UM.Theme.getColor("main_background")
margin: screenScaleFactor * 10
Item
ColumnLayout
{
anchors.fill: parent
spacing: UM.Theme.getSize("thick_margin").height
UM.I18nCatalog{id: catalog; name: "cura"}
Label
{
id: licenseHeader
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
text: licenseModel.headerText
Layout.fillWidth: true
text: catalog.i18nc("@label", "You need to accept the license to install the package")
wrapMode: Text.Wrap
renderType: Text.NativeRendering
}
Row {
id: packageRow
anchors.left: parent.left
anchors.right: parent.right
height: childrenRect.height
spacing: UM.Theme.getSize("default_margin").width
leftPadding: UM.Theme.getSize("narrow_margin").width
Image
{
id: icon
width: 30 * screenScaleFactor
height: width
fillMode: Image.PreserveAspectFit
source: licenseModel.iconUrl || "../../images/logobot.svg"
mipmap: true
}
Label
{
id: packageName
text: licenseModel.packageName
font.bold: true
anchors.verticalCenter: icon.verticalCenter
height: contentHeight
wrapMode: Text.Wrap
renderType: Text.NativeRendering
}
}
TextArea
{
id: licenseText
anchors.top: licenseHeader.bottom
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
Layout.fillWidth: true
Layout.fillHeight: true
anchors.topMargin: UM.Theme.getSize("default_margin").height
readOnly: true
text: licenseModel.licenseText
@ -57,7 +90,7 @@ UM.Dialog
leftPadding: UM.Theme.getSize("dialog_primary_button_padding").width
rightPadding: UM.Theme.getSize("dialog_primary_button_padding").width
text: catalog.i18nc("@button", "Agree")
text: licenseModel.acceptButtonText
onClicked: { handler.onLicenseAccepted() }
}
]
@ -67,7 +100,7 @@ UM.Dialog
Cura.SecondaryButton
{
id: declineButton
text: catalog.i18nc("@button", "Decline and remove from account")
text: licenseModel.declineButtonText
onClicked: { handler.onLicenseDeclined() }
}
]

View file

@ -8,11 +8,12 @@ from UM import i18nCatalog
from UM.Logger import Logger
from UM.Message import Message
from UM.Signal import Signal
from cura.CuraApplication import CuraApplication
from cura.CuraApplication import CuraApplication, ApplicationMetadata
from ..CloudApiModel import CloudApiModel
from .SubscribedPackagesModel import SubscribedPackagesModel
from ..UltimakerCloudScope import UltimakerCloudScope
from typing import List, Dict, Any
class CloudPackageChecker(QObject):
def __init__(self, application: CuraApplication) -> None:
@ -25,12 +26,12 @@ class CloudPackageChecker(QObject):
self._application.initializationFinished.connect(self._onAppInitialized)
self._i18n_catalog = i18nCatalog("cura")
self._sdk_version = ApplicationMetadata.CuraSDKVersion
# This is a plugin, so most of the components required are not ready when
# this is initialized. Therefore, we wait until the application is ready.
def _onAppInitialized(self) -> None:
self._package_manager = self._application.getPackageManager()
# initial check
self._fetchUserSubscribedPackages()
# check again whenever the login state changes
@ -38,25 +39,51 @@ class CloudPackageChecker(QObject):
def _fetchUserSubscribedPackages(self) -> None:
if self._application.getCuraAPI().account.isLoggedIn:
self._getUserPackages()
self._getUserSubscribedPackages()
def _handleCompatibilityData(self, json_data) -> None:
user_subscribed_packages = [plugin["package_id"] for plugin in json_data]
def _getUserSubscribedPackages(self) -> None:
Logger.debug("Requesting subscribed packages metadata from server.")
url = CloudApiModel.api_url_user_packages
self._application.getHttpRequestManager().get(url,
callback = self._onUserPackagesRequestFinished,
error_callback = self._onUserPackagesRequestFinished,
scope = self._scope)
def _onUserPackagesRequestFinished(self, reply: "QNetworkReply", error: Optional["QNetworkReply.NetworkError"] = None) -> None:
if error is not None or reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) != 200:
Logger.log("w",
"Requesting user packages failed, response code %s while trying to connect to %s",
reply.attribute(QNetworkRequest.HttpStatusCodeAttribute), reply.url())
return
try:
json_data = json.loads(bytes(reply.readAll()).decode("utf-8"))
# Check for errors:
if "errors" in json_data:
for error in json_data["errors"]:
Logger.log("e", "%s", error["title"])
return
self._handleCompatibilityData(json_data["data"])
except json.decoder.JSONDecodeError:
Logger.log("w", "Received invalid JSON for user subscribed packages from the Web Marketplace")
def _handleCompatibilityData(self, subscribed_packages_payload: List[Dict[str, Any]]) -> None:
user_subscribed_packages = [plugin["package_id"] for plugin in subscribed_packages_payload]
user_installed_packages = self._package_manager.getUserInstalledPackages()
# We need to re-evaluate the dismissed packages
# (i.e. some package might got updated to the correct SDK version in the meantime,
# hence remove them from the Dismissed Incompatible list)
self._package_manager.reEvaluateDismissedPackages(subscribed_packages_payload, self._sdk_version)
user_dismissed_packages = self._package_manager.getDismissedPackages()
if user_dismissed_packages:
user_installed_packages += user_dismissed_packages
# We check if there are packages installed in Cloud Marketplace but not in Cura marketplace
# We check if there are packages installed in Web Marketplace but not in Cura marketplace
package_discrepancy = list(set(user_subscribed_packages).difference(user_installed_packages))
self._model.setMetadata(json_data)
self._model.addDiscrepancies(package_discrepancy)
self._model.initialize()
if not self._model.hasCompatiblePackages:
return None
if package_discrepancy:
self._model.addDiscrepancies(package_discrepancy)
self._model.initialize(subscribed_packages_payload)
self._handlePackageDiscrepancies()
def _handlePackageDiscrepancies(self) -> None:
@ -76,35 +103,4 @@ class CloudPackageChecker(QObject):
def _onSyncButtonClicked(self, sync_message: Message, sync_message_action: str) -> None:
sync_message.hide()
self.discrepancies.emit(self._model)
def _getUserPackages(self) -> None:
Logger.log("d", "Requesting subscribed packages metadata from server.")
url = CloudApiModel.api_url_user_packages
self._application.getHttpRequestManager().get(url,
callback = self._onUserPackagesRequestFinished,
error_callback = self._onUserPackagesRequestFinished,
scope = self._scope)
def _onUserPackagesRequestFinished(self,
reply: "QNetworkReply",
error: Optional["QNetworkReply.NetworkError"] = None) -> None:
if error is not None or reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) != 200:
Logger.log("w",
"Requesting user packages failed, response code %s while trying to connect to %s",
reply.attribute(QNetworkRequest.HttpStatusCodeAttribute), reply.url())
return
try:
json_data = json.loads(bytes(reply.readAll()).decode("utf-8"))
# Check for errors:
if "errors" in json_data:
for error in json_data["errors"]:
Logger.log("e", "%s", error["title"])
return
self._handleCompatibilityData(json_data["data"])
except json.decoder.JSONDecodeError:
Logger.log("w", "Received invalid JSON for user packages")
self.discrepancies.emit(self._model)

View file

@ -28,13 +28,12 @@ class DiscrepanciesPresenter(QObject):
assert self._dialog
self._dialog.accepted.connect(lambda: self._onConfirmClicked(model))
@pyqtSlot("QVariant", str)
def dismissIncompatiblePackage(self, model: SubscribedPackagesModel, package_id: str) -> None:
model.dismissPackage(package_id) # update the model to update the view
self._package_manager.dismissPackage(package_id) # adds this package_id as dismissed in the user config file
def _onConfirmClicked(self, model: SubscribedPackagesModel) -> None:
# If there are incompatible packages - automatically dismiss them
if model.getIncompatiblePackages():
self._package_manager.dismissAllIncompatiblePackages(model.getIncompatiblePackages())
# For now, all compatible packages presented to the user should be installed.
# Later, we might remove items for which the user unselected the package
model.setItems(model.getCompatiblePackages())
self.packageMutations.emit(model)
if model.getCompatiblePackages():
model.setItems(model.getCompatiblePackages())
self.packageMutations.emit(model)

View file

@ -62,7 +62,8 @@ class DownloadPresenter:
"received": 0,
"total": 1, # make sure this is not considered done yet. Also divByZero-safe
"file_written": None,
"request_data": request_data
"request_data": request_data,
"package_model": item
}
self._started = True
@ -128,7 +129,14 @@ class DownloadPresenter:
if not item["file_written"]:
return False
success_items = {package_id : value["file_written"] for package_id, value in self._progress.items()}
success_items = {
package_id:
{
"package_path": value["file_written"],
"icon_url": value["package_model"]["icon_url"]
}
for package_id, value in self._progress.items()
}
error_items = [package_id for package_id in self._error]
self._progress_message.hide()

View file

@ -6,31 +6,52 @@ catalog = i18nCatalog("cura")
# Model for the ToolboxLicenseDialog
class LicenseModel(QObject):
dialogTitleChanged = pyqtSignal()
headerChanged = pyqtSignal()
licenseTextChanged = pyqtSignal()
DEFAULT_DECLINE_BUTTON_TEXT = catalog.i18nc("@button", "Decline")
ACCEPT_BUTTON_TEXT = catalog.i18nc("@button", "Agree")
def __init__(self) -> None:
dialogTitleChanged = pyqtSignal()
packageNameChanged = pyqtSignal()
licenseTextChanged = pyqtSignal()
iconChanged = pyqtSignal()
def __init__(self, decline_button_text: str = DEFAULT_DECLINE_BUTTON_TEXT) -> None:
super().__init__()
self._current_page_idx = 0
self._page_count = 1
self._dialogTitle = ""
self._header_text = ""
self._license_text = ""
self._package_name = ""
self._icon_url = ""
self._decline_button_text = decline_button_text
@pyqtProperty(str, constant = True)
def acceptButtonText(self):
return self.ACCEPT_BUTTON_TEXT
@pyqtProperty(str, constant = True)
def declineButtonText(self):
return self._decline_button_text
@pyqtProperty(str, notify=dialogTitleChanged)
def dialogTitle(self) -> str:
return self._dialogTitle
@pyqtProperty(str, notify=headerChanged)
def headerText(self) -> str:
return self._header_text
@pyqtProperty(str, notify=packageNameChanged)
def packageName(self) -> str:
return self._package_name
def setPackageName(self, name: str) -> None:
self._header_text = name + ": " + catalog.i18nc("@label", "This plugin contains a license.\nYou need to accept this license to install this plugin.\nDo you agree with the terms below?")
self.headerChanged.emit()
self._package_name = name
self.packageNameChanged.emit()
@pyqtProperty(str, notify=iconChanged)
def iconUrl(self) -> str:
return self._icon_url
def setIconUrl(self, url: str):
self._icon_url = url
self.iconChanged.emit()
@pyqtProperty(str, notify=licenseTextChanged)
def licenseText(self) -> str:

View file

@ -17,6 +17,7 @@ class LicensePresenter(QObject):
def __init__(self, app: CuraApplication) -> None:
super().__init__()
self._catalog = i18nCatalog("cura")
self._dialog = None # type: Optional[QObject]
self._package_manager = app.getPackageManager() # type: PackageManager
# Emits List[Dict[str, [Any]] containing for example
@ -25,7 +26,8 @@ class LicensePresenter(QObject):
self._current_package_idx = 0
self._package_models = [] # type: List[Dict]
self._license_model = LicenseModel() # type: LicenseModel
decline_button_text = self._catalog.i18nc("@button", "Decline and remove from account")
self._license_model = LicenseModel(decline_button_text=decline_button_text) # type: LicenseModel
self._app = app
@ -34,7 +36,7 @@ class LicensePresenter(QObject):
## Show a license dialog for multiple packages where users can read a license and accept or decline them
# \param plugin_path: Root directory of the Toolbox plugin
# \param packages: Dict[package id, file path]
def present(self, plugin_path: str, packages: Dict[str, str]) -> None:
def present(self, plugin_path: str, packages: Dict[str, Dict[str, str]]) -> None:
path = os.path.join(plugin_path, self._compatibility_dialog_path)
self._initState(packages)
@ -42,7 +44,7 @@ class LicensePresenter(QObject):
if self._dialog is None:
context_properties = {
"catalog": i18nCatalog("cura"),
"catalog": self._catalog,
"licenseModel": self._license_model,
"handler": self
}
@ -60,18 +62,20 @@ class LicensePresenter(QObject):
self._package_models[self._current_package_idx]["accepted"] = False
self._checkNextPage()
def _initState(self, packages: Dict[str, str]) -> None:
def _initState(self, packages: Dict[str, Dict[str, str]]) -> None:
self._package_models = [
{
"package_id" : package_id,
"package_path" : package_path,
"package_path" : item["package_path"],
"icon_url" : item["icon_url"],
"accepted" : None #: None: no answer yet
}
for package_id, package_path in packages.items()
for package_id, item in packages.items()
]
def _presentCurrentPackage(self) -> None:
package_model = self._package_models[self._current_package_idx]
package_info = self._package_manager.getPackageInfo(package_model["package_path"])
license_content = self._package_manager.getPackageLicense(package_model["package_path"])
if license_content is None:
# Implicitly accept when there is no license
@ -79,7 +83,8 @@ class LicensePresenter(QObject):
return
self._license_model.setCurrentPageIdx(self._current_package_idx)
self._license_model.setPackageName(package_model["package_id"])
self._license_model.setPackageName(package_info["display_name"])
self._license_model.setIconUrl(package_model["icon_url"])
self._license_model.setLicenseText(license_content)
if self._dialog:
self._dialog.open() # Does nothing if already open

View file

@ -37,27 +37,18 @@ class SubscribedPackagesModel(ListModel):
return True
return False
# Sets the "is_compatible" to True for the given package, in memory
@pyqtSlot()
def dismissPackage(self, package_id: str) -> None:
package = self.find(key="package_id", value=package_id)
if package != -1:
self.setProperty(package, property="is_dismissed", value=True)
Logger.debug("Package {} has been dismissed".format(package_id))
def setMetadata(self, data: List[Dict[str, List[Any]]]) -> None:
self._metadata = data
def addDiscrepancies(self, discrepancy: List[str]) -> None:
self._discrepancies = discrepancy
def getCompatiblePackages(self):
return [x for x in self._items if x["is_compatible"]]
def getCompatiblePackages(self) -> List[Dict[str, Any]]:
return [package for package in self._items if package["is_compatible"]]
def initialize(self) -> None:
def getIncompatiblePackages(self) -> List[str]:
return [package["package_id"] for package in self._items if not package["is_compatible"]]
def initialize(self, subscribed_packages_payload: List[Dict[str, Any]]) -> None:
self._items.clear()
for item in self._metadata:
for item in subscribed_packages_payload:
if item["package_id"] not in self._discrepancies:
continue
package = {

View file

@ -63,9 +63,9 @@ class SyncOrchestrator(Extension):
self._download_presenter.download(mutations)
## Called when a set of packages have finished downloading
# \param success_items: Dict[package_id, file_path]
# \param success_items: Dict[package_id, Dict[str, str]]
# \param error_items: List[package_id]
def _onDownloadFinished(self, success_items: Dict[str, str], error_items: List[str]) -> None:
def _onDownloadFinished(self, success_items: Dict[str, Dict[str, str]], error_items: List[str]) -> None:
if error_items:
message = i18n_catalog.i18nc("@info:generic", "{} plugins failed to download".format(len(error_items)))
self._showErrorMessage(message)

View file

@ -154,10 +154,11 @@ class Toolbox(QObject, Extension):
def getLicenseDialogPluginFileLocation(self) -> str:
return self._license_dialog_plugin_file_location
def openLicenseDialog(self, plugin_name: str, license_content: str, plugin_file_location: str) -> None:
def openLicenseDialog(self, plugin_name: str, license_content: str, plugin_file_location: str, icon_url: str) -> None:
# Set page 1/1 when opening the dialog for a single package
self._license_model.setCurrentPageIdx(0)
self._license_model.setPageCount(1)
self._license_model.setIconUrl(icon_url)
self._license_model.setPackageName(plugin_name)
self._license_model.setLicenseText(license_content)
@ -670,14 +671,17 @@ class Toolbox(QObject, Extension):
return
license_content = self._package_manager.getPackageLicense(file_path)
package_id = package_info["package_id"]
if license_content is not None:
self.openLicenseDialog(package_info["package_id"], license_content, file_path)
# get the icon url for package_id, make sure the result is a string, never None
icon_url = next((x["icon_url"] for x in self.packagesModel.items if x["id"] == package_id), None) or ""
self.openLicenseDialog(package_info["display_name"], license_content, file_path, icon_url)
return
package_id = self.install(file_path)
if package_id != package_info["package_id"]:
Logger.error("Installed package {} does not match {}".format(package_id, package_info["package_id"]))
self.subscribe(package_id)
installed_id = self.install(file_path)
if installed_id != package_id:
Logger.error("Installed package {} does not match {}".format(installed_id, package_id))
self.subscribe(installed_id)
# Getter & Setters for Properties:
# --------------------------------------------------------------------------
@ -699,14 +703,14 @@ class Toolbox(QObject, Extension):
def isDownloading(self) -> bool:
return self._is_downloading
def setActivePackage(self, package: Dict[str, Any]) -> None:
def setActivePackage(self, package: QObject) -> None:
if self._active_package != package:
self._active_package = package
self.activePackageChanged.emit()
## The active package is the package that is currently being downloaded
@pyqtProperty(QObject, fset = setActivePackage, notify = activePackageChanged)
def activePackage(self) -> Optional[Dict[str, Any]]:
def activePackage(self) -> Optional[QObject]:
return self._active_package
def setViewCategory(self, category: str = "plugin") -> None: