Merge branch 'CURA-7290_manual_account_sync' of github.com:Ultimaker/Cura

This commit is contained in:
Jaime van Kessel 2020-05-12 16:25:39 +02:00
commit 36cdf2edbe
No known key found for this signature in database
GPG Key ID: 3710727397403C91
10 changed files with 326 additions and 27 deletions

View File

@ -1,9 +1,11 @@
# Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from typing import Optional, Dict, TYPE_CHECKING
from datetime import datetime
from typing import Optional, Dict, TYPE_CHECKING, Union
from PyQt5.QtCore import QObject, pyqtSignal, pyqtSlot, pyqtProperty
from PyQt5.QtCore import QObject, pyqtSignal, pyqtSlot, pyqtProperty, QTimer, Q_ENUMS
from UM.Logger import Logger
from UM.Message import Message
from UM.i18n import i18nCatalog
from cura.OAuth2.AuthorizationService import AuthorizationService
@ -16,6 +18,13 @@ if TYPE_CHECKING:
i18n_catalog = i18nCatalog("cura")
class SyncState:
"""QML: Cura.AccountSyncState"""
SYNCING = 0
SUCCESS = 1
ERROR = 2
## The account API provides a version-proof bridge to use Ultimaker Accounts
#
# Usage:
@ -26,9 +35,21 @@ i18n_catalog = i18nCatalog("cura")
# api.account.userProfile # Who is logged in``
#
class Account(QObject):
# The interval in which sync services are automatically triggered
SYNC_INTERVAL = 30.0 # seconds
Q_ENUMS(SyncState)
# Signal emitted when user logged in or out.
loginStateChanged = pyqtSignal(bool)
accessTokenChanged = pyqtSignal()
syncRequested = pyqtSignal()
"""Sync services may connect to this signal to receive sync triggers.
Services should be resilient to receiving a signal while they are still syncing,
either by ignoring subsequent signals or restarting a sync.
See setSyncState() for providing user feedback on the state of your service.
"""
lastSyncDateTimeChanged = pyqtSignal()
syncStateChanged = pyqtSignal(int) # because SyncState is an int Enum
def __init__(self, application: "CuraApplication", parent = None) -> None:
super().__init__(parent)
@ -37,6 +58,8 @@ class Account(QObject):
self._error_message = None # type: Optional[Message]
self._logged_in = False
self._sync_state = SyncState.SUCCESS
self._last_sync_str = "-"
self._callback_port = 32118
self._oauth_root = UltimakerCloudAuthentication.CuraCloudAccountAPIRoot
@ -56,6 +79,16 @@ class Account(QObject):
self._authorization_service = AuthorizationService(self._oauth_settings)
# Create a timer for automatic account sync
self._update_timer = QTimer()
self._update_timer.setInterval(int(self.SYNC_INTERVAL * 1000))
# The timer is restarted explicitly after an update was processed. This prevents 2 concurrent updates
self._update_timer.setSingleShot(True)
self._update_timer.timeout.connect(self.syncRequested)
self._sync_services = {} # type: Dict[str, int]
"""contains entries "service_name" : SyncState"""
def initialize(self) -> None:
self._authorization_service.initialize(self._application.getPreferences())
self._authorization_service.onAuthStateChanged.connect(self._onLoginStateChanged)
@ -63,6 +96,39 @@ class Account(QObject):
self._authorization_service.accessTokenChanged.connect(self._onAccessTokenChanged)
self._authorization_service.loadAuthDataFromPreferences()
def setSyncState(self, service_name: str, state: int) -> None:
""" Can be used to register sync services and update account sync states
Contract: A sync service is expected exit syncing state in all cases, within reasonable time
Example: `setSyncState("PluginSyncService", SyncState.SYNCING)`
:param service_name: A unique name for your service, such as `plugins` or `backups`
:param state: One of SyncState
"""
prev_state = self._sync_state
self._sync_services[service_name] = state
if any(val == SyncState.SYNCING for val in self._sync_services.values()):
self._sync_state = SyncState.SYNCING
elif any(val == SyncState.ERROR for val in self._sync_services.values()):
self._sync_state = SyncState.ERROR
else:
self._sync_state = SyncState.SUCCESS
if self._sync_state != prev_state:
self.syncStateChanged.emit(self._sync_state)
if self._sync_state == SyncState.SUCCESS:
self._last_sync_str = datetime.now().strftime("%d/%m/%Y %H:%M")
self.lastSyncDateTimeChanged.emit()
if self._sync_state != SyncState.SYNCING:
# schedule new auto update after syncing completed (for whatever reason)
if not self._update_timer.isActive():
self._update_timer.start()
def _onAccessTokenChanged(self):
self.accessTokenChanged.emit()
@ -83,11 +149,18 @@ class Account(QObject):
self._error_message.show()
self._logged_in = False
self.loginStateChanged.emit(False)
if self._update_timer.isActive():
self._update_timer.stop()
return
if self._logged_in != logged_in:
self._logged_in = logged_in
self.loginStateChanged.emit(logged_in)
if logged_in:
self.sync()
else:
if self._update_timer.isActive():
self._update_timer.stop()
@pyqtSlot()
def login(self) -> None:
@ -123,6 +196,25 @@ class Account(QObject):
return None
return user_profile.__dict__
@pyqtProperty(str, notify=lastSyncDateTimeChanged)
def lastSyncDateTime(self) -> str:
return self._last_sync_str
@pyqtSlot()
def sync(self) -> None:
"""Signals all sync services to start syncing
This can be considered a forced sync: even when a
sync is currently running, a sync will be requested.
"""
if self._update_timer.isActive():
self._update_timer.stop()
elif self._sync_state == SyncState.SYNCING:
Logger.warning("Starting a new sync while previous sync was not completed\n{}", str(self._sync_services))
self.syncRequested.emit()
@pyqtSlot()
def logout(self) -> None:
if not self._logged_in:

