Merge branch 'master' into infill_mesh_shell_defaults

This commit is contained in:
Ghostkeeper 2020-02-11 17:21:19 +01:00
commit 53e1742d27
No known key found for this signature in database
GPG key ID: 37E2020986774393
341 changed files with 77506 additions and 69425 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:
@ -1005,8 +1024,7 @@ class ThreeMFWorkspaceReader(WorkspaceReader):
# Set metadata fields that are missing from the global stack
for key, value in self._machine_info.metadata_dict.items():
if key not in global_stack.getMetaData():
global_stack.setMetaDataEntry(key, value)
global_stack.setMetaDataEntry(key, value)
def _updateActiveMachine(self, global_stack):
# Actually change the active machine.

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Provides support for reading 3MF files.",
"api": "7.0",
"api": "7.1",
"i18n-catalog": "cura"
}

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

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Provides support for writing 3MF files.",
"api": "7.0",
"api": "7.1",
"i18n-catalog": "cura"
}

View file

@ -3,5 +3,5 @@
"author": "fieldOfView",
"version": "1.0.0",
"description": "Provides support for reading AMF files.",
"api": "7.0.0"
"api": "7.1.0"
}

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"description": "Backup and restore your configuration.",
"version": "1.2.0",
"api": "7.0",
"api": "7.1",
"i18n-catalog": "cura"
}

View file

@ -55,10 +55,22 @@ class CuraEngineBackend(QObject, Backend):
if Platform.isWindows():
executable_name += ".exe"
default_engine_location = executable_name
if os.path.exists(os.path.join(CuraApplication.getInstallPrefix(), "bin", executable_name)):
default_engine_location = os.path.join(CuraApplication.getInstallPrefix(), "bin", executable_name)
if hasattr(sys, "frozen"):
default_engine_location = os.path.join(os.path.dirname(os.path.abspath(sys.executable)), executable_name)
search_path = [
os.path.abspath(os.path.dirname(sys.executable)),
os.path.abspath(os.path.join(os.path.dirname(sys.executable), "bin")),
os.path.abspath(os.path.join(os.path.dirname(sys.executable), "..")),
os.path.join(CuraApplication.getInstallPrefix(), "bin"),
os.path.dirname(os.path.abspath(sys.executable)),
]
for path in search_path:
engine_path = os.path.join(path, executable_name)
if os.path.isfile(engine_path):
default_engine_location = engine_path
break
if Platform.isLinux() and not default_engine_location:
if not os.getenv("PATH"):
raise OSError("There is something wrong with your Linux installation.")

View file

@ -1,4 +1,4 @@
# Copyright (c) 2019 Ultimaker B.V.
# Copyright (c) 2020 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
import numpy
@ -153,7 +153,7 @@ class StartSliceJob(Job):
self.setResult(StartJobResult.MaterialIncompatible)
return
for position, extruder_stack in stack.extruders.items():
for extruder_stack in stack.extruderList:
material = extruder_stack.findContainer({"type": "material"})
if not extruder_stack.isEnabled:
continue
@ -162,7 +162,6 @@ class StartSliceJob(Job):
self.setResult(StartJobResult.MaterialIncompatible)
return
# Don't slice if there is a per object setting with an error value.
for node in DepthFirstIterator(self._scene.getRoot()):
if not isinstance(node, CuraSceneNode) or not node.isSelectable():
@ -172,146 +171,145 @@ class StartSliceJob(Job):
self.setResult(StartJobResult.ObjectSettingError)
return
with self._scene.getSceneLock():
# Remove old layer data.
for node in DepthFirstIterator(self._scene.getRoot()):
if node.callDecoration("getLayerData") and node.callDecoration("getBuildPlateNumber") == self._build_plate_number:
# Singe we walk through all nodes in the scene, they always have a parent.
cast(SceneNode, node.getParent()).removeChild(node)
break
# Remove old layer data.
for node in DepthFirstIterator(self._scene.getRoot()):
if node.callDecoration("getLayerData") and node.callDecoration("getBuildPlateNumber") == self._build_plate_number:
# Singe we walk through all nodes in the scene, they always have a parent.
cast(SceneNode, node.getParent()).removeChild(node)
break
# Get the objects in their groups to print.
object_groups = []
if stack.getProperty("print_sequence", "value") == "one_at_a_time":
for node in OneAtATimeIterator(self._scene.getRoot()):
temp_list = []
# Node can't be printed, so don't bother sending it.
if getattr(node, "_outside_buildarea", False):
continue
# Filter on current build plate
build_plate_number = node.callDecoration("getBuildPlateNumber")
if build_plate_number is not None and build_plate_number != self._build_plate_number:
continue
children = node.getAllChildren()
children.append(node)
for child_node in children:
mesh_data = child_node.getMeshData()
if mesh_data and mesh_data.getVertices() is not None:
temp_list.append(child_node)
if temp_list:
object_groups.append(temp_list)
Job.yieldThread()
if len(object_groups) == 0:
Logger.log("w", "No objects suitable for one at a time found, or no correct order found")
else:
# Get the objects in their groups to print.
object_groups = []
if stack.getProperty("print_sequence", "value") == "one_at_a_time":
for node in OneAtATimeIterator(self._scene.getRoot()):
temp_list = []
has_printing_mesh = False
for node in DepthFirstIterator(self._scene.getRoot()):
mesh_data = node.getMeshData()
if node.callDecoration("isSliceable") and mesh_data and mesh_data.getVertices() is not None:
is_non_printing_mesh = bool(node.callDecoration("isNonPrintingMesh"))
# Find a reason not to add the node
if node.callDecoration("getBuildPlateNumber") != self._build_plate_number:
continue
if getattr(node, "_outside_buildarea", False) and not is_non_printing_mesh:
continue
# Node can't be printed, so don't bother sending it.
if getattr(node, "_outside_buildarea", False):
continue
temp_list.append(node)
if not is_non_printing_mesh:
has_printing_mesh = True
# Filter on current build plate
build_plate_number = node.callDecoration("getBuildPlateNumber")
if build_plate_number is not None and build_plate_number != self._build_plate_number:
continue
Job.yieldThread()
# If the list doesn't have any model with suitable settings then clean the list
# otherwise CuraEngine will crash
if not has_printing_mesh:
temp_list.clear()
children = node.getAllChildren()
children.append(node)
for child_node in children:
mesh_data = child_node.getMeshData()
if mesh_data and mesh_data.getVertices() is not None:
temp_list.append(child_node)
if temp_list:
object_groups.append(temp_list)
Job.yieldThread()
if len(object_groups) == 0:
Logger.log("w", "No objects suitable for one at a time found, or no correct order found")
else:
temp_list = []
has_printing_mesh = False
for node in DepthFirstIterator(self._scene.getRoot()):
mesh_data = node.getMeshData()
if node.callDecoration("isSliceable") and mesh_data and mesh_data.getVertices() is not None:
is_non_printing_mesh = bool(node.callDecoration("isNonPrintingMesh"))
global_stack = CuraApplication.getInstance().getGlobalContainerStack()
if not global_stack:
return
extruders_enabled = {position: stack.isEnabled for position, stack in global_stack.extruders.items()}
filtered_object_groups = []
has_model_with_disabled_extruders = False
associated_disabled_extruders = set()
for group in object_groups:
stack = global_stack
skip_group = False
for node in group:
# Only check if the printing extruder is enabled for printing meshes
is_non_printing_mesh = node.callDecoration("evaluateIsNonPrintingMesh")
extruder_position = node.callDecoration("getActiveExtruderPosition")
if not is_non_printing_mesh and not extruders_enabled[extruder_position]:
skip_group = True
has_model_with_disabled_extruders = True
associated_disabled_extruders.add(extruder_position)
if not skip_group:
filtered_object_groups.append(group)
if has_model_with_disabled_extruders:
self.setResult(StartJobResult.ObjectsWithDisabledExtruder)
associated_disabled_extruders = {str(c) for c in sorted([int(p) + 1 for p in associated_disabled_extruders])}
self.setMessage(", ".join(associated_disabled_extruders))
return
# There are cases when there is nothing to slice. This can happen due to one at a time slicing not being
# able to find a possible sequence or because there are no objects on the build plate (or they are outside
# the build volume)
if not filtered_object_groups:
self.setResult(StartJobResult.NothingToSlice)
return
self._buildGlobalSettingsMessage(stack)
self._buildGlobalInheritsStackMessage(stack)
# Build messages for extruder stacks
for extruder_stack in global_stack.extruderList:
self._buildExtruderMessage(extruder_stack)
for group in filtered_object_groups:
group_message = self._slice_message.addRepeatedMessage("object_lists")
parent = group[0].getParent()
if parent is not None and parent.callDecoration("isGroup"):
self._handlePerObjectSettings(cast(CuraSceneNode, parent), group_message)
for object in group:
mesh_data = object.getMeshData()
if mesh_data is None:
# Find a reason not to add the node
if node.callDecoration("getBuildPlateNumber") != self._build_plate_number:
continue
if getattr(node, "_outside_buildarea", False) and not is_non_printing_mesh:
continue
rot_scale = object.getWorldTransformation().getTransposed().getData()[0:3, 0:3]
translate = object.getWorldTransformation().getData()[:3, 3]
# This effectively performs a limited form of MeshData.getTransformed that ignores normals.
verts = mesh_data.getVertices()
verts = verts.dot(rot_scale)
verts += translate
temp_list.append(node)
if not is_non_printing_mesh:
has_printing_mesh = True
# Convert from Y up axes to Z up axes. Equals a 90 degree rotation.
verts[:, [1, 2]] = verts[:, [2, 1]]
verts[:, 1] *= -1
Job.yieldThread()
obj = group_message.addRepeatedMessage("objects")
obj.id = id(object)
obj.name = object.getName()
indices = mesh_data.getIndices()
if indices is not None:
flat_verts = numpy.take(verts, indices.flatten(), axis=0)
else:
flat_verts = numpy.array(verts)
# If the list doesn't have any model with suitable settings then clean the list
# otherwise CuraEngine will crash
if not has_printing_mesh:
temp_list.clear()
obj.vertices = flat_verts
if temp_list:
object_groups.append(temp_list)
self._handlePerObjectSettings(cast(CuraSceneNode, object), obj)
global_stack = CuraApplication.getInstance().getGlobalContainerStack()
if not global_stack:
return
extruders_enabled = {position: stack.isEnabled for position, stack in global_stack.extruders.items()}
filtered_object_groups = []
has_model_with_disabled_extruders = False
associated_disabled_extruders = set()
for group in object_groups:
stack = global_stack
skip_group = False
for node in group:
# Only check if the printing extruder is enabled for printing meshes
is_non_printing_mesh = node.callDecoration("evaluateIsNonPrintingMesh")
extruder_position = node.callDecoration("getActiveExtruderPosition")
if not is_non_printing_mesh and not extruders_enabled[extruder_position]:
skip_group = True
has_model_with_disabled_extruders = True
associated_disabled_extruders.add(extruder_position)
if not skip_group:
filtered_object_groups.append(group)
Job.yieldThread()
if has_model_with_disabled_extruders:
self.setResult(StartJobResult.ObjectsWithDisabledExtruder)
associated_disabled_extruders = {str(c) for c in sorted([int(p) + 1 for p in associated_disabled_extruders])}
self.setMessage(", ".join(associated_disabled_extruders))
return
# There are cases when there is nothing to slice. This can happen due to one at a time slicing not being
# able to find a possible sequence or because there are no objects on the build plate (or they are outside
# the build volume)
if not filtered_object_groups:
self.setResult(StartJobResult.NothingToSlice)
return
self._buildGlobalSettingsMessage(stack)
self._buildGlobalInheritsStackMessage(stack)
# Build messages for extruder stacks
for extruder_stack in global_stack.extruderList:
self._buildExtruderMessage(extruder_stack)
for group in filtered_object_groups:
group_message = self._slice_message.addRepeatedMessage("object_lists")
parent = group[0].getParent()
if parent is not None and parent.callDecoration("isGroup"):
self._handlePerObjectSettings(cast(CuraSceneNode, parent), group_message)
for object in group:
mesh_data = object.getMeshData()
if mesh_data is None:
continue
rot_scale = object.getWorldTransformation().getTransposed().getData()[0:3, 0:3]
translate = object.getWorldTransformation().getData()[:3, 3]
# This effectively performs a limited form of MeshData.getTransformed that ignores normals.
verts = mesh_data.getVertices()
verts = verts.dot(rot_scale)
verts += translate
# Convert from Y up axes to Z up axes. Equals a 90 degree rotation.
verts[:, [1, 2]] = verts[:, [2, 1]]
verts[:, 1] *= -1
obj = group_message.addRepeatedMessage("objects")
obj.id = id(object)
obj.name = object.getName()
indices = mesh_data.getIndices()
if indices is not None:
flat_verts = numpy.take(verts, indices.flatten(), axis=0)
else:
flat_verts = numpy.array(verts)
obj.vertices = flat_verts
self._handlePerObjectSettings(cast(CuraSceneNode, object), obj)
Job.yieldThread()
self.setResult(StartJobResult.Finished)
@ -345,10 +343,7 @@ class StartSliceJob(Job):
result["time"] = time.strftime("%H:%M:%S") #Some extra settings.
result["date"] = time.strftime("%d-%m-%Y")
result["day"] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][int(time.strftime("%w"))]
initial_extruder_stack = CuraApplication.getInstance().getExtruderManager().getUsedExtruderStacks()[0]
initial_extruder_nr = initial_extruder_stack.getProperty("extruder_nr", "value")
result["initial_extruder_nr"] = initial_extruder_nr
result["initial_extruder_nr"] = CuraApplication.getInstance().getExtruderManager().getInitialExtruderNr()
return result

View file

