1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
|
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
import re
from enum import Enum
from typing import Any, cast, Dict, List, Optional
from PyQt5.QtCore import pyqtProperty, QObject, pyqtSignal, pyqtSlot
from PyQt5.QtQml import QQmlEngine
from cura.CuraApplication import CuraApplication
from cura.CuraPackageManager import CuraPackageManager
from cura.Settings.CuraContainerRegistry import CuraContainerRegistry # To get names of materials we're compatible with.
from UM.i18n import i18nCatalog # To translate placeholder names if data is not present.
from UM.Logger import Logger
from UM.PluginRegistry import PluginRegistry
catalog = i18nCatalog("cura")
class PackageModel(QObject):
"""
Represents a package, containing all the relevant information to be displayed about a package.
"""
def __init__(self, package_data: Dict[str, Any], section_title: Optional[str] = None, parent: Optional[QObject] = None) -> None:
"""
Constructs a new model for a single package.
:param package_data: The data received from the Marketplace API about the package to create.
:param section_title: If the packages are to be categorized per section provide the section_title
:param parent: The parent QML object that controls the lifetime of this model (normally a PackageList).
"""
super().__init__(parent)
QQmlEngine.setObjectOwnership(self, QQmlEngine.CppOwnership)
self._package_manager: CuraPackageManager = cast(CuraPackageManager, CuraApplication.getInstance().getPackageManager())
self._plugin_registry: PluginRegistry = CuraApplication.getInstance().getPluginRegistry()
self._package_id = package_data.get("package_id", "UnknownPackageId")
self._package_type = package_data.get("package_type", "")
self._is_bundled = package_data.get("is_bundled", False)
self._icon_url = package_data.get("icon_url", "")
self._display_name = package_data.get("display_name", catalog.i18nc("@label:property", "Unknown Package"))
tags = package_data.get("tags", [])
self._is_checked_by_ultimaker = (self._package_type == "plugin" and "verified" in tags) or (
self._package_type == "material" and "certified" in tags)
self._package_version = package_data.get("package_version", "") # Display purpose, no need for 'UM.Version'.
self._package_info_url = package_data.get("website", "") # Not to be confused with 'download_url'.
self._download_count = package_data.get("download_count", 0)
self._description = package_data.get("description", "")
self._formatted_description = self._format(self._description)
self._download_url = package_data.get("download_url", "")
self._release_notes = package_data.get("release_notes", "") # Not used yet, propose to add to description?
subdata = package_data.get("data", {})
self._technical_data_sheet = self._findLink(subdata, "technical_data_sheet")
self._safety_data_sheet = self._findLink(subdata, "safety_data_sheet")
self._where_to_buy = self._findLink(subdata, "where_to_buy")
self._compatible_printers = self._getCompatiblePrinters(subdata)
self._compatible_support_materials = self._getCompatibleSupportMaterials(subdata)
self._is_compatible_material_station = self._isCompatibleMaterialStation(subdata)
self._is_compatible_air_manager = self._isCompatibleAirManager(subdata)
author_data = package_data.get("author", {})
self._author_name = author_data.get("display_name", catalog.i18nc("@label:property", "Unknown Author"))
self._author_info_url = author_data.get("website", "")
if not self._icon_url or self._icon_url == "":
self._icon_url = author_data.get("icon_url", "")
self._can_update = False
self._section_title = section_title
self.sdk_version = package_data.get("sdk_version_semver", "")
# Note that there's a lot more info in the package_data than just these specified here.
self.enablePackageTriggered.connect(self._plugin_registry.enablePlugin)
self.disablePackageTriggered.connect(self._plugin_registry.disablePlugin)
self._is_recently_updated = self._getRecentlyUpdated()
self._is_recently_installed = self._getRecentlyInstalled()
self.updatePackageTriggered.connect(lambda pkg: self._setIsUpdating(True))
self._plugin_registry.hasPluginsEnabledOrDisabledChanged.connect(self.stateManageButtonChanged)
self._package_manager.packageInstalled.connect(lambda pkg_id: self._packageInstalled(pkg_id))
self._package_manager.packageUninstalled.connect(lambda pkg_id: self._packageInstalled(pkg_id))
self._package_manager.packageInstallingFailed.connect(lambda pkg_id: self._packageInstalled(pkg_id))
self._package_manager.packagesWithUpdateChanged.connect(lambda: self.setCanUpdate(self._package_id in self._package_manager.packagesWithUpdate))
self._is_busy = False
def __eq__(self, other: object) -> bool:
if isinstance(other, PackageModel):
return other == self
elif isinstance(other, str):
return other == self._package_id
else:
return False
def __repr__(self) -> str:
return f"<{self._package_id} : {self._package_version} : {self._section_title}>"
def _findLink(self, subdata: Dict[str, Any], link_type: str) -> str:
"""
Searches the package data for a link of a certain type.
The links are not in a fixed path in the package data. We need to iterate over the available links to find them.
:param subdata: The "data" element in the package data, which should contain links.
:param link_type: The type of link to find.
:return: A URL of where the link leads, or an empty string if there is no link of that type in the package data.
"""
links = subdata.get("links", [])
for link in links:
if link.get("type", "") == link_type:
return link.get("url", "")
else:
return "" # No link with the correct type was found.
def _format(self, text: str) -> str:
"""
Formats a user-readable block of text for display.
:return: A block of rich text with formatting embedded.
"""
# Turn all in-line hyperlinks into actual links.
url_regex = re.compile(r"(((http|https)://)[a-zA-Z0-9@:%.\-_+~#?&/=]{2,256}\.[a-z]{2,12}(/[a-zA-Z0-9@:%.\-_+~#?&/=]*)?)")
text = re.sub(url_regex, r'<a href="\1">\1</a>', text)
# Turn newlines into <br> so that they get displayed as newlines when rendering as rich text.
text = text.replace("\n", "<br>")
return text
def _getCompatiblePrinters(self, subdata: Dict[str, Any]) -> List[str]:
"""
Gets the list of printers that this package provides material compatibility with.
Any printer is listed, even if it's only for a single nozzle on a single material in the package.
:param subdata: The "data" element in the package data, which should contain this compatibility information.
:return: A list of printer names that this package provides material compatibility with.
"""
result = set()
for material in subdata.get("materials", []):
for compatibility in material.get("compatibility", []):
printer_name = compatibility.get("machine_name")
if printer_name is None:
continue # Missing printer name information. Skip this one.
for subcompatibility in compatibility.get("compatibilities", []):
if subcompatibility.get("hardware_compatible", False):
result.add(printer_name)
break
return list(sorted(result))
def _getCompatibleSupportMaterials(self, subdata: Dict[str, Any]) -> List[str]:
"""
Gets the list of support materials that the materials in this package are compatible with.
Since the materials are individually encoded as keys in the API response, only PVA and Breakaway are currently
supported.
:param subdata: The "data" element in the package data, which should contain this compatibility information.
:return: A list of support materials that the materials in this package are compatible with.
"""
result = set()
container_registry = CuraContainerRegistry.getInstance()
try:
pva_name = container_registry.findContainersMetadata(id = "ultimaker_pva")[0].get("name", "Ultimaker PVA")
except IndexError:
pva_name = "Ultimaker PVA"
try:
breakaway_name = container_registry.findContainersMetadata(id = "ultimaker_bam")[0].get("name", "Ultimaker Breakaway")
except IndexError:
breakaway_name = "Ultimaker Breakaway"
for material in subdata.get("materials", []):
if material.get("pva_compatible", False):
result.add(pva_name)
if material.get("breakaway_compatible", False):
result.add(breakaway_name)
return list(sorted(result))
def _isCompatibleMaterialStation(self, subdata: Dict[str, Any]) -> bool:
"""
Finds out if this package provides any material that is compatible with the material station.
:param subdata: The "data" element in the package data, which should contain this compatibility information.
:return: Whether this package provides any material that is compatible with the material station.
"""
for material in subdata.get("materials", []):
for compatibility in material.get("compatibility", []):
if compatibility.get("material_station_optimized", False):
return True
return False
def _isCompatibleAirManager(self, subdata: Dict[str, Any]) -> bool:
"""
Finds out if this package provides any material that is compatible with the air manager.
:param subdata: The "data" element in the package data, which should contain this compatibility information.
:return: Whether this package provides any material that is compatible with the air manager.
"""
for material in subdata.get("materials", []):
for compatibility in material.get("compatibility", []):
if compatibility.get("air_manager_optimized", False):
return True
return False
@pyqtProperty(str, constant = True)
def packageId(self) -> str:
return self._package_id
@pyqtProperty(str, constant = True)
def packageType(self) -> str:
return self._package_type
@pyqtProperty(str, constant = True)
def iconUrl(self) -> str:
return self._icon_url
@pyqtProperty(str, constant = True)
def displayName(self) -> str:
return self._display_name
@pyqtProperty(bool, constant = True)
def isCheckedByUltimaker(self):
return self._is_checked_by_ultimaker
@pyqtProperty(str, constant = True)
def packageVersion(self) -> str:
return self._package_version
@pyqtProperty(str, constant = True)
def packageInfoUrl(self) -> str:
return self._package_info_url
@pyqtProperty(int, constant = True)
def downloadCount(self) -> str:
return self._download_count
@pyqtProperty(str, constant = True)
def description(self) -> str:
return self._description
@pyqtProperty(str, constant = True)
def formattedDescription(self) -> str:
return self._formatted_description
@pyqtProperty(str, constant = True)
def authorName(self) -> str:
return self._author_name
@pyqtProperty(str, constant = True)
def authorInfoUrl(self) -> str:
return self._author_info_url
@pyqtProperty(str, constant = True)
def sectionTitle(self) -> Optional[str]:
return self._section_title
@pyqtProperty(str, constant = True)
def technicalDataSheet(self) -> str:
return self._technical_data_sheet
@pyqtProperty(str, constant = True)
def safetyDataSheet(self) -> str:
return self._safety_data_sheet
@pyqtProperty(str, constant = True)
def whereToBuy(self) -> str:
return self._where_to_buy
@pyqtProperty("QStringList", constant = True)
def compatiblePrinters(self) -> List[str]:
return self._compatible_printers
@pyqtProperty("QStringList", constant = True)
def compatibleSupportMaterials(self) -> List[str]:
return self._compatible_support_materials
@pyqtProperty(bool, constant = True)
def isCompatibleMaterialStation(self) -> bool:
return self._is_compatible_material_station
@pyqtProperty(bool, constant = True)
def isCompatibleAirManager(self) -> bool:
return self._is_compatible_air_manager
@pyqtProperty(bool, constant = True)
def isBundled(self) -> bool:
return self._is_bundled
@pyqtProperty(str, constant = True)
def downloadURL(self) -> str:
return self._download_url
# --- manage buttons signals ---
stateManageButtonChanged = pyqtSignal()
installPackageTriggered = pyqtSignal(str, str)
uninstallPackageTriggered = pyqtSignal(str)
updatePackageTriggered = pyqtSignal(str)
enablePackageTriggered = pyqtSignal(str)
disablePackageTriggered = pyqtSignal(str)
installed = pyqtSignal(bool)
updated = pyqtSignal(bool)
busyChanged = pyqtSignal()
@pyqtSlot()
def install(self):
self.setBusy(True)
self.installPackageTriggered.emit(self.packageId, self.downloadURL)
@pyqtSlot()
def uninstall(self):
self.setBusy(True)
self.uninstallPackageTriggered.emit(self.packageId)
@pyqtProperty(bool, notify= busyChanged)
def busy(self):
"""
Property indicating that some kind of upgrade is active.
"""
return self._is_busy
@pyqtSlot()
def enable(self):
self.enablePackageTriggered.emit(self.packageId)
@pyqtSlot()
def disable(self):
self.disablePackageTriggered.emit(self.packageId)
def setBusy(self, value: bool):
if self._is_busy != value:
self._is_busy = value
try:
self.busyChanged.emit()
except RuntimeError:
pass
def _packageInstalled(self, package_id: str) -> None:
if self._package_id != package_id:
return
self.setBusy(not self._is_busy)
try:
self.stateManageButtonChanged.emit()
except RuntimeError:
pass
def _getRecentlyInstalled(self) -> bool:
return (self._package_id in self._package_manager.getPackagesToInstall() or self._package_id in self._package_manager.getPackagesToRemove()) \
and self._package_id not in self._package_manager.package_infosWithUpdate
def _getRecentlyUpdated(self) -> bool:
return self._package_id in self._package_manager.package_infosWithUpdate and self._package_id in self._package_manager.getPackagesToInstall()
@pyqtProperty(bool, constant = True)
def isRecentlyUpdatedChanged(self) -> bool:
return self._is_recently_updated
@pyqtProperty(bool, notify = stateManageButtonChanged)
def isInstalled(self) -> bool:
return self._package_id in self._package_manager.local_packages_ids
@pyqtProperty(bool, notify = stateManageButtonChanged)
def isActive(self) -> bool:
return not self._package_id in self._plugin_registry.getDisabledPlugins()
@pyqtProperty(bool, notify = stateManageButtonChanged)
def canDowngrade(self) -> bool:
"""Flag if the installed package can be downgraded to a bundled version"""
return self._package_manager.canDowngrade(self._package_id)
def setCanUpdate(self, value: bool) -> None:
if value != self._can_update:
self._can_update = value
self.stateManageButtonChanged.emit()
@pyqtProperty(bool, fset = setCanUpdate, notify = stateManageButtonChanged)
def canUpdate(self) -> bool:
"""Flag indicating if the package can be updated"""
return self._can_update
|