View File

@ -48,6 +48,7 @@ from UM.Workspace.WorkspaceReader import WorkspaceReader
from UM.i18n import i18nCatalog
from cura import ApplicationMetadata
from cura.API import CuraAPI
from cura.API.Account import Account
from cura.Arranging.Arrange import Arrange
from cura.Arranging.ArrangeObjectsAllBuildPlatesJob import ArrangeObjectsAllBuildPlatesJob
from cura.Arranging.ArrangeObjectsJob import ArrangeObjectsJob
@ -1113,6 +1114,7 @@ class CuraApplication(QtApplication):
from cura.API import CuraAPI
qmlRegisterSingletonType(CuraAPI, "Cura", 1, 1, "API", self.getCuraAPI)
qmlRegisterUncreatableType(Account, "Cura", 1, 0, "AccountSyncState", "Could not create AccountSyncState")
# As of Qt5.7, it is necessary to get rid of any ".." in the path for the singleton to work.
actions_url = QUrl.fromLocalFile(os.path.abspath(Resources.getPath(CuraApplication.ResourceTypes.QmlFiles, "Actions.qml")))

View File

@ -2,7 +2,7 @@
# Cura is released under the terms of the LGPLv3 or higher.
import json
from typing import List, Dict, Any
from typing import List, Dict, Any, Set
from typing import Optional
from PyQt5.QtCore import QObject
@ -13,6 +13,7 @@ from UM.Logger import Logger
from UM.Message import Message
from UM.Signal import Signal
from UM.TaskManagement.HttpRequestScope import JsonDecoratorScope
from cura.API.Account import SyncState
from cura.CuraApplication import CuraApplication, ApplicationMetadata
from cura.UltimakerCloud.UltimakerCloudScope import UltimakerCloudScope
from .SubscribedPackagesModel import SubscribedPackagesModel
@ -20,6 +21,9 @@ from ..CloudApiModel import CloudApiModel
class CloudPackageChecker(QObject):
SYNC_SERVICE_NAME = "CloudPackageChecker"
def __init__(self, application: CuraApplication) -> None:
super().__init__()
@ -32,23 +36,32 @@ class CloudPackageChecker(QObject):
self._application.initializationFinished.connect(self._onAppInitialized)
self._i18n_catalog = i18nCatalog("cura")
self._sdk_version = ApplicationMetadata.CuraSDKVersion
self._last_notified_packages = set() # type: Set[str]
"""Packages for which a notification has been shown. No need to bother the user twice fo equal content"""
# 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._onLoginStateChanged()
# check again whenever the login state changes
self._getPackagesIfLoggedIn()
self._application.getCuraAPI().account.loginStateChanged.connect(self._onLoginStateChanged)
self._application.getCuraAPI().account.syncRequested.connect(self._getPackagesIfLoggedIn)
def _onLoginStateChanged(self) -> None:
# reset session
self._last_notified_packages = set()
self._getPackagesIfLoggedIn()
def _getPackagesIfLoggedIn(self) -> None:
if self._application.getCuraAPI().account.isLoggedIn:
self._getUserSubscribedPackages()
else:
self._hideSyncMessage()
def _getUserSubscribedPackages(self) -> None:
self._application.getCuraAPI().account.setSyncState(self.SYNC_SERVICE_NAME, SyncState.SYNCING)
Logger.debug("Requesting subscribed packages metadata from server.")
url = CloudApiModel.api_url_user_packages
self._application.getHttpRequestManager().get(url,
@ -61,6 +74,7 @@ class CloudPackageChecker(QObject):
Logger.log("w",
"Requesting user packages failed, response code %s while trying to connect to %s",
reply.attribute(QNetworkRequest.HttpStatusCodeAttribute), reply.url())
self._application.getCuraAPI().account.setSyncState(self.SYNC_SERVICE_NAME, SyncState.ERROR)
return
try:
@ -69,15 +83,22 @@ class CloudPackageChecker(QObject):
if "errors" in json_data:
for error in json_data["errors"]:
Logger.log("e", "%s", error["title"])
self._application.getCuraAPI().account.setSyncState(self.SYNC_SERVICE_NAME, SyncState.ERROR)
return
self._handleCompatibilityData(json_data["data"])
except json.decoder.JSONDecodeError:
Logger.log("w", "Received invalid JSON for user subscribed packages from the Web Marketplace")
self._application.getCuraAPI().account.setSyncState(self.SYNC_SERVICE_NAME, SyncState.SUCCESS)
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.getAllInstalledPackageIDs()
if user_subscribed_packages == self._last_notified_packages:
# already notified user about these
return
# 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)
@ -87,12 +108,13 @@ class CloudPackageChecker(QObject):
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))
package_discrepancy = list(user_subscribed_packages.difference(user_installed_packages))
if package_discrepancy:
Logger.log("d", "Discrepancy found between Cloud subscribed packages and Cura installed packages")
self._model.addDiscrepancies(package_discrepancy)
self._model.initialize(self._package_manager, subscribed_packages_payload)
self._showSyncMessage()
self._last_notified_packages = user_subscribed_packages
def _showSyncMessage(self) -> None:
"""Show the message if it is not already shown"""