@ -2,7 +2,7 @@
"name": "CuraEngine Backend",
"author": "Ultimaker B.V.",
"description": "Provides the link to the CuraEngine slicing backend.",
"api": "7.0",
"api": "7.1",
"version": "1.0.1",
"i18n-catalog": "cura"
}

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Provides support for importing Cura profiles.",
"api": "7.0",
"api": "7.1",
"i18n-catalog": "cura"
}

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Provides support for exporting Cura profiles.",
"api": "7.0",
"api": "7.1",
"i18n-catalog":"cura"
}

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Checks for firmware updates.",
"api": "7.0",
"api": "7.1",
"i18n-catalog": "cura"
}

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Provides a machine actions for updating firmware.",
"api": "7.0",
"api": "7.1",
"i18n-catalog": "cura"
}

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Reads g-code from a compressed archive.",
"api": "7.0",
"api": "7.1",
"i18n-catalog": "cura"
}

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Writes g-code to a compressed archive.",
"api": "7.0",
"api": "7.1",
"i18n-catalog": "cura"
}

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Provides support for importing profiles from g-code files.",
"api": "7.0",
"api": "7.1",
"i18n-catalog": "cura"
}

View file

@ -1,4 +1,4 @@
# Copyright (c) 2018 Ultimaker B.V.
# Copyright (c) 2020 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
import math
@ -258,16 +258,19 @@ class FlavorParser:
continue
if item.startswith(";"):
continue
if item[0] == "X":
x = float(item[1:])
if item[0] == "Y":
y = float(item[1:])
if item[0] == "Z":
z = float(item[1:])
if item[0] == "F":
f = float(item[1:]) / 60
if item[0] == "E":
e = float(item[1:])
try:
if item[0] == "X":
x = float(item[1:])
if item[0] == "Y":
y = float(item[1:])
if item[0] == "Z":
z = float(item[1:])
if item[0] == "F":
f = float(item[1:]) / 60
if item[0] == "E":
e = float(item[1:])
except ValueError: # Improperly formatted g-code: Coordinates are not floats.
continue # Skip the command then.
params = PositionOptional(x, y, z, f, e)
return func(position, params, path)
return position

View file

@ -1,8 +1,8 @@
{
"name": "G-code Reader",
"author": "Victor Larchenko, Ultimaker",
"author": "Victor Larchenko, Ultimaker B.V.",
"version": "1.0.1",
"description": "Allows loading and displaying G-code files.",
"api": "7.0",
"api": "7.1",
"i18n-catalog": "cura"
}

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Writes g-code to a file.",
"api": "7.0",
"api": "7.1",
"i18n-catalog": "cura"
}

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Enables ability to generate printable geometry from 2D image files.",
"api": "7.0",
"api": "7.1",
"i18n-catalog": "cura"
}

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Provides support for importing profiles from legacy Cura versions.",
"api": "7.0",
"api": "7.1",
"i18n-catalog": "cura"
}

View file

@ -1,8 +1,8 @@
{
"name": "Machine Settings action",
"author": "fieldOfView",
"name": "Machine Settings Action",
"author": "fieldOfView, Ultimaker B.V.",
"version": "1.0.1",
"description": "Provides a way to change machine settings (such as build volume, nozzle size, etc.).",
"api": "7.0",
"api": "7.1",
"i18n-catalog": "cura"
}

View file

@ -2,7 +2,7 @@
"name": "Model Checker",
"author": "Ultimaker B.V.",
"version": "1.0.1",
"api": "7.0",
"api": "7.1",
"description": "Checks models and print configuration for possible printing issues and give suggestions.",
"i18n-catalog": "cura"
}

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Provides a monitor stage in Cura.",
"api": "7.0",
"api": "7.1",
"i18n-catalog": "cura"
}

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Provides the Per Model Settings.",
"api": "7.0",
"api": "7.1",
"i18n-catalog": "cura"
}

View file

@ -44,6 +44,9 @@ class PostProcessingPlugin(QObject, Extension):
# There can be duplicates, which will be executed in sequence.
self._script_list = [] # type: List[Script]
self._selected_script_index = -1
self._global_container_stack = Application.getInstance().getGlobalContainerStack()
if self._global_container_stack:
self._global_container_stack.metaDataChanged.connect(self._restoreScriptInforFromMetadata)
Application.getInstance().getOutputDeviceManager().writeStarted.connect(self.execute)
Application.getInstance().globalContainerStackChanged.connect(self._onGlobalContainerStackChanged) # When the current printer changes, update the list of scripts.
@ -209,33 +212,34 @@ class PostProcessingPlugin(QObject, Extension):
self.scriptListChanged.emit()
self._propertyChanged()
## When the global container stack is changed, swap out the list of active
# scripts.
def _onGlobalContainerStackChanged(self) -> None:
def _restoreScriptInforFromMetadata(self):
self.loadAllScripts()
new_stack = Application.getInstance().getGlobalContainerStack()
new_stack = self._global_container_stack
if new_stack is None:
return
self._script_list.clear()
if not new_stack.getMetaDataEntry("post_processing_scripts"): # Missing or empty.
self.scriptListChanged.emit() # Even emit this if it didn't change. We want it to write the empty list to the stack's metadata.
if not new_stack.getMetaDataEntry("post_processing_scripts"): # Missing or empty.
self.scriptListChanged.emit() # Even emit this if it didn't change. We want it to write the empty list to the stack's metadata.
self.setSelectedScriptIndex(-1)
return
self._script_list.clear()
scripts_list_strs = new_stack.getMetaDataEntry("post_processing_scripts")
for script_str in scripts_list_strs.split("\n"): # Encoded config files should never contain three newlines in a row. At most 2, just before section headers.
for script_str in scripts_list_strs.split(
"\n"): # Encoded config files should never contain three newlines in a row. At most 2, just before section headers.
if not script_str: # There were no scripts in this one (or a corrupt file caused more than 3 consecutive newlines here).
continue
script_str = script_str.replace(r"\\\n", "\n").replace(r"\\\\", "\\\\") # Unescape escape sequences.
script_parser = configparser.ConfigParser(interpolation = None)
script_parser = configparser.ConfigParser(interpolation=None)
script_parser.optionxform = str # type: ignore # Don't transform the setting keys as they are case-sensitive.
script_parser.read_string(script_str)
for script_name, settings in script_parser.items(): # There should only be one, really! Otherwise we can't guarantee the order or allow multiple uses of the same script.
if script_name == "DEFAULT": # ConfigParser always has a DEFAULT section, but we don't fill it. Ignore this one.
continue
if script_name not in self._loaded_scripts: # Don't know this post-processing plug-in.
Logger.log("e", "Unknown post-processing script {script_name} was encountered in this global stack.".format(script_name = script_name))
if script_name not in self._loaded_scripts: # Don't know this post-processing plug-in.
Logger.log("e",
"Unknown post-processing script {script_name} was encountered in this global stack.".format(
script_name=script_name))
continue
new_script = self._loaded_scripts[script_name]()
new_script.initialize()
@ -245,7 +249,22 @@ class PostProcessingPlugin(QObject, Extension):
self._script_list.append(new_script)
self.setSelectedScriptIndex(0)
# Ensure that we always force an update (otherwise the fields don't update correctly!)
self.selectedIndexChanged.emit()
self.scriptListChanged.emit()
self._propertyChanged()
## When the global container stack is changed, swap out the list of active
# scripts.
def _onGlobalContainerStackChanged(self) -> None:
if self._global_container_stack:
self._global_container_stack.metaDataChanged.disconnect(self._restoreScriptInforFromMetadata)
self._global_container_stack = Application.getInstance().getGlobalContainerStack()
if self._global_container_stack:
self._global_container_stack.metaDataChanged.connect(self._restoreScriptInforFromMetadata)
self._restoreScriptInforFromMetadata()
@pyqtSlot()
def writeScriptsToStack(self) -> None:
@ -267,14 +286,18 @@ class PostProcessingPlugin(QObject, Extension):
script_list_string = "\n".join(script_list_strs) # ConfigParser should never output three newlines in a row when serialised, so it's a safe delimiter.
global_stack = Application.getInstance().getGlobalContainerStack()
if global_stack is None:
if self._global_container_stack is None:
return
if "post_processing_scripts" not in global_stack.getMetaData():
global_stack.setMetaDataEntry("post_processing_scripts", "")
# Ensure we don't get triggered by our own write.
self._global_container_stack.metaDataChanged.disconnect(self._restoreScriptInforFromMetadata)
global_stack.setMetaDataEntry("post_processing_scripts", script_list_string)
if "post_processing_scripts" not in self._global_container_stack.getMetaData():
self._global_container_stack.setMetaDataEntry("post_processing_scripts", "")
self._global_container_stack.setMetaDataEntry("post_processing_scripts", script_list_string)
# We do want to listen to other events.
self._global_container_stack.metaDataChanged.connect(self._restoreScriptInforFromMetadata)
## Creates the view used by show popup. The view is saved because of the fairly aggressive garbage collection.
def _createView(self) -> None:

View file

@ -2,7 +2,7 @@
"name": "Post Processing",
"author": "Ultimaker",
"version": "2.2.1",
"api": "7.0",
"api": "7.1",
"description": "Extension that allows for user created scripts for post processing",
"catalog": "cura"
}

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

@ -35,7 +35,7 @@ class GCodeStep():
Class to store the current value of each G_Code parameter
for any G-Code step
"""
def __init__(self, step, in_relative_movement: bool = False):
def __init__(self, step, in_relative_movement: bool = False) -> None:
self.step = step
self.step_x = 0
self.step_y = 0

View file

@ -2,6 +2,7 @@
from ..Script import Script
class TimeLapse(Script):
def __init__(self):
super().__init__()
@ -75,21 +76,29 @@ class TimeLapse(Script):
trigger_command = self.getSettingValueByKey("trigger_command")
pause_length = self.getSettingValueByKey("pause_length")
gcode_to_append = ";TimeLapse Begin\n"
last_x = 0
last_y = 0
if park_print_head:
gcode_to_append += self.putValue(G = 1, F = feed_rate, X = x_park, Y = y_park) + " ;Park print head\n"
gcode_to_append += self.putValue(M = 400) + " ;Wait for moves to finish\n"
gcode_to_append += self.putValue(G=1, F=feed_rate,
X=x_park, Y=y_park) + " ;Park print head\n"
gcode_to_append += self.putValue(M=400) + " ;Wait for moves to finish\n"
gcode_to_append += trigger_command + " ;Snap Photo\n"
gcode_to_append += self.putValue(G = 4, P = pause_length) + " ;Wait for camera\n"
gcode_to_append += ";TimeLapse End\n"
for layer in data:
gcode_to_append += self.putValue(G=4, P=pause_length) + " ;Wait for camera\n"
for idx, layer in enumerate(data):
for line in layer.split("\n"):
if self.getValue(line, "G") in {0, 1}: # Track X,Y location.
last_x = self.getValue(line, "X", last_x)
last_y = self.getValue(line, "Y", last_y)
# Check that a layer is being printed
lines = layer.split("\n")
for line in lines:
if ";LAYER:" in line:
index = data.index(layer)
layer += gcode_to_append
data[index] = layer
layer += "G0 X%s Y%s\n" % (last_x, last_y)
data[idx] = layer
break
return data

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Provides a prepare stage in Cura.",
"api": "7.0",
"api": "7.1",
"i18n-catalog": "cura"
}

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Provides a preview stage in Cura.",
"api": "7.0",
"api": "7.1",
"i18n-catalog": "cura"
}

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"description": "Provides removable drive hotplugging and writing support.",
"version": "1.0.1",
"api": "7.0",
"api": "7.1",
"i18n-catalog": "cura"
}

View file

@ -3,7 +3,10 @@
from UM.Logger import LogOutput
from typing import Set
from sentry_sdk import add_breadcrumb
try:
from sentry_sdk import add_breadcrumb
except ImportError:
pass
from typing import Optional
import os

View file

@ -1,6 +1,11 @@
# Copyright (c) 2019 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from typing import TYPE_CHECKING, Dict, Any
try:
import sentry_sdk
has_sentry = True
except ImportError:
has_sentry = False
from . import SentryLogger
@ -13,4 +18,6 @@ def getMetaData() -> Dict[str, Any]:
def register(app: "Application") -> Dict[str, Any]:
if not has_sentry:
return {} # Nothing to do here!
return {"logger": SentryLogger.SentryLogger()}

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.0",
"description": "Logs certain events so that they can be used by the crash reporter",
"api": "7.0",
"api": "7.1",
"i18n-catalog": "cura"
}

View file

@ -120,7 +120,10 @@ class SimulationPass(RenderPass):
end = self._layer_view.end_elements_index
index = self._layer_view._current_path_num
offset = 0
for polygon in layer_data.getLayer(self._layer_view._current_layer_num).polygons:
layer = layer_data.getLayer(self._layer_view._current_layer_num)
if layer is None:
continue
for polygon in layer.polygons:
# The size indicates all values in the two-dimension array, and the second dimension is
# always size 3 because we have 3D points.
if index >= polygon.data.size // 3 - offset:

View file

@ -1,4 +1,4 @@
# Copyright (c) 2018 Ultimaker B.V.
# Copyright (c) 2020 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
import sys
@ -116,8 +116,9 @@ class SimulationView(CuraView):
self._only_show_top_layers = bool(Application.getInstance().getPreferences().getValue("view/only_show_top_layers"))
self._compatibility_mode = self._evaluateCompatibilityMode()
self._wireprint_warning_message = Message(catalog.i18nc("@info:status", "Cura does not accurately display layers when Wire Printing is enabled"),
self._wireprint_warning_message = Message(catalog.i18nc("@info:status", "Cura does not accurately display layers when Wire Printing is enabled."),
title = catalog.i18nc("@info:title", "Simulation View"))
self._slice_first_warning_message = Message(catalog.i18nc("@info:status", "Nothing is shown because you need to slice first."), title = catalog.i18nc("@info:title", "No layers to show"))
QtApplication.getInstance().engineCreatedSignal.connect(self._onEngineCreated)
@ -149,6 +150,7 @@ class SimulationView(CuraView):
if self._activity == activity:
return
self._activity = activity
self._updateSliceWarningVisibility()
self.activityChanged.emit()
def getSimulationPass(self) -> SimulationPass:
@ -543,11 +545,13 @@ class SimulationView(CuraView):
self._composite_pass.getLayerBindings().append("simulationview")
self._old_composite_shader = self._composite_pass.getCompositeShader()
self._composite_pass.setCompositeShader(self._simulationview_composite_shader)
self._updateSliceWarningVisibility()
elif event.type == Event.ViewDeactivateEvent:
self._controller.getScene().getRoot().childrenChanged.disconnect(self._onSceneChanged)
Application.getInstance().getPreferences().preferenceChanged.disconnect(self._onPreferencesChanged)
self._wireprint_warning_message.hide()
self._slice_first_warning_message.hide()
Application.getInstance().globalContainerStackChanged.disconnect(self._onGlobalStackChanged)
if self._global_container_stack:
self._global_container_stack.propertyChanged.disconnect(self._onPropertyChanged)
@ -661,6 +665,12 @@ class SimulationView(CuraView):
self._updateWithPreferences()
def _updateSliceWarningVisibility(self):
if not self.getActivity():
self._slice_first_warning_message.show()
else:
self._slice_first_warning_message.hide()
class _CreateTopLayersJob(Job):
def __init__(self, scene: "Scene", layer_number: int, solid_layers: int) -> None:

View file

@ -112,7 +112,7 @@ Cura.ExpandableComponent
type_id: 1
})
layerViewTypes.append({
text: catalog.i18nc("@label:listbox", "Feedrate"),
text: catalog.i18nc("@label:listbox", "Speed"),
type_id: 2
})
layerViewTypes.append({

View file

@ -11,7 +11,7 @@ if TYPE_CHECKING:
class SimulationViewProxy(QObject):
def __init__(self, simulation_view: "SimulationView", parent=None):
def __init__(self, simulation_view: "SimulationView", parent=None) -> None:
super().__init__(parent)
self._simulation_view = simulation_view
self._current_layer = 0

View file

@ -80,7 +80,7 @@ vertex41core =
case 1: // "Line type"
v_color = a_color;
break;
case 2: // "Feedrate"
case 2: // "Speed", or technically 'Feedrate'
v_color = feedrateGradientColor(a_feedrate, u_min_feedrate, u_max_feedrate);
break;
case 3: // "Layer thickness"

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Provides the Simulation view.",
"api": "7.0",
"api": "7.1",
"i18n-catalog": "cura"
}

View file

@ -72,6 +72,7 @@ Window
right: parent.right
}
text: catalog.i18nc("@text:window", "Ultimaker Cura collects anonymous data in order to improve the print quality and user experience. Below is an example of all the data that is shared:")
color: UM.Theme.getColor("text")
wrapMode: Text.WordWrap
renderType: Text.NativeRendering
}
@ -88,7 +89,7 @@ Window
right: parent.right
}
textArea.text: manager.getExampleData()
textArea.text: (manager === null) ? "" : manager.getExampleData()
textArea.textFormat: Text.RichText
textArea.wrapMode: Text.Wrap
textArea.readOnly: true

View file

@ -5,14 +5,13 @@ import json
import os
import platform
import time
from typing import cast, Optional, Set
from typing import cast, Optional, Set, TYPE_CHECKING
from PyQt5.QtCore import pyqtSlot, QObject
from PyQt5.QtNetwork import QNetworkRequest
from UM.Extension import Extension
from UM.Application import Application
from UM.Scene.Iterator.DepthFirstIterator import DepthFirstIterator
from UM.Message import Message
from UM.i18n import i18nCatalog
from UM.Logger import Logger
from UM.PluginRegistry import PluginRegistry
@ -20,7 +19,8 @@ from UM.Qt.Duration import DurationFormat
from cura import ApplicationMetadata
from .SliceInfoJob import SliceInfoJob
if TYPE_CHECKING:
from PyQt5.QtNetwork import QNetworkReply
catalog = i18nCatalog("cura")
@ -36,7 +36,8 @@ class SliceInfo(QObject, Extension):
QObject.__init__(self, parent)
Extension.__init__(self)
self._application = Application.getInstance()
from cura.CuraApplication import CuraApplication
self._application = CuraApplication.getInstance()
self._application.getOutputDeviceManager().writeStarted.connect(self._onWriteStarted)
self._application.getPreferences().addPreference("info/send_slice_info", True)
@ -56,7 +57,7 @@ class SliceInfo(QObject, Extension):
## Perform action based on user input.
# Note that clicking "Disable" won't actually disable the data sending, but rather take the user to preferences where they can disable it.
def messageActionTriggered(self, message_id, action_id):
Application.getInstance().getPreferences().setValue("info/asked_send_slice_info", True)
self._application.getPreferences().setValue("info/asked_send_slice_info", True)
if action_id == "MoreInfo":
self.showMoreInfoDialog()
self.send_slice_info_message.hide()
@ -69,7 +70,7 @@ class SliceInfo(QObject, Extension):
def _createDialog(self, qml_name):
Logger.log("d", "Creating dialog [%s]", qml_name)
file_path = os.path.join(PluginRegistry.getInstance().getPluginPath(self.getPluginId()), qml_name)
dialog = Application.getInstance().createQmlComponent(file_path, {"manager": self})
dialog = self._application.createQmlComponent(file_path, {"manager": self})
return dialog
@pyqtSlot(result = str)
@ -87,12 +88,10 @@ class SliceInfo(QObject, Extension):
@pyqtSlot(bool)
def setSendSliceInfo(self, enabled: bool):
Application.getInstance().getPreferences().setValue("info/send_slice_info", enabled)
self._application.getPreferences().setValue("info/send_slice_info", enabled)
def _getUserModifiedSettingKeys(self) -> list:
from cura.CuraApplication import CuraApplication
application = cast(CuraApplication, Application.getInstance())
machine_manager = application.getMachineManager()
machine_manager = self._application.getMachineManager()
global_stack = machine_manager.activeMachine
user_modified_setting_keys = set() # type: Set[str]
@ -106,30 +105,28 @@ class SliceInfo(QObject, Extension):
def _onWriteStarted(self, output_device):
try:
if not Application.getInstance().getPreferences().getValue("info/send_slice_info"):
if not self._application.getPreferences().getValue("info/send_slice_info"):
Logger.log("d", "'info/send_slice_info' is turned off.")
return # Do nothing, user does not want to send data
from cura.CuraApplication import CuraApplication
application = cast(CuraApplication, Application.getInstance())
machine_manager = application.getMachineManager()
print_information = application.getPrintInformation()
machine_manager = self._application.getMachineManager()
print_information = self._application.getPrintInformation()
global_stack = machine_manager.activeMachine
data = dict() # The data that we're going to submit.
data["time_stamp"] = time.time()
data["schema_version"] = 0
data["cura_version"] = application.getVersion()
data["cura_version"] = self._application.getVersion()
data["cura_build_type"] = ApplicationMetadata.CuraBuildType
active_mode = Application.getInstance().getPreferences().getValue("cura/active_mode")
active_mode = self._application.getPreferences().getValue("cura/active_mode")
if active_mode == 0:
data["active_mode"] = "recommended"
else:
data["active_mode"] = "custom"
data["camera_view"] = application.getPreferences().getValue("general/camera_perspective_mode")
data["camera_view"] = self._application.getPreferences().getValue("general/camera_perspective_mode")
if data["camera_view"] == "orthographic":
data["camera_view"] = "orthogonal" #The database still only recognises the old name "orthogonal".
@ -142,7 +139,7 @@ class SliceInfo(QObject, Extension):
machine_settings_changed_by_user = True
data["machine_settings_changed_by_user"] = machine_settings_changed_by_user
data["language"] = Application.getInstance().getPreferences().getValue("general/language")
data["language"] = self._application.getPreferences().getValue("general/language")
data["os"] = {"type": platform.system(), "version": platform.version()}
data["active_machine"] = {"definition_id": global_stack.definition.getId(),
@ -184,7 +181,7 @@ class SliceInfo(QObject, Extension):
data["models"] = []
# Listing all files placed on the build plate
for node in DepthFirstIterator(application.getController().getScene().getRoot()):
for node in DepthFirstIterator(self._application.getController().getScene().getRoot()):
if node.callDecoration("isSliceable"):
model = dict()
model["hash"] = node.getMeshData().getHash()
@ -263,10 +260,23 @@ class SliceInfo(QObject, Extension):
# Convert data to bytes
binary_data = json.dumps(data).encode("utf-8")
# Sending slice info non-blocking
reportJob = SliceInfoJob(self.info_url, binary_data)
reportJob.start()
# Send slice info non-blocking
network_manager = self._application.getHttpRequestManager()
network_manager.post(self.info_url, data = binary_data,
callback = self._onRequestFinished, error_callback = self._onRequestError)
except Exception:
# We really can't afford to have a mistake here, as this would break the sending of g-code to a device
# (Either saving or directly to a printer). The functionality of the slice data is not *that* important.
Logger.logException("e", "Exception raised while sending slice info.") # But we should be notified about these problems of course.
def _onRequestFinished(self, reply: "QNetworkReply") -> None:
status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute)
if status_code == 200:
Logger.log("i", "SliceInfo sent successfully")
return
data = reply.readAll().data().decode("utf-8")
Logger.log("e", "SliceInfo request failed, status code %s, data: %s", status_code, data)
def _onRequestError(self, reply: "QNetworkReply", error: "QNetworkReply.NetworkError") -> None:
Logger.log("e", "Got error for SliceInfo request: %s", reply.errorString())

View file

@ -1,43 +0,0 @@
# Copyright (c) 2017 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from UM.Job import Job
from UM.Logger import Logger
from UM.Platform import Platform
import ssl
import urllib.request
import urllib.error
import certifi
class SliceInfoJob(Job):
def __init__(self, url, data):
super().__init__()
self._url = url
self._data = data
def run(self):
if not self._url or not self._data:
Logger.log("e", "URL or DATA for sending slice info was not set!")
return
# CURA-6698 Create an SSL context and use certifi CA certificates for verification.
context = ssl.SSLContext(protocol = ssl.PROTOCOL_TLSv1_2)
context.load_verify_locations(cafile = certifi.where())
# Submit data
kwoptions = {"data": self._data,
"timeout": 5,
"context": context}
Logger.log("i", "Sending anonymous slice info to [%s]...", self._url)
try:
f = urllib.request.urlopen(self._url, **kwoptions)
Logger.log("i", "Sent anonymous slice info.")
f.close()
except urllib.error.HTTPError:
Logger.logException("e", "An HTTP error occurred while trying to send slice information")
except Exception: # We don't want any exception to cause problems
Logger.logException("e", "An exception occurred while trying to send slice information")

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Submits anonymous slice info. Can be disabled through preferences.",
"api": "7.0",
"api": "7.1",
"i18n-catalog": "cura"
}

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Provides a normal solid mesh view.",
"api": "7.0",
"api": "7.1",
"i18n-catalog": "cura"
}

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Creates an eraser mesh to block the printing of support in certain places",
"api": "7.0",
"api": "7.1",
"i18n-catalog": "cura"
}

View file

@ -2,6 +2,7 @@
# Toolbox is released under the terms of the LGPLv3 or higher.
from .src import Toolbox
from .src.CloudSync.SyncOrchestrator import SyncOrchestrator
def getMetaData():
@ -9,4 +10,6 @@ def getMetaData():
def register(app):
return {"extension": Toolbox.Toolbox(app)}
return {
"extension": [Toolbox.Toolbox(app), SyncOrchestrator(app)]
}

View file

@ -2,6 +2,6 @@
"name": "Toolbox",
"author": "Ultimaker B.V.",
"version": "1.0.1",
"api": "7.0",
"api": "7.1",
"description": "Find, manage and install new Cura packages."
}

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24">
<path d="M19,3H5A2.9,2.9,0,0,0,2,6V9a3.9,3.9,0,0,0,2,3.4V22H20V12.4A3.9,3.9,0,0,0,22,9V6A2.9,2.9,0,0,0,19,3ZM10,5h4V9a2,2,0,0,1-4,0ZM4,9V5H8V9A2,2,0,0,1,4,9ZM18,20H14V15H10v5H6V13a3.7,3.7,0,0,0,3-1.4A3.7,3.7,0,0,0,12,13a3.7,3.7,0,0,0,3-1.4A3.7,3.7,0,0,0,18,13ZM20,9a2,2,0,0,1-4,0V5h4Z" />
</svg>

After

Width:  |  Height:  |  Size: 364 B

View file

@ -96,17 +96,12 @@ Window
visible: toolbox.restartRequired
height: visible ? UM.Theme.getSize("toolbox_footer").height : 0
}
// TODO: Clean this up:
Connections
{
target: toolbox
onShowLicenseDialog:
{
licenseDialog.pluginName = toolbox.getLicenseDialogPluginName();
licenseDialog.licenseContent = toolbox.getLicenseDialogLicenseContent();
licenseDialog.pluginFileLocation = toolbox.getLicenseDialogPluginFileLocation();
licenseDialog.show();
}
onShowLicenseDialog: { licenseDialog.show() }
onCloseLicenseDialog: { licenseDialog.close() }
}
ToolboxLicenseDialog

View file

@ -14,17 +14,44 @@ Rectangle
Column
{
height: childrenRect.height + 2 * padding
spacing: UM.Theme.getSize("default_margin").width
spacing: UM.Theme.getSize("default_margin").height
width: parent.width
padding: UM.Theme.getSize("wide_margin").height
Label
Item
{
id: heading
text: catalog.i18nc("@label", "Featured")
width: parent.width
color: UM.Theme.getColor("text_medium")
font: UM.Theme.getFont("large")
renderType: Text.NativeRendering
width: parent.width - parent.padding * 2
height: childrenRect.height
Label
{
id: heading
text: catalog.i18nc("@label", "Featured")
width: contentWidth
height: contentHeight
color: UM.Theme.getColor("text_medium")
font: UM.Theme.getFont("large")
renderType: Text.NativeRendering
}
UM.TooltipArea
{
width: childrenRect.width
height: childrenRect.height
anchors.right: parent.right
text: catalog.i18nc("@info:tooltip", "Go to Web Marketplace")
Label
{
text: "<a href='%2'>".arg(toolbox.getWebMarketplaceUrl("materials")) + catalog.i18nc("@label", "Search materials") + "</a>"
width: contentWidth
height: contentHeight
horizontalAlignment: Text.AlignRight
font: UM.Theme.getFont("default")
renderType: Text.NativeRendering
linkColor: UM.Theme.getColor("text_link")
onLinkActivated: Qt.openUrlExternally(link)
visible: toolbox.viewCategory === "material"
}
}
}
Grid
{

View file

@ -1,4 +1,4 @@
// Copyright (c) 2018 Ultimaker B.V.
// Copyright (c) 2020 Ultimaker B.V.
// Toolbox is released under the terms of the LGPLv3 or higher.
import QtQuick 2.10
@ -51,32 +51,25 @@ Item
toolbox.viewPage = "overview"
}
}
}
ToolboxTabButton
{
id: installedTabButton
text: catalog.i18nc("@title:tab", "Installed")
active: toolbox.viewCategory == "installed"
enabled: !toolbox.isDownloading
anchors
ToolboxTabButton
{
right: parent.right
rightMargin: UM.Theme.getSize("default_margin").width
id: installedTabButton
text: catalog.i18nc("@title:tab", "Installed")
active: toolbox.viewCategory == "installed"
enabled: !toolbox.isDownloading
onClicked: toolbox.viewCategory = "installed"
width: UM.Theme.getSize("toolbox_header_tab").width + marketplaceNotificationIcon.width - UM.Theme.getSize("default_margin").width
}
onClicked: toolbox.viewCategory = "installed"
width: UM.Theme.getSize("toolbox_header_tab").width + marketplaceNotificationIcon.width - UM.Theme.getSize("default_margin").width
}
Cura.NotificationIcon
{
id: marketplaceNotificationIcon
visible: CuraApplication.getPackageManager().packagesWithUpdate.length > 0
anchors.right: installedTabButton.right
anchors.verticalCenter: installedTabButton.verticalCenter
anchors.right: bar.right
labelText:
{
const itemCount = CuraApplication.getPackageManager().packagesWithUpdate.length
@ -84,6 +77,33 @@ Item
}
}
UM.TooltipArea
{
id: webMarketplaceButtonTooltipArea
width: childrenRect.width
height: parent.height
text: catalog.i18nc("@info:tooltip", "Go to Web Marketplace")
anchors
{
right: parent.right
rightMargin: UM.Theme.getSize("default_margin").width
verticalCenter: parent.verticalCenter
}
onClicked: Qt.openUrlExternally(toolbox.getWebMarketplaceUrl("plugins"))
UM.RecolorImage
{
id: cloudMarketplaceButton
source: "../../images/shop.svg"
color: UM.Theme.getColor(webMarketplaceButtonTooltipArea.containsMouse ? "primary" : "text")
height: parent.height / 2
width: height
anchors.verticalCenter: parent.verticalCenter
sourceSize.width: width
sourceSize.height: height
}
}
ToolboxShadow
{
anchors.top: bar.bottom

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
@ -48,19 +50,20 @@ UM.Dialog{
{
font: UM.Theme.getFont("default")
text: catalog.i18nc("@label", "The following packages will be added:")
visible: subscribedPackagesModel.hasCompatiblePackages
color: UM.Theme.getColor("text")
height: contentHeight + UM.Theme.getSize("default_margin").height
}
Repeater
{
model: toolbox.subscribedPackagesModel
model: subscribedPackagesModel
Component
{
Item
{
width: parent.width
property var lineHeight: 60
visible: model.is_compatible == "True" ? true : false
property int lineHeight: 60
visible: model.is_compatible
height: visible ? (lineHeight + UM.Theme.getSize("default_margin").height) : 0 // We only show the compatible packages here
Image
{
@ -73,7 +76,7 @@ UM.Dialog{
}
Label
{
text: model.name
text: model.display_name
font: UM.Theme.getFont("medium_bold")
anchors.left: packageIcon.right
anchors.leftMargin: UM.Theme.getSize("default_margin").width
@ -89,20 +92,21 @@ UM.Dialog{
Label
{
font: UM.Theme.getFont("default")
text: catalog.i18nc("@label", "The following packages can not be installed because of incompatible Cura version:")
text: catalog.i18nc("@label", "The following packages can not be installed because of an incompatible Cura version:")
visible: subscribedPackagesModel.hasIncompatiblePackages
color: UM.Theme.getColor("text")
height: contentHeight + UM.Theme.getSize("default_margin").height
}
Repeater
{
model: toolbox.subscribedPackagesModel
model: subscribedPackagesModel
Component
{
Item
{
width: parent.width
property var lineHeight: 60
visible: model.is_compatible == "True" ? false : true
property int lineHeight: 60
visible: !model.is_compatible && !model.is_dismissed
height: visible ? (lineHeight + UM.Theme.getSize("default_margin").height) : 0 // We only show the incompatible packages here
Image
{
@ -115,7 +119,7 @@ UM.Dialog{
}
Label
{
text: model.name
text: model.display_name
font: UM.Theme.getFont("medium_bold")
anchors.left: packageIcon.right
anchors.leftMargin: UM.Theme.getSize("default_margin").width
@ -130,13 +134,16 @@ UM.Dialog{
} // End of ScrollView
Cura.ActionButton
Cura.PrimaryButton
{
id: nextButton
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

@ -4,70 +4,106 @@
import QtQuick 2.10
import QtQuick.Dialogs 1.1
import QtQuick.Window 2.2
import QtQuick.Controls 1.4
import QtQuick.Controls 2.3
import QtQuick.Layouts 1.3
import QtQuick.Controls.Styles 1.4
// TODO: Switch to QtQuick.Controls 2.x and remove QtQuick.Controls.Styles
import UM 1.1 as UM
import Cura 1.6 as Cura
UM.Dialog
{
title: catalog.i18nc("@title:window", "Plugin License Agreement")
id: licenseDialog
title: licenseModel.dialogTitle
minimumWidth: UM.Theme.getSize("license_window_minimum").width
minimumHeight: UM.Theme.getSize("license_window_minimum").height
width: minimumWidth
height: minimumHeight
property var pluginName;
property var licenseContent;
property var pluginFileLocation;
Item
backgroundColor: UM.Theme.getColor("main_background")
margin: screenScaleFactor * 10
ColumnLayout
{
anchors.fill: parent
spacing: UM.Theme.getSize("thick_margin").height
UM.I18nCatalog{id: catalog; name: "cura"}
Label
{
id: licenseTitle
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
text: licenseDialog.pluginName + ": " + 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?")
id: licenseHeader
Layout.fillWidth: true
text: catalog.i18nc("@label", "You need to accept the license to install the package")
color: UM.Theme.getColor("text")
wrapMode: Text.Wrap
renderType: Text.NativeRendering
}
TextArea
{
id: licenseText
anchors.top: licenseTitle.bottom
anchors.bottom: parent.bottom
Row {
id: packageRow
anchors.left: parent.left
anchors.right: parent.right
anchors.topMargin: UM.Theme.getSize("default_margin").height
readOnly: true
text: licenseDialog.licenseContent || ""
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
color: UM.Theme.getColor("text")
font.bold: true
anchors.verticalCenter: icon.verticalCenter
height: contentHeight
wrapMode: Text.Wrap
renderType: Text.NativeRendering
}
}
Cura.ScrollableTextArea
{
Layout.fillWidth: true
Layout.fillHeight: true
anchors.topMargin: UM.Theme.getSize("default_margin").height
textArea.text: licenseModel.licenseText
textArea.readOnly: true
}
}
rightButtons:
[
Button
Cura.PrimaryButton
{
id: acceptButton
anchors.margins: UM.Theme.getSize("default_margin").width
text: catalog.i18nc("@action:button", "Accept")
onClicked:
{
licenseDialog.close();
toolbox.install(licenseDialog.pluginFileLocation);
}
},
Button
leftPadding: UM.Theme.getSize("dialog_primary_button_padding").width
rightPadding: UM.Theme.getSize("dialog_primary_button_padding").width
text: licenseModel.acceptButtonText
onClicked: { handler.onLicenseAccepted() }
}
]
leftButtons:
[
Cura.SecondaryButton
{
id: declineButton
anchors.margins: UM.Theme.getSize("default_margin").width
text: catalog.i18nc("@action:button", "Decline")
onClicked:
{
licenseDialog.close();
}
text: licenseModel.declineButtonText
onClicked: { handler.onLicenseDeclined() }
}
]
}

View file

@ -4,7 +4,7 @@
import re
from typing import Dict, List, Optional, Union
from PyQt5.QtCore import Qt, pyqtProperty, pyqtSignal
from PyQt5.QtCore import Qt, pyqtProperty
from UM.Qt.ListModel import ListModel

View file

@ -0,0 +1,28 @@
from typing import Union
from cura import ApplicationMetadata, UltimakerCloudAuthentication
class CloudApiModel:
sdk_version = ApplicationMetadata.CuraSDKVersion # type: Union[str, int]
cloud_api_version = UltimakerCloudAuthentication.CuraCloudAPIVersion # type: str
cloud_api_root = UltimakerCloudAuthentication.CuraCloudAPIRoot # type: str
api_url = "{cloud_api_root}/cura-packages/v{cloud_api_version}/cura/v{sdk_version}".format(
cloud_api_root = cloud_api_root,
cloud_api_version = cloud_api_version,
sdk_version = sdk_version
) # type: str
# https://api.ultimaker.com/cura-packages/v1/user/packages
api_url_user_packages = "{cloud_api_root}/cura-packages/v{cloud_api_version}/user/packages".format(
cloud_api_root=cloud_api_root,
cloud_api_version=cloud_api_version,
)
## https://api.ultimaker.com/cura-packages/v1/user/packages/{package_id}
@classmethod
def userPackageUrl(cls, package_id: str) -> str:
return (CloudApiModel.api_url_user_packages + "/{package_id}").format(
package_id=package_id
)

View file

@ -0,0 +1,105 @@
import json
from typing import Optional
from PyQt5.QtCore import QObject
from PyQt5.QtNetwork import QNetworkReply, QNetworkRequest
from UM import i18nCatalog
from UM.Logger import Logger
from UM.Message import Message
from UM.Signal import Signal
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:
super().__init__()
self.discrepancies = Signal() # Emits SubscribedPackagesModel
self._application = application # type: CuraApplication
self._scope = UltimakerCloudScope(application)
self._model = SubscribedPackagesModel()
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
self._application.getCuraAPI().account.loginStateChanged.connect(self._fetchUserSubscribedPackages)
def _fetchUserSubscribedPackages(self) -> None:
if self._application.getCuraAPI().account.isLoggedIn:
self._getUserSubscribedPackages()
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 Web Marketplace but not in Cura marketplace
package_discrepancy = list(set(user_subscribed_packages).difference(user_installed_packages))
if package_discrepancy:
self._model.addDiscrepancies(package_discrepancy)
self._model.initialize(subscribed_packages_payload)
self._handlePackageDiscrepancies()
def _handlePackageDiscrepancies(self) -> None:
Logger.log("d", "Discrepancy found between Cloud subscribed packages and Cura installed packages")
sync_message = Message(self._i18n_catalog.i18nc(
"@info:generic",
"\nDo you want to sync material and software packages with your account?"),
title=self._i18n_catalog.i18nc("@info:title", "Changes detected from your Ultimaker account", ))
sync_message.addAction("sync",
name=self._i18n_catalog.i18nc("@action:button", "Sync"),
icon="",
description="Sync your Cloud subscribed packages to your local environment.",
button_align=Message.ActionButtonAlignment.ALIGN_RIGHT)
sync_message.actionTriggered.connect(self._onSyncButtonClicked)
sync_message.show()
def _onSyncButtonClicked(self, sync_message: Message, sync_message_action: str) -> None:
sync_message.hide()
self.discrepancies.emit(self._model)

View file

@ -0,0 +1,51 @@
from UM.Logger import Logger
from UM.TaskManagement.HttpRequestManager import HttpRequestManager
from cura.CuraApplication import CuraApplication
from ..CloudApiModel import CloudApiModel
from ..UltimakerCloudScope import UltimakerCloudScope
class CloudPackageManager:
"""Manages Cloud subscriptions
When a package is added to a user's account, the user is 'subscribed' to that package.
Whenever the user logs in on another instance of Cura, these subscriptions can be used to sync the user's plugins
Singleton: use CloudPackageManager.getInstance() instead of CloudPackageManager()
"""
__instance = None
@classmethod
def getInstance(cls, app: CuraApplication):
if not cls.__instance:
cls.__instance = CloudPackageManager(app)
return cls.__instance
def __init__(self, app: CuraApplication) -> None:
if self.__instance is not None:
raise RuntimeError("This is a Singleton. use getInstance()")
self._scope = UltimakerCloudScope(app) # type: UltimakerCloudScope
app.getPackageManager().packageInstalled.connect(self._onPackageInstalled)
def unsubscribe(self, package_id: str) -> None:
url = CloudApiModel.userPackageUrl(package_id)
HttpRequestManager.getInstance().delete(url = url, scope = self._scope)
def _subscribe(self, package_id: str) -> None:
"""You probably don't want to use this directly. All installed packages will be automatically subscribed."""
Logger.debug("Subscribing to {}", package_id)
data = "{\"data\": {\"package_id\": \"%s\", \"sdk_version\": \"%s\"}}" % (package_id, CloudApiModel.sdk_version)
HttpRequestManager.getInstance().put(
url = CloudApiModel.api_url_user_packages,
data = data.encode(),
scope = self._scope
)
def _onPackageInstalled(self, package_id: str):
if CuraApplication.getInstance().getCuraAPI().account.isLoggedIn:
# We might already be subscribed, but checking would take one extra request. Instead, simply subscribe
self._subscribe(package_id)