View File

@ -53,10 +53,10 @@ class CloudApiClient:
## Retrieves all the clusters for the user that is currently logged in.
# \param on_finished: The function to be called after the result is parsed.
def getClusters(self, on_finished: Callable[[List[CloudClusterResponse]], Any]) -> None:
def getClusters(self, on_finished: Callable[[List[CloudClusterResponse]], Any], failed: Callable) -> None:
url = "{}/clusters?status=active".format(self.CLUSTER_API_ROOT)
reply = self._manager.get(self._createEmptyRequest(url))
self._addCallback(reply, on_finished, CloudClusterResponse)
self._addCallback(reply, on_finished, CloudClusterResponse, failed)
## Retrieves the status of the given cluster.
# \param cluster_id: The ID of the cluster.
@ -166,16 +166,24 @@ class CloudApiClient:
reply: QNetworkReply,
on_finished: Union[Callable[[CloudApiClientModel], Any],
Callable[[List[CloudApiClientModel]], Any]],
model: Type[CloudApiClientModel]) -> None:
model: Type[CloudApiClientModel],
on_error: Optional[Callable] = None) -> None:
def parse() -> None:
self._anti_gc_callbacks.remove(parse)
# Don't try to parse the reply if we didn't get one
if reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) is None:
if on_error is not None:
on_error()
return
status_code, response = self._parseReply(reply)
self._parseModels(response, on_finished, model)
if status_code >= 300 and on_error is not None:
on_error()
else:
self._parseModels(response, on_finished, model)
self._anti_gc_callbacks.append(parse)
reply.finished.connect(parse)
if on_error is not None:
reply.error.connect(on_error)