View file

@ -0,0 +1,39 @@
import os
from typing import Optional
from PyQt5.QtCore import QObject, pyqtSlot
from UM.Qt.QtApplication import QtApplication
from UM.Signal import Signal
from .SubscribedPackagesModel import SubscribedPackagesModel
## Shows a list of packages to be added or removed. The user can select which packages to (un)install. The user's
# choices are emitted on the `packageMutations` Signal.
class DiscrepanciesPresenter(QObject):
def __init__(self, app: QtApplication) -> None:
super().__init__(app)
self.packageMutations = Signal() # Emits SubscribedPackagesModel
self._app = app
self._package_manager = app.getPackageManager()
self._dialog = None # type: Optional[QObject]
self._compatibility_dialog_path = "resources/qml/dialogs/CompatibilityDialog.qml"
def present(self, plugin_path: str, model: SubscribedPackagesModel) -> None:
path = os.path.join(plugin_path, self._compatibility_dialog_path)
self._dialog = self._app.createQmlComponent(path, {"subscribedPackagesModel": model, "handler": self})
assert self._dialog
self._dialog.accepted.connect(lambda: self._onConfirmClicked(model))
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
if model.getCompatiblePackages():
model.setItems(model.getCompatiblePackages())
self.packageMutations.emit(model)

View file

@ -0,0 +1,144 @@
import tempfile
from typing import Dict, List, Any
from PyQt5.QtNetwork import QNetworkReply
from UM import i18n_catalog
from UM.Logger import Logger
from UM.Message import Message
from UM.Signal import Signal
from UM.TaskManagement.HttpRequestManager import HttpRequestManager
from cura.CuraApplication import CuraApplication
from .SubscribedPackagesModel import SubscribedPackagesModel
from ..UltimakerCloudScope import UltimakerCloudScope
## Downloads a set of packages from the Ultimaker Cloud Marketplace
# use download() exactly once: should not be used for multiple sets of downloads since this class contains state
class DownloadPresenter:
DISK_WRITE_BUFFER_SIZE = 256 * 1024 # 256 KB
def __init__(self, app: CuraApplication) -> None:
# Emits (Dict[str, str], List[str]) # (success_items, error_items)
# Dict{success_package_id, temp_file_path}
# List[errored_package_id]
self.done = Signal()
self._app = app
self._scope = UltimakerCloudScope(app)
self._started = False
self._progress_message = self._createProgressMessage()
self._progress = {} # type: Dict[str, Dict[str, Any]] # package_id, Dict
self._error = [] # type: List[str] # package_id
def download(self, model: SubscribedPackagesModel) -> None:
if self._started:
Logger.error("Download already started. Create a new %s instead", self.__class__.__name__)
return
manager = HttpRequestManager.getInstance()
for item in model.items:
package_id = item["package_id"]
def finishedCallback(reply: QNetworkReply, pid = package_id) -> None:
self._onFinished(pid, reply)
def progressCallback(rx: int, rt: int, pid = package_id) -> None:
self._onProgress(pid, rx, rt)
def errorCallback(reply: QNetworkReply, error: QNetworkReply.NetworkError, pid = package_id) -> None:
self._onError(pid)
request_data = manager.get(
item["download_url"],
callback = finishedCallback,
download_progress_callback = progressCallback,
error_callback = errorCallback,
scope = self._scope)
self._progress[package_id] = {
"received": 0,
"total": 1, # make sure this is not considered done yet. Also divByZero-safe
"file_written": None,
"request_data": request_data,
"package_model": item
}
self._started = True
self._progress_message.show()
def abort(self) -> None:
manager = HttpRequestManager.getInstance()
for item in self._progress.values():
manager.abortRequest(item["request_data"])
# Aborts all current operations and returns a copy with the same settings such as app and scope
def resetCopy(self) -> "DownloadPresenter":
self.abort()
self.done.disconnectAll()
return DownloadPresenter(self._app)
def _createProgressMessage(self) -> Message:
return Message(i18n_catalog.i18nc(
"@info:generic",
"\nSyncing..."),
lifetime = 0,
use_inactivity_timer=False,
progress = 0.0,
title = i18n_catalog.i18nc("@info:title", "Changes detected from your Ultimaker account", ))
def _onFinished(self, package_id: str, reply: QNetworkReply) -> None:
self._progress[package_id]["received"] = self._progress[package_id]["total"]
try:
with tempfile.NamedTemporaryFile(mode ="wb+", suffix =".curapackage", delete = False) 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)
self._app.processEvents()
self._progress[package_id]["file_written"] = temp_file.name
except IOError as e:
Logger.logException("e", "Failed to write downloaded package to temp file", e)
self._onError(package_id)
temp_file.close()
self._checkDone()
def _onProgress(self, package_id: str, rx: int, rt: int) -> None:
self._progress[package_id]["received"] = rx
self._progress[package_id]["total"] = rt
received = 0
total = 0
for item in self._progress.values():
received += item["received"]
total += item["total"]
self._progress_message.setProgress(100.0 * (received / total)) # [0 .. 100] %
def _onError(self, package_id: str) -> None:
self._progress.pop(package_id)
self._error.append(package_id)
self._checkDone()
def _checkDone(self) -> bool:
for item in self._progress.values():
if not item["file_written"]:
return False
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()
self.done.emit(success_items, error_items)
return True

View file

@ -0,0 +1,77 @@
from PyQt5.QtCore import QObject, pyqtProperty, pyqtSignal
from UM.i18n import i18nCatalog
catalog = i18nCatalog("cura")
# Model for the ToolboxLicenseDialog
class LicenseModel(QObject):
DEFAULT_DECLINE_BUTTON_TEXT = catalog.i18nc("@button", "Decline")
ACCEPT_BUTTON_TEXT = catalog.i18nc("@button", "Agree")
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._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=packageNameChanged)
def packageName(self) -> str:
return self._package_name
def setPackageName(self, name: str) -> None:
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:
return self._license_text
def setLicenseText(self, license_text: str) -> None:
if self._license_text != license_text:
self._license_text = license_text
self.licenseTextChanged.emit()
def setCurrentPageIdx(self, idx: int) -> None:
self._current_page_idx = idx
self._updateDialogTitle()
def setPageCount(self, count: int) -> None:
self._page_count = count
self._updateDialogTitle()
def _updateDialogTitle(self):
self._dialogTitle = catalog.i18nc("@title:window", "Plugin License Agreement")
if self._page_count > 1:
self._dialogTitle = self._dialogTitle + " ({}/{})".format(self._current_page_idx + 1, self._page_count)
self.dialogTitleChanged.emit()

View file

@ -0,0 +1,113 @@
import os
from collections import OrderedDict
from typing import Dict, Optional, List, Any
from PyQt5.QtCore import QObject, pyqtSlot
from UM.PackageManager import PackageManager
from UM.Signal import Signal
from cura.CuraApplication import CuraApplication
from UM.i18n import i18nCatalog
from .LicenseModel import LicenseModel
## Call present() to show a licenseDialog for a set of packages
# licenseAnswers emits a list of Dicts containing answers when the user has made a choice for all provided packages
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
# [{ "package_id": "BarbarianPlugin", "package_path" : "/tmp/dg345as", "accepted" : True }]
self.licenseAnswers = Signal()
self._current_package_idx = 0
self._package_models = [] # type: List[Dict]
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._page_count = 0
self._app = app
self._compatibility_dialog_path = "resources/qml/dialogs/ToolboxLicenseDialog.qml"
## 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, Dict[str, str]]) -> None:
path = os.path.join(plugin_path, self._compatibility_dialog_path)
self._initState(packages)
if self._page_count == 0:
self.licenseAnswers.emit(self._package_models)
return
if self._dialog is None:
context_properties = {
"catalog": self._catalog,
"licenseModel": self._license_model,
"handler": self
}
self._dialog = self._app.createQmlComponent(path, context_properties)
self._presentCurrentPackage()
@pyqtSlot()
def onLicenseAccepted(self) -> None:
self._package_models[self._current_package_idx]["accepted"] = True
self._checkNextPage()
@pyqtSlot()
def onLicenseDeclined(self) -> None:
self._package_models[self._current_package_idx]["accepted"] = False
self._checkNextPage()
def _initState(self, packages: Dict[str, Dict[str, Any]]) -> None:
implicitly_accepted_count = 0
for package_id, item in packages.items():
item["package_id"] = package_id
item["licence_content"] = self._package_manager.getPackageLicense(item["package_path"])
if item["licence_content"] is None:
# Implicitly accept when there is no license
item["accepted"] = True
implicitly_accepted_count = implicitly_accepted_count + 1
self._package_models.append(item)
else:
item["accepted"] = None #: None: no answer yet
# When presenting the packages, we want to show packages which have a license first.
# In fact, we don't want to show the others at all because they are implicitly accepted
self._package_models.insert(0, item)
CuraApplication.getInstance().processEvents()
self._page_count = len(self._package_models) - implicitly_accepted_count
self._license_model.setPageCount(self._page_count)
def _presentCurrentPackage(self) -> None:
package_model = self._package_models[self._current_package_idx]
package_info = self._package_manager.getPackageInfo(package_model["package_path"])
self._license_model.setCurrentPageIdx(self._current_package_idx)
self._license_model.setPackageName(package_info["display_name"])
self._license_model.setIconUrl(package_model["icon_url"])
self._license_model.setLicenseText(package_model["licence_content"])
if self._dialog:
self._dialog.open() # Does nothing if already open
def _checkNextPage(self) -> None:
if self._current_package_idx + 1 < self._page_count:
self._current_package_idx += 1
self._presentCurrentPackage()
else:
if self._dialog:
self._dialog.close()
self.licenseAnswers.emit(self._package_models)

View file

@ -0,0 +1,31 @@
from UM import i18nCatalog
from UM.Message import Message
from cura.CuraApplication import CuraApplication
## Presents a dialog telling the user that a restart is required to apply changes
# Since we cannot restart Cura, the app is closed instead when the button is clicked
class RestartApplicationPresenter:
def __init__(self, app: CuraApplication) -> None:
self._app = app
self._i18n_catalog = i18nCatalog("cura")
def present(self) -> None:
app_name = self._app.getApplicationDisplayName()
message = Message(self._i18n_catalog.i18nc(
"@info:generic",
"You need to quit and restart {} before changes have effect.", app_name
))
message.addAction("quit",
name="Quit " + app_name,
icon = "",
description="Close the application",
button_align=Message.ActionButtonAlignment.ALIGN_RIGHT)
message.actionTriggered.connect(self._quitClicked)
message.show()
def _quitClicked(self, *_):
self._app.windowClosed()

View file

@ -0,0 +1,73 @@
# Copyright (c) 2020 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from PyQt5.QtCore import Qt, pyqtProperty, pyqtSlot
from UM.Qt.ListModel import ListModel
from cura import ApplicationMetadata
from UM.Logger import Logger
from typing import List, Dict, Any
class SubscribedPackagesModel(ListModel):
def __init__(self, parent = None):
super().__init__(parent)
self._items = []
self._metadata = None
self._discrepancies = None
self._sdk_version = ApplicationMetadata.CuraSDKVersion
self.addRoleName(Qt.UserRole + 1, "package_id")
self.addRoleName(Qt.UserRole + 2, "display_name")
self.addRoleName(Qt.UserRole + 3, "icon_url")
self.addRoleName(Qt.UserRole + 4, "is_compatible")
self.addRoleName(Qt.UserRole + 5, "is_dismissed")
@pyqtProperty(bool, constant=True)
def hasCompatiblePackages(self) -> bool:
for item in self._items:
if item['is_compatible']:
return True
return False
@pyqtProperty(bool, constant=True)
def hasIncompatiblePackages(self) -> bool:
for item in self._items:
if not item['is_compatible']:
return True
return False
def addDiscrepancies(self, discrepancy: List[str]) -> None:
self._discrepancies = discrepancy
def getCompatiblePackages(self) -> List[Dict[str, Any]]:
return [package for package in self._items if package["is_compatible"]]
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 subscribed_packages_payload:
if item["package_id"] not in self._discrepancies:
continue
package = {
"package_id": item["package_id"],
"display_name": item["display_name"],
"sdk_versions": item["sdk_versions"],
"download_url": item["download_url"],
"md5_hash": item["md5_hash"],
"is_dismissed": False,
}
if self._sdk_version not in item["sdk_versions"]:
package.update({"is_compatible": False})
else:
package.update({"is_compatible": True})
try:
package.update({"icon_url": item["icon_url"]})
except KeyError: # There is no 'icon_url" in the response payload for this package
package.update({"icon_url": ""})
self._items.append(package)
self.setItems(self._items)

View file

@ -0,0 +1,100 @@
import os
from typing import List, Dict, Any, cast
from UM import i18n_catalog
from UM.Extension import Extension
from UM.Logger import Logger
from UM.Message import Message
from UM.PluginRegistry import PluginRegistry
from cura.CuraApplication import CuraApplication
from .CloudPackageChecker import CloudPackageChecker
from .CloudPackageManager import CloudPackageManager
from .DiscrepanciesPresenter import DiscrepanciesPresenter
from .DownloadPresenter import DownloadPresenter
from .LicensePresenter import LicensePresenter
from .RestartApplicationPresenter import RestartApplicationPresenter
from .SubscribedPackagesModel import SubscribedPackagesModel
## Orchestrates the synchronizing of packages from the user account to the installed packages
# Example flow:
# - CloudPackageChecker compares a list of packages the user `subscribed` to in their account
# If there are `discrepancies` between the account and locally installed packages, they are emitted
# - DiscrepanciesPresenter shows a list of packages to be added or removed to the user. It emits the `packageMutations`
# the user selected to be performed
# - The SyncOrchestrator uses PackageManager to remove local packages the users wants to see removed
# - The DownloadPresenter shows a download progress dialog. It emits A tuple of succeeded and failed downloads
# - The LicensePresenter extracts licenses from the downloaded packages and presents a license for each package to
# be installed. It emits the `licenseAnswers` signal for accept or declines
# - The CloudPackageManager removes the declined packages from the account
# - The SyncOrchestrator uses PackageManager to install the downloaded packages and delete temp files.
# - The RestartApplicationPresenter notifies the user that a restart is required for changes to take effect
class SyncOrchestrator(Extension):
def __init__(self, app: CuraApplication) -> None:
super().__init__()
# Differentiate This PluginObject from the Toolbox. self.getId() includes _name.
# getPluginId() will return the same value for The toolbox extension and this one
self._name = "SyncOrchestrator"
self._package_manager = app.getPackageManager()
# Keep a reference to the CloudPackageManager. it watches for installed packages and subscribes to them
self._cloud_package_manager = CloudPackageManager.getInstance(app) # type: CloudPackageManager
self._checker = CloudPackageChecker(app) # type: CloudPackageChecker
self._checker.discrepancies.connect(self._onDiscrepancies)
self._discrepancies_presenter = DiscrepanciesPresenter(app) # type: DiscrepanciesPresenter
self._discrepancies_presenter.packageMutations.connect(self._onPackageMutations)
self._download_presenter = DownloadPresenter(app) # type: DownloadPresenter
self._license_presenter = LicensePresenter(app) # type: LicensePresenter
self._license_presenter.licenseAnswers.connect(self._onLicenseAnswers)
self._restart_presenter = RestartApplicationPresenter(app)
def _onDiscrepancies(self, model: SubscribedPackagesModel) -> None:
plugin_path = cast(str, PluginRegistry.getInstance().getPluginPath(self.getPluginId()))
self._discrepancies_presenter.present(plugin_path, model)
def _onPackageMutations(self, mutations: SubscribedPackagesModel) -> None:
self._download_presenter = self._download_presenter.resetCopy()
self._download_presenter.done.connect(self._onDownloadFinished)
self._download_presenter.download(mutations)
## Called when a set of packages have finished downloading
# \param success_items: Dict[package_id, Dict[str, str]]
# \param error_items: List[package_id]
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)
plugin_path = cast(str, PluginRegistry.getInstance().getPluginPath(self.getPluginId()))
self._license_presenter.present(plugin_path, success_items)
# Called when user has accepted / declined all licenses for the downloaded packages
def _onLicenseAnswers(self, answers: List[Dict[str, Any]]) -> None:
has_changes = False # True when at least one package is installed
for item in answers:
if item["accepted"]:
# install and subscribe packages
if not self._package_manager.installPackage(item["package_path"]):
message = "Could not install {}".format(item["package_id"])
self._showErrorMessage(message)
continue
has_changes = True
else:
self._cloud_package_manager.unsubscribe(item["package_id"])
# delete temp file
os.remove(item["package_path"])
if has_changes:
self._restart_presenter.present()
## Logs an error and shows it to the user
def _showErrorMessage(self, text: str):
Logger.error(text)
Message(text, lifetime=0).show()

View file

@ -1,7 +1,8 @@
# Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from PyQt5.QtCore import Qt, pyqtProperty
from PyQt5.QtCore import Qt
from UM.Qt.ListModel import ListModel

View file

@ -1,46 +0,0 @@
# Copyright (c) 2020 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from PyQt5.QtCore import Qt
from UM.Qt.ListModel import ListModel
from cura import ApplicationMetadata
class SubscribedPackagesModel(ListModel):
def __init__(self, parent = None):
super().__init__(parent)
self._metadata = None
self._discrepancies = None
self._sdk_version = ApplicationMetadata.CuraSDKVersion
self.addRoleName(Qt.UserRole + 1, "name")
self.addRoleName(Qt.UserRole + 2, "icon_url")
self.addRoleName(Qt.UserRole + 3, "is_compatible")
def setMetadata(self, data):
if self._metadata != data:
self._metadata = data
def addValue(self, discrepancy):
if self._discrepancies != discrepancy:
self._discrepancies = discrepancy
def update(self):
items = []
for item in self._metadata:
if item["package_id"] not in self._discrepancies:
continue
package = {"name": item["display_name"], "sdk_versions": item["sdk_versions"]}
if self._sdk_version not in item["sdk_versions"]:
package.update({"is_compatible": "False"})
else:
package.update({"is_compatible": "True"})
try:
package.update({"icon_url": item["icon_url"]})
except KeyError: # There is no 'icon_url" in the response payload for this package
package.update({"icon_url": ""})
items.append(package)
self.setItems(items)

View file

@ -4,10 +4,9 @@
import json
import os
import tempfile
import platform
from typing import cast, Any, Dict, List, Set, TYPE_CHECKING, Tuple, Optional, Union
from PyQt5.QtCore import QUrl, QObject, pyqtProperty, pyqtSignal, pyqtSlot
from PyQt5.QtCore import QObject, pyqtProperty, pyqtSignal, pyqtSlot
from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply
from UM.Logger import Logger
@ -15,24 +14,34 @@ from UM.PluginRegistry import PluginRegistry
from UM.Extension import Extension
from UM.i18n import i18nCatalog
from UM.Version import Version
from UM.Message import Message
from cura import ApplicationMetadata
from cura import UltimakerCloudAuthentication
from cura.CuraApplication import CuraApplication
from cura.Machines.ContainerTree import ContainerTree
from .CloudApiModel import CloudApiModel
from .AuthorsModel import AuthorsModel
from .CloudSync.LicenseModel import LicenseModel
from .PackagesModel import PackagesModel
from .SubscribedPackagesModel import SubscribedPackagesModel
from .UltimakerCloudScope import UltimakerCloudScope
if TYPE_CHECKING:
from UM.TaskManagement.HttpRequestData import HttpRequestData
from cura.Settings.GlobalStack import GlobalStack
i18n_catalog = i18nCatalog("cura")
DEFAULT_MARKETPLACE_ROOT = "https://marketplace.ultimaker.com" # type: str
## The Toolbox class is responsible of communicating with the server through the API
try:
from cura.CuraVersion import CuraMarketplaceRoot
except ImportError:
CuraMarketplaceRoot = DEFAULT_MARKETPLACE_ROOT
# todo Remove license and download dialog, use SyncOrchestrator instead
## Provides a marketplace for users to download plugins an materials
class Toolbox(QObject, Extension):
def __init__(self, application: CuraApplication) -> None:
super().__init__()
@ -40,20 +49,14 @@ class Toolbox(QObject, Extension):
self._application = application # type: CuraApplication
self._sdk_version = ApplicationMetadata.CuraSDKVersion # type: Union[str, int]
self._cloud_api_version = UltimakerCloudAuthentication.CuraCloudAPIVersion # type: str
self._cloud_api_root = UltimakerCloudAuthentication.CuraCloudAPIRoot # type: str
self._api_url = None # type: Optional[str]
# Network:
self._download_request = None # type: Optional[QNetworkRequest]
self._download_reply = None # type: Optional[QNetworkReply]
self._download_request_data = None # type: Optional[HttpRequestData]
self._download_progress = 0 # type: float
self._is_downloading = False # type: bool
self._network_manager = None # type: Optional[QNetworkAccessManager]
self._request_headers = [] # type: List[Tuple[bytes, bytes]]
self._updateRequestHeader()
self._scope = UltimakerCloudScope(application) # type: UltimakerCloudScope
self._request_urls = {} # type: Dict[str, QUrl]
self._request_urls = {} # type: Dict[str, str]
self._to_update = [] # type: List[str] # Package_ids that are waiting to be updated
self._old_plugin_ids = set() # type: Set[str]
self._old_plugin_metadata = dict() # type: Dict[str, Dict[str, Any]]
@ -62,17 +65,15 @@ class Toolbox(QObject, Extension):
self._server_response_data = {
"authors": [],
"packages": [],
"updates": [],
"subscribed_packages": [],
"updates": []
} # type: Dict[str, List[Any]]
# Models:
self._models = {
"authors": AuthorsModel(self),
"packages": PackagesModel(self),
"updates": PackagesModel(self),
"subscribed_packages": SubscribedPackagesModel(self),
} # type: Dict[str, Union[AuthorsModel, PackagesModel, SubscribedPackagesModel]]
"updates": PackagesModel(self)
} # type: Dict[str, Union[AuthorsModel, PackagesModel]]
self._plugins_showcase_model = PackagesModel(self)
self._plugins_available_model = PackagesModel(self)
@ -83,6 +84,8 @@ class Toolbox(QObject, Extension):
self._materials_installed_model = PackagesModel(self)
self._materials_generic_model = PackagesModel(self)
self._license_model = LicenseModel()
# These properties are for keeping track of the UI state:
# ----------------------------------------------------------------------
# View category defines which filter to use, and therefore effectively
@ -105,13 +108,9 @@ class Toolbox(QObject, Extension):
self._restart_required = False # type: bool
# variables for the license agreement dialog
self._license_dialog_plugin_name = "" # type: str
self._license_dialog_license_content = "" # type: str
self._license_dialog_plugin_file_location = "" # type: str
self._restart_dialog_message = "" # type: str
self._application.initializationFinished.connect(self._onAppInitialized)
self._application.getCuraAPI().account.accessTokenChanged.connect(self._updateRequestHeader)
# Signals:
# --------------------------------------------------------------------------
@ -129,11 +128,11 @@ class Toolbox(QObject, Extension):
filterChanged = pyqtSignal()
metadataChanged = pyqtSignal()
showLicenseDialog = pyqtSignal()
closeLicenseDialog = pyqtSignal()
uninstallVariablesChanged = pyqtSignal()
## Go back to the start state (welcome screen or loading if no login required)
def _restart(self):
self._updateRequestHeader()
# For an Essentials build, login is mandatory
if not self._application.getCuraAPI().account.isLoggedIn and ApplicationMetadata.IsEnterpriseVersion:
self.setViewPage("welcome")
@ -141,22 +140,6 @@ class Toolbox(QObject, Extension):
self.setViewPage("loading")
self._fetchPackageData()
def _updateRequestHeader(self):
self._request_headers = [
(b"User-Agent",
str.encode(
"%s/%s (%s %s)" % (
self._application.getApplicationName(),
self._application.getVersion(),
platform.system(),
platform.machine(),
)
))
]
access_token = self._application.getCuraAPI().account.accessToken
if access_token:
self._request_headers.append((b"Authorization", "Bearer {}".format(access_token).encode()))
def _resetUninstallVariables(self) -> None:
self._package_id_to_uninstall = None # type: Optional[str]
self._package_name_to_uninstall = ""
@ -165,29 +148,22 @@ class Toolbox(QObject, Extension):
@pyqtSlot(str, int)
def ratePackage(self, package_id: str, rating: int) -> None:
url = QUrl("{base_url}/packages/{package_id}/ratings".format(base_url = self._api_url, package_id = package_id))
self._rate_request = QNetworkRequest(url)
for header_name, header_value in self._request_headers:
cast(QNetworkRequest, self._rate_request).setRawHeader(header_name, header_value)
url = "{base_url}/packages/{package_id}/ratings".format(base_url = CloudApiModel.api_url, package_id = package_id)
data = "{\"data\": {\"cura_version\": \"%s\", \"rating\": %i}}" % (Version(self._application.getVersion()), rating)
self._rate_reply = cast(QNetworkAccessManager, self._network_manager).put(self._rate_request, data.encode())
@pyqtSlot(result = str)
def getLicenseDialogPluginName(self) -> str:
return self._license_dialog_plugin_name
self._application.getHttpRequestManager().put(url, data = data.encode(), scope = self._scope)
@pyqtSlot(result = str)
def getLicenseDialogPluginFileLocation(self) -> str:
return self._license_dialog_plugin_file_location
@pyqtSlot(result = str)
def getLicenseDialogLicenseContent(self) -> str:
return self._license_dialog_license_content
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)
def openLicenseDialog(self, plugin_name: str, license_content: str, plugin_file_location: str) -> None:
self._license_dialog_plugin_name = plugin_name
self._license_dialog_license_content = license_content
self._license_model.setPackageName(plugin_name)
self._license_model.setLicenseText(license_content)
self._license_dialog_plugin_file_location = plugin_file_location
self.showLicenseDialog.emit()
@ -196,16 +172,6 @@ class Toolbox(QObject, Extension):
def _onAppInitialized(self) -> None:
self._plugin_registry = self._application.getPluginRegistry()
self._package_manager = self._application.getPackageManager()
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,
sdk_version = self._sdk_version
)
# https://api.ultimaker.com/cura-packages/v1/user/packages
self._api_url_user_packages = "{cloud_api_root}/cura-packages/v{cloud_api_version}/user/packages".format(
cloud_api_root = self._cloud_api_root,
cloud_api_version = self._cloud_api_version,
)
# We need to construct a query like installed_packages=ID:VERSION&installed_packages=ID:VERSION, etc.
installed_package_ids_with_versions = [":".join(items) for items in
@ -213,51 +179,28 @@ class Toolbox(QObject, Extension):
installed_packages_query = "&installed_packages=".join(installed_package_ids_with_versions)
self._request_urls = {
"authors": QUrl("{base_url}/authors".format(base_url = self._api_url)),
"packages": QUrl("{base_url}/packages".format(base_url = self._api_url)),
"updates": QUrl("{base_url}/packages/package-updates?installed_packages={query}".format(
base_url = self._api_url, query = installed_packages_query)),
"subscribed_packages": QUrl(self._api_url_user_packages)
"authors": "{base_url}/authors".format(base_url = CloudApiModel.api_url),
"packages": "{base_url}/packages".format(base_url = CloudApiModel.api_url),
"updates": "{base_url}/packages/package-updates?installed_packages={query}".format(
base_url = CloudApiModel.api_url, query = installed_packages_query)
}
self._application.getCuraAPI().account.loginStateChanged.connect(self._restart)
self._application.getCuraAPI().account.loginStateChanged.connect(self._fetchUserSubscribedPackages)
# On boot we check which packages have updates.
if CuraApplication.getInstance().getPreferences().getValue("info/automatic_update_check") and len(installed_package_ids_with_versions) > 0:
# Request the latest and greatest!
self._fetchPackageUpdates()
self._fetchUserSubscribedPackages()
self._makeRequestByType("updates")
def _prepareNetworkManager(self):
if self._network_manager is not None:
self._network_manager.finished.disconnect(self._onRequestFinished)
self._network_manager.networkAccessibleChanged.disconnect(self._onNetworkAccessibleChanged)
self._network_manager = QNetworkAccessManager()
self._network_manager.finished.connect(self._onRequestFinished)
self._network_manager.networkAccessibleChanged.connect(self._onNetworkAccessibleChanged)
def _fetchPackageUpdates(self):
self._prepareNetworkManager()
self._makeRequestByType("updates")
def _fetchPackageData(self):
self._prepareNetworkManager()
# Make remote requests:
def _fetchPackageData(self) -> None:
self._makeRequestByType("packages")
self._makeRequestByType("authors")
# Gather installed packages:
self._updateInstalledModels()
def _fetchUserSubscribedPackages(self):
if self._application.getCuraAPI().account.isLoggedIn:
self._prepareNetworkManager()
self._makeRequestByType("subscribed_packages")
# Displays the toolbox
@pyqtSlot()
def launch(self) -> None:
if not self._dialog:
self._dialog = self._createDialog("Toolbox.qml")
@ -268,7 +211,6 @@ class Toolbox(QObject, Extension):
self._restart()
self._dialog.show()
# Apply enabled/disabled state to installed plugins
self.enabledChanged.emit()
@ -279,7 +221,11 @@ class Toolbox(QObject, Extension):
return None
path = os.path.join(plugin_path, "resources", "qml", qml_name)
dialog = self._application.createQmlComponent(path, {"toolbox": self})
dialog = self._application.createQmlComponent(path, {
"toolbox": self,
"handler": self,
"licenseModel": self._license_model
})
if not dialog:
raise Exception("Failed to create Marketplace dialog")
return dialog
@ -350,13 +296,14 @@ class Toolbox(QObject, Extension):
self.metadataChanged.emit()
@pyqtSlot(str)
def install(self, file_path: str) -> None:
self._package_manager.installPackage(file_path)
def install(self, file_path: str) -> Optional[str]:
package_id = self._package_manager.installPackage(file_path)
self.installChanged.emit()
self._updateInstalledModels()
self.metadataChanged.emit()
self._restart_required = True
self.restartRequiredChanged.emit()
return package_id
## Check package usage and uninstall
# If the package is in use, you'll get a confirmation dialog to set everything to default
@ -428,6 +375,16 @@ class Toolbox(QObject, Extension):
self._resetUninstallVariables()
self.closeConfirmResetDialog()
@pyqtSlot()
def onLicenseAccepted(self):
self.closeLicenseDialog.emit()
package_id = self.install(self.getLicenseDialogPluginFileLocation())
@pyqtSlot()
def onLicenseDeclined(self):
self.closeLicenseDialog.emit()
def _markPackageMaterialsAsToBeUninstalled(self, package_id: str) -> None:
container_registry = self._application.getContainerRegistry()
@ -576,184 +533,136 @@ class Toolbox(QObject, Extension):
# Make API Calls
# --------------------------------------------------------------------------
def _makeRequestByType(self, request_type: str) -> None:
Logger.log("d", "Requesting '%s' metadata from server.", request_type)
request = QNetworkRequest(self._request_urls[request_type])
for header_name, header_value in self._request_headers:
request.setRawHeader(header_name, header_value)
self._updateRequestHeader()
if self._network_manager:
self._network_manager.get(request)
Logger.log("d", "Requesting [%s] metadata from server.", request_type)
url = self._request_urls[request_type]
callback = lambda r, rt = request_type: self._onDataRequestFinished(rt, r)
error_callback = lambda r, e, rt = request_type: self._onDataRequestError(rt, r, e)
self._application.getHttpRequestManager().get(url,
callback = callback,
error_callback = error_callback,
scope=self._scope)
@pyqtSlot(str)
def startDownload(self, url: str) -> None:
Logger.log("i", "Attempting to download & install package from %s.", url)
url = QUrl(url)
self._download_request = QNetworkRequest(url)
if hasattr(QNetworkRequest, "FollowRedirectsAttribute"):
# Patch for Qt 5.6-5.8
cast(QNetworkRequest, self._download_request).setAttribute(QNetworkRequest.FollowRedirectsAttribute, True)
if hasattr(QNetworkRequest, "RedirectPolicyAttribute"):
# Patch for Qt 5.9+
cast(QNetworkRequest, self._download_request).setAttribute(QNetworkRequest.RedirectPolicyAttribute, True)
for header_name, header_value in self._request_headers:
cast(QNetworkRequest, self._download_request).setRawHeader(header_name, header_value)
self._download_reply = cast(QNetworkAccessManager, self._network_manager).get(self._download_request)
callback = lambda r: self._onDownloadFinished(r)
error_callback = lambda r, e: self._onDownloadFailed(r, e)
download_progress_callback = self._onDownloadProgress
request_data = self._application.getHttpRequestManager().get(url,
callback = callback,
error_callback = error_callback,
download_progress_callback = download_progress_callback,
scope=self._scope
)
self._download_request_data = request_data
self.setDownloadProgress(0)
self.setIsDownloading(True)
cast(QNetworkReply, self._download_reply).downloadProgress.connect(self._onDownloadProgress)
@pyqtSlot()
def cancelDownload(self) -> None:
Logger.log("i", "User cancelled the download of a package.")
Logger.log("i", "User cancelled the download of a package. request %s", self._download_request_data)
if self._download_request_data is not None:
self._application.getHttpRequestManager().abortRequest(self._download_request_data)
self._download_request_data = None
self.resetDownload()
def resetDownload(self) -> None:
if self._download_reply:
try:
self._download_reply.downloadProgress.disconnect(self._onDownloadProgress)
except (TypeError, RuntimeError): # Raised when the method is not connected to the signal yet.
pass # Don't need to disconnect.
try:
self._download_reply.abort()
except RuntimeError:
# In some cases the garbage collector is a bit to agressive, which causes the dowload_reply
# to be deleted (especially if the machine has been put to sleep). As we don't know what exactly causes
# this (The issue probably lives in the bowels of (py)Qt somewhere), we can only catch and ignore it.
pass
self._download_reply = None
self._download_request = None
self.setDownloadProgress(0)
self.setIsDownloading(False)
# Handlers for Network Events
# --------------------------------------------------------------------------
def _onNetworkAccessibleChanged(self, network_accessibility: QNetworkAccessManager.NetworkAccessibility) -> None:
if network_accessibility == QNetworkAccessManager.NotAccessible:
self.resetDownload()
def _onDataRequestError(self, request_type: str, reply: "QNetworkReply", error: "QNetworkReply.NetworkError") -> None:
Logger.log("e", "Request [%s] failed due to error [%s]: %s", request_type, error, reply.errorString())
self.setViewPage("errored")
def _onRequestFinished(self, reply: QNetworkReply) -> None:
if reply.error() == QNetworkReply.TimeoutError:
Logger.log("w", "Got a timeout.")
self.setViewPage("errored")
self.resetDownload()
def _onDataRequestFinished(self, request_type: str, reply: "QNetworkReply") -> None:
if reply.operation() != QNetworkAccessManager.GetOperation:
Logger.log("e", "_onDataRequestFinished() only handles GET requests but got [%s] instead", reply.operation())
return
if reply.error() == QNetworkReply.HostNotFoundError:
Logger.log("w", "Unable to reach server.")
http_status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute)
if http_status_code != 200:
Logger.log("e", "Request type [%s] got non-200 HTTP response: [%s]", http_status_code)
self.setViewPage("errored")
self.resetDownload()
return
if reply.operation() == QNetworkAccessManager.GetOperation:
for response_type, url in self._request_urls.items():
if reply.url() == url:
if reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) == 200:
try:
json_data = json.loads(bytes(reply.readAll()).decode("utf-8"))
data = bytes(reply.readAll())
try:
json_data = json.loads(data.decode("utf-8"))
except json.decoder.JSONDecodeError:
Logger.log("e", "Failed to decode response data as JSON for request type [%s], response data [%s]",
request_type, data)
self.setViewPage("errored")
return
# Check for errors:
if "errors" in json_data:
for error in json_data["errors"]:
Logger.log("e", "%s", error["title"])
return
# Check for errors:
if "errors" in json_data:
for error in json_data["errors"]:
Logger.log("e", "Request type [%s] got response showing error: %s", error["title"])
self.setViewPage("errored")
return
# Create model and apply metadata:
if not self._models[response_type]:
Logger.log("e", "Could not find the %s model.", response_type)
break
# Create model and apply metadata:
if not self._models[request_type]:
Logger.log("e", "Could not find the model for request type [%s].", request_type)
self.setViewPage("errored")
return
self._server_response_data[response_type] = json_data["data"]
self._models[response_type].setMetadata(self._server_response_data[response_type])
self._server_response_data[request_type] = json_data["data"]
self._models[request_type].setMetadata(self._server_response_data[request_type])
if response_type == "packages":
self._models[response_type].setFilter({"type": "plugin"})
self.reBuildMaterialsModels()
self.reBuildPluginsModels()
self._notifyPackageManager()
elif response_type == "authors":
self._models[response_type].setFilter({"package_types": "material"})
self._models[response_type].setFilter({"tags": "generic"})
elif response_type == "updates":
# Tell the package manager that there's a new set of updates available.
packages = set([pkg["package_id"] for pkg in self._server_response_data[response_type]])
self._package_manager.setPackagesWithUpdate(packages)
elif response_type == "subscribed_packages":
self._checkCompatibilities(json_data["data"])
if request_type == "packages":
self._models[request_type].setFilter({"type": "plugin"})
self.reBuildMaterialsModels()
self.reBuildPluginsModels()
self._notifyPackageManager()
elif request_type == "authors":
self._models[request_type].setFilter({"package_types": "material"})
self._models[request_type].setFilter({"tags": "generic"})
elif request_type == "updates":
# Tell the package manager that there's a new set of updates available.
packages = set([pkg["package_id"] for pkg in self._server_response_data[request_type]])
self._package_manager.setPackagesWithUpdate(packages)
self.metadataChanged.emit()
self.metadataChanged.emit()
if self.isLoadingComplete():
self.setViewPage("overview")
except json.decoder.JSONDecodeError:
Logger.log("w", "Received invalid JSON for %s.", response_type)
break
else:
Logger.log("w", "Unable to connect with the server, we got a response code %s while trying to connect to %s", reply.attribute(QNetworkRequest.HttpStatusCodeAttribute), reply.url())
self.setViewPage("errored")
self.resetDownload()
def _checkCompatibilities(self, json_data) -> None:
user_subscribed_packages = [plugin["package_id"] for plugin in json_data]
user_installed_packages = self._package_manager.getUserInstalledPackages()
# We check if there are packages installed in Cloud Marketplace but not in Cura marketplace (discrepancy)
package_discrepancy = list(set(user_subscribed_packages).difference(user_installed_packages))
if package_discrepancy:
self._models["subscribed_packages"].addValue(package_discrepancy)
self._models["subscribed_packages"].update()
Logger.log("d", "Discrepancy found between Cloud subscribed packages and Cura installed packages")
sync_message = Message(i18n_catalog.i18nc(
"@info:generic",
"\nDo you want to sync material and software packages with your account?"),
lifetime=0,
title=i18n_catalog.i18nc("@info:title", "Changes detected from your Ultimaker account", ))
sync_message.addAction("sync",
name=i18n_catalog.i18nc("@action:button", "Sync"),
icon="",
description="Sync your Cloud subscribed packages to your local environment.",
button_align=Message.ActionButtonAlignment.ALIGN_RIGHT)
sync_message.actionTriggered.connect(self._onSyncButtonClicked)
sync_message.show()
def _onSyncButtonClicked(self, sync_message: Message, sync_message_action: str) -> None:
sync_message.hide()
compatibility_dialog_path = "resources/qml/dialogs/CompatibilityDialog.qml"
plugin_path_prefix = PluginRegistry.getInstance().getPluginPath(self.getPluginId())
if plugin_path_prefix:
path = os.path.join(plugin_path_prefix, compatibility_dialog_path)
self.compatibility_dialog_view = self._application.getInstance().createQmlComponent(path, {"toolbox": self})
if self.isLoadingComplete():
self.setViewPage("overview")
# This function goes through all known remote versions of a package and notifies the package manager of this change
def _notifyPackageManager(self):
for package in self._server_response_data["packages"]:
self._package_manager.addAvailablePackageVersion(package["package_id"], Version(package["package_version"]))
def _onDownloadFinished(self, reply: "QNetworkReply") -> None:
self.resetDownload()
if reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) != 200:
Logger.log("w", "Failed to download package. The following error was returned: %s",
json.loads(reply.readAll().data().decode("utf-8")))
return
# Must not delete the temporary file on Windows
self._temp_plugin_file = tempfile.NamedTemporaryFile(mode = "w+b", suffix = ".curapackage", delete = False)
file_path = self._temp_plugin_file.name
# Write first and close, otherwise on Windows, it cannot read the file
self._temp_plugin_file.write(reply.readAll())
self._temp_plugin_file.close()
self._onDownloadComplete(file_path)
def _onDownloadFailed(self, reply: "QNetworkReply", error: "QNetworkReply.NetworkError") -> None:
Logger.log("w", "Failed to download package. The following error was returned: %s", error)
self.resetDownload()
def _onDownloadProgress(self, bytes_sent: int, bytes_total: int) -> None:
if bytes_total > 0:
new_progress = bytes_sent / bytes_total * 100
self.setDownloadProgress(new_progress)
if bytes_sent == bytes_total:
self.setIsDownloading(False)
self._download_reply = cast(QNetworkReply, self._download_reply)
self._download_reply.downloadProgress.disconnect(self._onDownloadProgress)
# Check if the download was sucessfull
if self._download_reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) != 200:
try:
Logger.log("w", "Failed to download package. The following error was returned: %s", json.loads(bytes(self._download_reply.readAll()).decode("utf-8")))
except json.decoder.JSONDecodeError:
Logger.logException("w", "Failed to download package and failed to parse a response from it")
finally:
return
# Must not delete the temporary file on Windows
self._temp_plugin_file = tempfile.NamedTemporaryFile(mode = "w+b", suffix = ".curapackage", delete = False)
file_path = self._temp_plugin_file.name
# Write first and close, otherwise on Windows, it cannot read the file
self._temp_plugin_file.write(cast(QNetworkReply, self._download_reply).readAll())
self._temp_plugin_file.close()
self._onDownloadComplete(file_path)
Logger.log("d", "new download progress %s / %s : %s%%", bytes_sent, bytes_total, new_progress)
def _onDownloadComplete(self, file_path: str) -> None:
Logger.log("i", "Download complete.")
@ -763,11 +672,16 @@ 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
self.install(file_path)
installed_id = self.install(file_path)
if installed_id != package_id:
Logger.error("Installed package {} does not match {}".format(installed_id, package_id))
# Getter & Setters for Properties:
# --------------------------------------------------------------------------
@ -789,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:
@ -804,6 +718,11 @@ class Toolbox(QObject, Extension):
self._view_category = category
self.viewChanged.emit()
## Function explicitly defined so that it can be called through the callExtensionsMethod
# which cannot receive arguments.
def setViewCategoryToMaterials(self) -> None:
self.setViewCategory("material")
@pyqtProperty(str, fset = setViewCategory, notify = viewChanged)
def viewCategory(self) -> str:
return self._view_category
@ -823,10 +742,6 @@ class Toolbox(QObject, Extension):
def authorsModel(self) -> AuthorsModel:
return cast(AuthorsModel, self._models["authors"])
@pyqtProperty(QObject, constant = True)
def subscribedPackagesModel(self) -> SubscribedPackagesModel:
return cast(SubscribedPackagesModel, self._models["subscribed_packages"])
@pyqtProperty(QObject, constant = True)
def packagesModel(self) -> PackagesModel:
return cast(PackagesModel, self._models["packages"])
@ -859,6 +774,13 @@ class Toolbox(QObject, Extension):
def materialsGenericModel(self) -> PackagesModel:
return self._materials_generic_model
@pyqtSlot(str, result = str)
def getWebMarketplaceUrl(self, page: str) -> str:
root = CuraMarketplaceRoot
if root == "":
root = DEFAULT_MARKETPLACE_ROOT
return root + "/app/cura/" + page
# Filter Models:
# --------------------------------------------------------------------------
@pyqtSlot(str, str, str)

View file

@ -0,0 +1,28 @@
from PyQt5.QtNetwork import QNetworkRequest
from UM.Logger import Logger
from UM.TaskManagement.HttpRequestScope import DefaultUserAgentScope
from cura.API import Account
from cura.CuraApplication import CuraApplication
## Add a Authorization header to the request for Ultimaker Cloud Api requests.
# When the user is not logged in or a token is not available, a warning will be logged
# Also add the user agent headers (see DefaultUserAgentScope)
class UltimakerCloudScope(DefaultUserAgentScope):
def __init__(self, application: CuraApplication):
super().__init__(application)
api = application.getCuraAPI()
self._account = api.account # type: Account
def request_hook(self, request: QNetworkRequest):
super().request_hook(request)
token = self._account.accessToken
if not self._account.isLoggedIn or token is None:
Logger.warning("Cannot add authorization to Cloud Api request")
return
header_dict = {
"Authorization": "Bearer {}".format(token)
}
self.add_headers(request, header_dict)

View file

@ -3,5 +3,5 @@
"author": "Ultimaker B.V.",
"version": "1.0.0",
"description": "Provides support for reading model files.",
"api": "7.0.0"
"api": "7.1.0"
}

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.0",
"description": "Provides support for reading Ultimaker Format Packages.",
"supported_sdk_versions": ["7.0.0"],
"supported_sdk_versions": ["7.1.0"],
"i18n-catalog": "cura"
}

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Provides support for writing Ultimaker Format Packages.",
"api": "7.0",
"api": "7.1",
"i18n-catalog": "cura"
}

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"description": "Manages network connections to Ultimaker networked printers.",
"version": "2.0.0",
"api": "7.0",
"api": "7.1",
"i18n-catalog": "cura"
}

View file

@ -14,7 +14,7 @@ class ClusterPrinterMaterialStationSlot(ClusterPrintCoreConfiguration):
# \param material_remaining: How much material is remaining on the spool (between 0 and 1, or -1 for missing data).
# \param material_empty: Whether the material spool is too empty to be used.
def __init__(self, slot_index: int, compatible: bool, material_remaining: float,
material_empty: Optional[bool] = False, **kwargs):
material_empty: Optional[bool] = False, **kwargs) -> None:
self.slot_index = slot_index
self.compatible = compatible
self.material_remaining = material_remaining

View file

@ -88,7 +88,8 @@ class USBPrinterOutputDevice(PrinterOutputDevice):
self._firmware_name_requested = False
self._firmware_updater = AvrFirmwareUpdater(self)
self._monitor_view_qml_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "MonitorItem.qml")
plugin_path = cast(str, PluginRegistry.getInstance().getPluginPath("USBPrinting"))
self._monitor_view_qml_path = os.path.join(plugin_path, "MonitorItem.qml")
CuraApplication.getInstance().getOnExitCallbackManager().addCallback(self._checkActivePrintingUponAppExit)

View file

@ -2,7 +2,7 @@
"name": "USB printing",
"author": "Ultimaker B.V.",
"version": "1.0.2",
"api": "7.0",
"api": "7.1",
"description": "Accepts G-Code and sends them to a printer. Plugin can also update firmware.",
"i18n-catalog": "cura"
}

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Provides machine actions for Ultimaker machines (such as bed leveling wizard, selecting upgrades, etc.).",
"api": "7.0",
"api": "7.1",
"i18n-catalog": "cura"
}

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Upgrades configurations from Cura 2.1 to Cura 2.2.",
"api": "7.0",
"api": "7.1",
"i18n-catalog": "cura"
}

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Upgrades configurations from Cura 2.2 to Cura 2.4.",
"api": "7.0",
"api": "7.1",
"i18n-catalog": "cura"
}

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Upgrades configurations from Cura 2.5 to Cura 2.6.",
"api": "7.0",
"api": "7.1",
"i18n-catalog": "cura"
}

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Upgrades configurations from Cura 2.6 to Cura 2.7.",
"api": "7.0",
"api": "7.1",
"i18n-catalog": "cura"
}

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Upgrades configurations from Cura 2.7 to Cura 3.0.",
"api": "7.0",
"api": "7.1",
"i18n-catalog": "cura"
}

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Upgrades configurations from Cura 3.0 to Cura 3.1.",
"api": "7.0",
"api": "7.1",
"i18n-catalog": "cura"
}

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Upgrades configurations from Cura 3.2 to Cura 3.3.",
"api": "7.0",
"api": "7.1",
"i18n-catalog": "cura"
}

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Upgrades configurations from Cura 3.3 to Cura 3.4.",
"api": "7.0",
"api": "7.1",
"i18n-catalog": "cura"
}

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Upgrades configurations from Cura 3.4 to Cura 3.5.",
"api": "7.0",
"api": "7.1",
"i18n-catalog": "cura"
}

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.0",
"description": "Upgrades configurations from Cura 3.5 to Cura 4.0.",
"api": "7.0",
"api": "7.1",
"i18n-catalog": "cura"
}

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Upgrades configurations from Cura 4.0 to Cura 4.1.",
"api": "7.0",
"api": "7.1",
"i18n-catalog": "cura"
}

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.0",
"description": "Upgrades configurations from Cura 4.1 to Cura 4.2.",
"api": "7.0",
"api": "7.1",
"i18n-catalog": "cura"
}

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.0",
"description": "Upgrades configurations from Cura 4.2 to Cura 4.3.",
"api": "7.0",
"api": "7.1",
"i18n-catalog": "cura"
}

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.0",
"description": "Upgrades configurations from Cura 4.3 to Cura 4.4.",
"api": "7.0",
"api": "7.1",
"i18n-catalog": "cura"
}

View file

@ -1,6 +1,16 @@
# Copyright (c) 2020 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
import configparser
from typing import Tuple, List
import fnmatch # To filter files that we need to delete.
import io
import os # To get the path to check for hidden stacks to delete.
import urllib.parse # To get the container IDs from file names.
import re # To filter directories to search for hidden stacks to delete.
from UM.Logger import Logger
from UM.Resources import Resources # To get the path to check for hidden stacks to delete.
from UM.Version import Version # To sort folders by version number.
from UM.VersionUpgrade import VersionUpgrade
# Settings that were merged into one. Each one is a pair of settings. If both
@ -16,6 +26,102 @@ _removed_settings = {
}
class VersionUpgrade44to45(VersionUpgrade):
def __init__(self) -> None:
"""
Creates the version upgrade plug-in from 4.4 to 4.5.
In this case the plug-in will also check for stacks that need to be
deleted.
"""
# Only delete hidden stacks when upgrading from version 4.4. Not 4.3 or 4.5, just when you're starting out from 4.4.
# If you're starting from an earlier version, you can't have had the bug that produces too many hidden stacks (https://github.com/Ultimaker/Cura/issues/6731).
# If you're starting from a later version, the bug was already fixed.
data_storage_root = os.path.dirname(Resources.getDataStoragePath())
folders = set(os.listdir(data_storage_root)) # All version folders.
folders = set(filter(lambda p: re.fullmatch(r"\d+\.\d+", p), folders)) # Only folders with a correct version number as name.
folders.difference_update({os.path.basename(Resources.getDataStoragePath())}) # Remove current version from candidates (since the folder was just copied).
if folders:
latest_version = max(folders, key = Version) # Sort them by semantic version numbering.
if latest_version == "4.4":
self.removeHiddenStacks()
def removeHiddenStacks(self) -> None:
"""
If starting the upgrade from 4.4, this will remove any hidden printer
stacks from the configuration folder as well as all of the user profiles
and definition changes profiles.
This will ONLY run when upgrading from 4.4, not when e.g. upgrading from
4.3 to 4.6 (through 4.4). This is because it's to fix a bug
(https://github.com/Ultimaker/Cura/issues/6731) that occurred in 4.4
only, so only there will it have hidden stacks that need to be deleted.
If people upgrade from 4.3 they don't need to be deleted. If people
upgrade from 4.5 they have already been deleted previously or never got
the broken hidden stacks.
"""
Logger.log("d", "Removing all hidden container stacks.")
hidden_global_stacks = set() # Which global stacks have been found? We'll delete anything referred to by these. Set of stack IDs.
hidden_extruder_stacks = set() # Which extruder stacks refer to the hidden global profiles?
hidden_instance_containers = set() # Which instance containers are referred to by the hidden stacks?
exclude_directories = {"plugins"}
# First find all of the hidden container stacks.
data_storage = Resources.getDataStoragePath()
for root, dirs, files in os.walk(data_storage):
dirs[:] = [dir for dir in dirs if dir not in exclude_directories]
for filename in fnmatch.filter(files, "*.global.cfg"):
parser = configparser.ConfigParser(interpolation = None)
try:
parser.read(os.path.join(root, filename))
except OSError: # File not found or insufficient rights.
continue
except configparser.Error: # Invalid file format.
continue
if "metadata" in parser and "hidden" in parser["metadata"] and parser["metadata"]["hidden"] == "True":
stack_id = urllib.parse.unquote_plus(os.path.basename(filename).split(".")[0])
hidden_global_stacks.add(stack_id)
# The user container and definition changes container are specific to this stack. We need to delete those too.
if "containers" in parser:
if "0" in parser["containers"]: # User container.
hidden_instance_containers.add(parser["containers"]["0"])
if "6" in parser["containers"]: # Definition changes container.
hidden_instance_containers.add(parser["containers"]["6"])
os.remove(os.path.join(root, filename))
# Walk a second time to find all extruder stacks referring to these hidden container stacks.
for root, dirs, files in os.walk(data_storage):
dirs[:] = [dir for dir in dirs if dir not in exclude_directories]
for filename in fnmatch.filter(files, "*.extruder.cfg"):
parser = configparser.ConfigParser(interpolation = None)
try:
parser.read(os.path.join(root, filename))
except OSError: # File not found or insufficient rights.
continue
except configparser.Error: # Invalid file format.
continue
if "metadata" in parser and "machine" in parser["metadata"] and parser["metadata"]["machine"] in hidden_global_stacks:
stack_id = urllib.parse.unquote_plus(os.path.basename(filename).split(".")[0])
hidden_extruder_stacks.add(stack_id)
# The user container and definition changes container are specific to this stack. We need to delete those too.
if "containers" in parser:
if "0" in parser["containers"]: # User container.
hidden_instance_containers.add(parser["containers"]["0"])
if "6" in parser["containers"]: # Definition changes container.
hidden_instance_containers.add(parser["containers"]["6"])
os.remove(os.path.join(root, filename))
# Walk a third time to remove all instance containers that are referred to by either of those.
for root, dirs, files in os.walk(data_storage):
dirs[:] = [dir for dir in dirs if dir not in exclude_directories]
for filename in fnmatch.filter(files, "*.inst.cfg"):
container_id = urllib.parse.unquote_plus(os.path.basename(filename).split(".")[0])
if container_id in hidden_instance_containers:
try:
os.remove(os.path.join(root, filename))
except OSError: # Is a directory, file not found, or insufficient rights.
continue
def getCfgVersion(self, serialised: str) -> int:
parser = configparser.ConfigParser(interpolation = None)
parser.read_string(serialised)

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.0",
"description": "Upgrades configurations from Cura 4.4 to Cura 4.5.",
"api": "7.0",
"api": "7.1",
"i18n-catalog": "cura"
}

View file

@ -1,8 +1,8 @@
{
"name": "X3D Reader",
"author": "Seva Alekseyev",
"author": "Seva Alekseyev, Ultimaker B.V.",
"version": "1.0.1",
"description": "Provides support for reading X3D files.",
"api": "7.0",
"api": "7.1",
"i18n-catalog": "cura"
}

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Provides the X-Ray view.",
"api": "7.0",
"api": "7.1",
"i18n-catalog": "cura"
}

View file

@ -0,0 +1,15 @@
from UM.PluginObject import PluginObject
class PluginInfo(PluginObject):
__instance = None # type: PluginInfo
def __init__(self, *args, **kwags):
super().__init__(*args, **kwags)
if PluginInfo.__instance is not None:
raise RuntimeError("Try to create singleton '%s' more than once" % self.__class__.__name__)
PluginInfo.__instance = self
@classmethod
def getInstance(cls, *args, **kwargs) -> "PluginInfo":
return cls.__instance

Some files were not shown because too many files have changed in this diff Show more