View File

@ -10,6 +10,7 @@ from UM.Logger import Logger # To log errors talking to the API.
from UM.Message import Message
from UM.Signal import Signal
from cura.API import Account
from cura.API.Account import SyncState
from cura.CuraApplication import CuraApplication
from cura.Settings.CuraStackBuilder import CuraStackBuilder
from cura.Settings.GlobalStack import GlobalStack
@ -27,9 +28,7 @@ class CloudOutputDeviceManager:
META_CLUSTER_ID = "um_cloud_cluster_id"
META_NETWORK_KEY = "um_network_key"
# The interval with which the remote clusters are checked
CHECK_CLUSTER_INTERVAL = 30.0 # seconds
SYNC_SERVICE_NAME = "CloudOutputDeviceManager"
# The translation catalog for this device.
I18N_CATALOG = i18nCatalog("cura")
@ -44,16 +43,11 @@ class CloudOutputDeviceManager:
self._api = CloudApiClient(self._account, on_error = lambda error: Logger.log("e", str(error)))
self._account.loginStateChanged.connect(self._onLoginStateChanged)
# Create a timer to update the remote cluster list
self._update_timer = QTimer()
self._update_timer.setInterval(int(self.CHECK_CLUSTER_INTERVAL * 1000))
# The timer is restarted explicitly after an update was processed. This prevents 2 concurrent updates
self._update_timer.setSingleShot(True)
self._update_timer.timeout.connect(self._getRemoteClusters)
# Ensure we don't start twice.
self._running = False
self._syncing = False
def start(self):
"""Starts running the cloud output device manager, thus periodically requesting cloud data."""
@ -62,18 +56,16 @@ class CloudOutputDeviceManager:
if not self._account.isLoggedIn:
return
self._running = True
if not self._update_timer.isActive():
self._update_timer.start()
self._getRemoteClusters()
self._account.syncRequested.connect(self._getRemoteClusters)
def stop(self):
"""Stops running the cloud output device manager."""
if not self._running:
return
self._running = False
if self._update_timer.isActive():
self._update_timer.stop()
self._onGetRemoteClustersFinished([]) # Make sure we remove all cloud output devices.
def refreshConnections(self) -> None:
@ -92,7 +84,14 @@ class CloudOutputDeviceManager:
def _getRemoteClusters(self) -> None:
"""Gets all remote clusters from the API."""
self._api.getClusters(self._onGetRemoteClustersFinished)
if self._syncing:
return
Logger.info("Syncing cloud printer clusters")
self._syncing = True
self._account.setSyncState(self.SYNC_SERVICE_NAME, SyncState.SYNCING)
self._api.getClusters(self._onGetRemoteClustersFinished, self._onGetRemoteClusterFailed)
def _onGetRemoteClustersFinished(self, clusters: List[CloudClusterResponse]) -> None:
"""Callback for when the request for getting the clusters is finished."""
@ -115,8 +114,13 @@ class CloudOutputDeviceManager:
if removed_device_keys:
# If the removed device was active we should connect to the new active device
self._connectToActiveMachine()
# Schedule a new update
self._update_timer.start()
self._syncing = False
self._account.setSyncState(self.SYNC_SERVICE_NAME, SyncState.SUCCESS)
def _onGetRemoteClusterFailed(self):
self._syncing = False
self._account.setSyncState(self.SYNC_SERVICE_NAME, SyncState.ERROR)
def _onDevicesDiscovered(self, clusters: List[CloudClusterResponse]) -> None:
"""**Synchronously** create machines for discovered devices

View File

@ -0,0 +1,110 @@
import QtQuick 2.10
import QtQuick.Controls 2.3
import UM 1.4 as UM
import Cura 1.1 as Cura
Row // sync state icon + message
{
property alias iconSource: icon.source
property alias labelText: stateLabel.text
property alias syncButtonVisible: accountSyncButton.visible
property alias animateIconRotation: updateAnimator.running
width: childrenRect.width
height: childrenRect.height
anchors.horizontalCenter: parent.horizontalCenter
spacing: UM.Theme.getSize("narrow_margin").height
UM.RecolorImage
{
id: icon
width: 20 * screenScaleFactor
height: width
source: UM.Theme.getIcon("update")
color: palette.text
RotationAnimator
{
id: updateAnimator
target: icon
from: 0
to: 360
duration: 1000
loops: Animation.Infinite
running: true
// reset rotation when stopped
onRunningChanged: {
if(!running)
{
icon.rotation = 0
}
}
}
}
Column
{
width: childrenRect.width
height: childrenRect.height
Label
{
id: stateLabel
text: catalog.i18nc("@state", "Checking...")
color: UM.Theme.getColor("text")
font: UM.Theme.getFont("medium")
renderType: Text.NativeRendering
}
Label
{
id: accountSyncButton
text: catalog.i18nc("@button", "Check for account updates")
color: UM.Theme.getColor("secondary_button_text")
font: UM.Theme.getFont("medium")
renderType: Text.NativeRendering
MouseArea
{
anchors.fill: parent
onClicked: Cura.API.account.sync()
hoverEnabled: true
onEntered: accountSyncButton.font.underline = true
onExited: accountSyncButton.font.underline = false
}
}
}
signal syncStateChanged(string newState)
onSyncStateChanged: {
if(newState == Cura.AccountSyncState.SYNCING){
syncRow.iconSource = UM.Theme.getIcon("update")
syncRow.labelText = catalog.i18nc("@label", "Checking...")
} else if (newState == Cura.AccountSyncState.SUCCESS) {
syncRow.iconSource = UM.Theme.getIcon("checked")
syncRow.labelText = catalog.i18nc("@label", "You are up to date")
} else if (newState == Cura.AccountSyncState.ERROR) {
syncRow.iconSource = UM.Theme.getIcon("warning_light")
syncRow.labelText = catalog.i18nc("@label", "Something went wrong...")
} else {
print("Error: unexpected sync state: " + newState)
}
if(newState == Cura.AccountSyncState.SYNCING){
syncRow.animateIconRotation = true
syncRow.syncButtonVisible = false
} else {
syncRow.animateIconRotation = false
syncRow.syncButtonVisible = true
}
}
Component.onCompleted: Cura.API.account.syncStateChanged.connect(syncStateChanged)
}

View File

@ -13,6 +13,11 @@ Column
spacing: UM.Theme.getSize("default_margin").height
SystemPalette
{
id: palette
}
Label
{
id: title
@ -24,6 +29,24 @@ Column
color: UM.Theme.getColor("text")
}
SyncState
{
id: syncRow
}
Label
{
id: lastSyncLabel
anchors.horizontalCenter: parent.horizontalCenter
horizontalAlignment: Text.AlignHCenter
renderType: Text.NativeRendering
text: catalog.i18nc("@label The argument is a timestamp", "Last update: %1").arg(Cura.API.account.lastSyncDateTime)
font: UM.Theme.getFont("default")
color: UM.Theme.getColor("text_medium")
}
Cura.SecondaryButton
{
id: accountButton
@ -53,4 +76,5 @@ Column
onExited: signOutButton.font.underline = false
}
}
}

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="16px" height="16px" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 63.1 (92452) - https://sketch.com -->
<title>checked</title>
<desc>Created with Sketch.</desc>
<g id="checked" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Check-circle" fill="#000000" fill-rule="nonzero">
<path d="M8,0 C3.581722,0 1.42108547e-15,3.581722 1.42108547e-15,8 C1.42108547e-15,12.418278 3.581722,16 8,16 C12.418278,16 16,12.418278 16,8 C16,5.87826808 15.1571453,3.84343678 13.6568542,2.34314575 C12.1565632,0.842854723 10.1217319,0 8,0 Z M8,14.4 C4.4653776,14.4 1.6,11.5346224 1.6,8 C1.6,4.4653776 4.4653776,1.6 8,1.6 C11.5346224,1.6 14.4,4.4653776 14.4,8 C14.4,9.69738553 13.7257162,11.3252506 12.5254834,12.5254834 C11.3252506,13.7257162 9.69738553,14.4 8,14.4 Z" id="Shape"></path>
<polygon id="Path" points="11.44 5.04 7.2 9.28 4.56 6.64 3.44 7.76 7.2 11.52 12.56 6.16"></polygon>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="16px" height="16px" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 63.1 (92452) - https://sketch.com -->
<title>update</title>
<desc>Created with Sketch.</desc>
<g id="update" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Update" transform="translate(1.000000, 0.000000)" fill="#000000" fill-rule="nonzero">
<path d="M12.8,8 C12.8,11.0927946 10.2927946,13.6 7.2,13.6 C5.73088329,13.6179028 4.31699148,13.0408041 3.28,12 L6.4,12 L6.4,10.4 L0.8,10.4 L0.8,16 L2.4,16 L2.4,13.36 C3.71086874,14.5564475 5.42526687,15.2136335 7.2,15.2 C11.1764502,15.2 14.4,11.9764502 14.4,8 L12.8,8 Z" id="Path"></path>
<path d="M7.2,2.4 C8.66911671,2.38209724 10.0830085,2.95919594 11.12,4 L8,4 L8,5.6 L13.6,5.6 L13.6,0 L12,0 L12,2.64 C10.6891313,1.44355247 8.97473313,0.786366515 7.2,0.8 C3.2235498,0.8 0,4.0235498 0,8 L1.6,8 C1.6,4.9072054 4.1072054,2.4 7.2,2.4 Z" id="Path"></path>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="16px" height="16px" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 63.1 (92452) - https://sketch.com -->
<title>warning</title>
<desc>Created with Sketch.</desc>
<g id="warning" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Warning" transform="translate(0.000000, 1.000000)" fill="#000000" fill-rule="nonzero">
<path d="M15.2727273,11.6363636 L9.23636364,0.8 C9.00246505,0.33139139 8.52373903,0.0352931909 8,0.0352931909 C7.47626097,0.0352931909 6.99753495,0.33139139 6.76363636,0.8 L0.727272727,11.6363636 C0.433127714,12.0765996 0.433127714,12.6506731 0.727272727,13.0909091 C0.97144313,13.5450025 1.44810945,13.8253945 1.96363636,13.8183177 L14.0363636,13.8183177 C14.5518905,13.8253945 15.0285569,13.5450025 15.2727273,13.0909091 C15.5668723,12.6506731 15.5668723,12.0765996 15.2727273,11.6363636 L15.2727273,11.6363636 Z M1.96363636,12.3636364 L8,1.52727273 L14.0363636,12.3636364 L1.96363636,12.3636364 Z" id="Shape"></path>
<rect id="Rectangle" x="7.27272727" y="4.36363636" width="1.45454545" height="4.36363636"></rect>
<rect id="Rectangle" x="7.27272727" y="10.1818182" width="1.45454545" height="1.45454545"></rect>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB