Skip to content

Commit f64c210

Browse files
Merge pull request #13 from EftyK/main
Update README, add test for Import tab, and fix few bugs
2 parents c59e646 + 8b11fe0 commit f64c210

8 files changed

Lines changed: 300 additions & 36 deletions

File tree

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ QFlowCrate will help you document the **metadata** of your project, prompting to
5555
| **Custom metadata entry** | UI helps the user fill in title, description, and author details. |
5656
| **Cross‑platform** | Works on Windows, macOS, and Linux Ubuntu (QGIS ≥ 3.40). |
5757
| **Data‑format support** | <br>• **Vector:** Shapefile (`.shp`), GeoJSON (`.geojson`), KML (`.kml`) <br>• **Raster:** GeoTIFF (`.tif/.tiff`), PNG (`.png`), JPEG (`.jpg`) <br>• **Other:** CSV tables, OGC-compliant server connections (WFS, WMS) |
58+
| **RO‑Crate import** | Imports a RO-Crate ZIP for visualization and inspection. Workflow re-execution is currently not supported. |
5859

5960
---
6061

@@ -134,6 +135,12 @@ ProjectA/
134135
└─ symbology.qml
135136
```
136137

138+
### Step 7 – Import a RO‑Crate
139+
140+
1. Navigate to the Import tab.
141+
1. Browse an already exported RO-Crate zip file.
142+
1. Visualize the graph and inspect the graph elements for details.
143+
137144
---
138145

139146

images/showcase.gif

23.2 KB
Loading

qflowcrate/Plugin/Graph/connection_arrow.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,18 +20,21 @@ class ConnectionArrow(QGraphicsLineItem):
2020
# INITIALIZATION
2121
# ============================================================================
2222

23-
def __init__(self, start_node, end_node):
23+
def __init__(self, start_node, end_node, deletable=True):
2424
"""Initialize a ConnectionArrow between two nodes.
2525
2626
:param start_node: Starting node for the connection
2727
:type start_node: LayerNode or ProcessNode
2828
:param end_node: Ending node for the connection
2929
:type end_node: LayerNode or ProcessNode
30+
:param deletable: Whether the arrow can be deleted by clicking
31+
:type deletable: bool
3032
"""
3133
super().__init__()
3234
self.start_node = start_node
3335
self.end_node = end_node
3436
self.arrowhead = None # Will hold the arrowhead polygon
37+
self.deletable = deletable
3538

3639
# Set visual properties
3740
self.setPen(QPen(Qt.black, 2))
@@ -57,7 +60,7 @@ def __init__(self, start_node, end_node):
5760
if self.arrowhead:
5861
scene.addItem(self.arrowhead)
5962

60-
self.setToolTip("Click to delete connection")
63+
self.setToolTip("Double click to delete connection" if self.deletable else "")
6164

6265
# ============================================================================
6366
# POSITION CALCULATION
@@ -204,12 +207,12 @@ def _create_arrowhead(self, tip_point, dx, dy):
204207
# ============================================================================
205208

206209
def mousePressEvent(self, event): # noqa: N802
207-
"""Delete arrow on click.
210+
"""Delete arrow on click if deletable.
208211
209212
:param event: Mouse press event
210213
:type event: QMouseEvent
211214
"""
212-
if event.button() == Qt.LeftButton:
215+
if event.button() == Qt.LeftButton and self.deletable:
213216
self.remove_arrow()
214217

215218
# ============================================================================

qflowcrate/Plugin/Graph/graph_view.py

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ def __init__(self):
3434
self.setAcceptDrops(True)
3535
self.setDragMode(QGraphicsView.RubberBandDrag)
3636

37+
# Enable keyboard focus for zoom shortcuts
38+
self.setFocusPolicy(Qt.StrongFocus)
39+
3740
# Connection mode
3841
self.connection_mode = False
3942
self.connection_start = None
@@ -93,6 +96,32 @@ def dropEvent(self, event): # noqa: N802
9396
# MOUSE EVENT HANDLERS
9497
# ============================================================================
9598

99+
def keyPressEvent(self, event): # noqa: N802
100+
"""Handle keyboard shortcuts for zooming.
101+
102+
:param event: Key press event
103+
:type event: QKeyEvent
104+
"""
105+
if event.modifiers() & Qt.ControlModifier:
106+
if event.key() == Qt.Key_Plus:
107+
# Zoom in
108+
zoom_factor = 1.2
109+
current_scale = self.transform().m11()
110+
new_scale = current_scale * zoom_factor
111+
if new_scale <= 5.0:
112+
self.scale(zoom_factor, zoom_factor)
113+
elif event.key() == Qt.Key_Minus:
114+
# Zoom out
115+
zoom_factor = 1.0 / 1.2
116+
current_scale = self.transform().m11()
117+
new_scale = current_scale * zoom_factor
118+
if new_scale >= 0.1:
119+
self.scale(zoom_factor, zoom_factor)
120+
else:
121+
super().keyPressEvent(event)
122+
else:
123+
super().keyPressEvent(event)
124+
96125
def mousePressEvent(self, event): # noqa: N802
97126
"""Handle mouse press for connections.
98127
@@ -193,7 +222,11 @@ def toggle_connection_mode(self, enabled):
193222
"""
194223
self.connection_mode = enabled
195224
if not enabled and self.connection_start:
196-
self.connection_start.setBrush(
197-
self._get_original_brush(self.connection_start)
198-
)
225+
try:
226+
self.connection_start.setBrush(
227+
self._get_original_brush(self.connection_start)
228+
)
229+
except RuntimeError:
230+
# Object has been deleted
231+
pass
199232
self.connection_start = None

qflowcrate/Plugin/Import/import_tab.py

Lines changed: 30 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,12 @@
3636
class ImportedLayer:
3737
"""Mock layer object rich enough to satisfy LayerMetadataDialog"""
3838
def __init__(self, dataset_entity, entity_map):
39-
self.id = dataset_entity["@id"]
40-
self.name = dataset_entity["name"]
39+
self.id = dataset_entity.get("@id", "unknown")
40+
self.name = dataset_entity.get("name", "Unknown Layer")
4141
self.clean_name = self.name
42-
self.description = dataset_entity["description"]
42+
self.description = dataset_entity.get("description", "")
4343

44-
visible_val = dataset_entity["layerVisible"]
44+
visible_val = dataset_entity.get("layerVisible", True)
4545
self.visible = str(visible_val).lower() == "true" if isinstance(visible_val, str) else bool(visible_val)
4646

4747
# Prepare default values
@@ -55,19 +55,19 @@ def __init__(self, dataset_entity, entity_map):
5555

5656
# Find geometry entity which contains technical & external metadata
5757
geometry_entity = None
58-
has_parts = dataset_entity["hasPart"]
58+
has_parts = dataset_entity.get("hasPart", [])
5959
if isinstance(has_parts, dict):
6060
has_parts = [has_parts]
6161

6262
for part_ref in has_parts:
63-
part_id = part_ref if isinstance(part_ref, str) else part_ref["@id"]
63+
part_id = part_ref if isinstance(part_ref, str) else part_ref.get("@id", "")
6464
if part_id in entity_map and "geometry" in part_id.lower():
6565
geometry_entity = entity_map[part_id]
6666
break
6767

6868
# Extract metadata from geometry entity
6969
if geometry_entity:
70-
self.source = geometry_entity["@id"]
70+
self.source = geometry_entity.get("@id", self.id)
7171

7272
# Extract layer type (fall back to additionalType if layerType is somehow missing)
7373
raw_type = geometry_entity.get("layerType", geometry_entity.get("additionalType", "Unknown"))
@@ -96,11 +96,11 @@ class ImportedProcess:
9696
"""Mock process object rich enough to satisfy ProcessMetadataDialog"""
9797
def __init__(self, action_entity, instrument_entity=None):
9898
self.id = action_entity["@id"]
99-
self.name = action_entity["name"]
100-
self.description = action_entity["description"]
99+
self.name = action_entity.get("name", "Unknown Process")
100+
self.description = action_entity.get("description", "")
101101

102102
if instrument_entity:
103-
self.algorithm_id = instrument_entity["name"]
103+
self.algorithm_id = instrument_entity.get("name", "Unknown Algorithm")
104104
else:
105105
self.algorithm_id = "Unknown Algorithm"
106106

@@ -115,9 +115,9 @@ def __init__(self, action_entity, instrument_entity=None):
115115
self.timestamp = "Unknown Time"
116116

117117
# Read custom QGIS properties
118-
self.log = action_entity["qgisLog"]
119-
raw_params = action_entity["qgisParameters"]
120-
raw_results = action_entity["qgisResults"]
118+
self.log = action_entity.get("qgisLog", "")
119+
raw_params = action_entity.get("qgisParameters", "{}")
120+
raw_results = action_entity.get("qgisResults", "{}")
121121

122122
# Convert JSON strings to dictionaries for UI rendering in the metadata dialog
123123
try:
@@ -158,7 +158,8 @@ def setup_ui(self):
158158

159159
instruction_label = QLabel(
160160
"Select an exported RO-Crate (.zip) file to visualize its workflow graph. "
161-
"This view is read-only. Right-click any node to inspect its metadata."
161+
"This view is read-only. Right-click any node to inspect its metadata. "
162+
"To zoom in and out of the graph, use the keyboard shortcuts Ctrl & '+' / Ctrl & '-', respectively."
162163
)
163164
instruction_label.setWordWrap(True)
164165
main_layout.addWidget(instruction_label)
@@ -257,11 +258,11 @@ def load_graph_from_crate(self, zip_path):
257258
action_id = action["@id"]
258259

259260
# Fetch instrument entity to pass to our Mock Process
260-
instrument_refs = action["instrument"]
261+
instrument_refs = action.get("instrument", [])
261262
instrument_entity = None
262263
if instrument_refs:
263264
ref_id = instrument_refs[0]["@id"] if isinstance(instrument_refs, list) else instrument_refs["@id"]
264-
instrument_entity = self.entity_map[ref_id]
265+
instrument_entity = self.entity_map.get(ref_id)
265266

266267
# Create Process Node
267268
process_obj = ImportedProcess(action, instrument_entity)
@@ -271,23 +272,23 @@ def load_graph_from_crate(self, zip_path):
271272
self.graph_view.scene.addItem(p_node)
272273

273274
# Process Inputs (Data -> Process)
274-
inputs = action["object"]
275+
inputs = action.get("object", [])
275276
if isinstance(inputs, dict):
276277
inputs = [inputs]
277278

278279
for input_ref in inputs:
279-
in_id = input_ref["@id"]
280+
in_id = input_ref.get("@id")
280281
if in_id:
281282
norm_in_id = self._ensure_layer_node_exists(in_id)
282283
connections_to_make.append((norm_in_id, action_id))
283284

284285
# Process Outputs (Process -> Data)
285-
outputs = action["result"]
286+
outputs = action.get("result", [])
286287
if isinstance(outputs, dict):
287288
outputs = [outputs]
288289

289290
for output_ref in outputs:
290-
out_id = output_ref["@id"]
291+
out_id = output_ref.get("@id")
291292
if out_id:
292293
norm_out_id = self._ensure_layer_node_exists(out_id)
293294
connections_to_make.append((action_id, norm_out_id))
@@ -298,7 +299,7 @@ def load_graph_from_crate(self, zip_path):
298299
target_node = self.nodes_map.get(target_id)
299300

300301
if source_node and target_node:
301-
arrow = ConnectionArrow(source_node, target_node)
302+
arrow = ConnectionArrow(source_node, target_node, deletable=False)
302303
self.graph_view.scene.addItem(arrow)
303304

304305
# 6. Apply auto-layout
@@ -407,4 +408,11 @@ def _apply_auto_layout(self, edges):
407408
arrow.update_position()
408409
elif isinstance(node, ProcessNode):
409410
for arrow in node.input_arrows + node.output_arrows:
410-
arrow.update_position()
411+
arrow.update_position()
412+
413+
# Update scene rect to encompass all items
414+
scene_rect = self.graph_view.scene.itemsBoundingRect()
415+
self.graph_view.scene.setSceneRect(scene_rect)
416+
417+
# Fit the view to show all items within the visible area
418+
self.graph_view.fitInView(scene_rect, Qt.KeepAspectRatio)

qflowcrate/Plugin/Instruction/instruction_tab.py

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,14 @@ def setup_ui(self):
4545
scroll_area.setWidgetResizable(True)
4646
scroll_area.setFrameStyle(QFrame.NoFrame)
4747

48+
# Create container widget for scroll area content
49+
container = QWidget()
50+
container_layout = QVBoxLayout(container)
51+
container_layout.setContentsMargins(10, 10, 10, 10)
52+
container_layout.setSpacing(12)
53+
4854
self._add_section(
49-
main_layout,
55+
container_layout,
5056
"Application Overview",
5157
"This plugin helps you to document your project and its creation workflow. "
5258
"It exports to RO-Crate 1.1 to enable sharing of the map. The export "
@@ -56,7 +62,7 @@ def setup_ui(self):
5662

5763
# Graph tab instructions
5864
self._add_section(
59-
main_layout,
65+
container_layout,
6066
"Graph Tab - Creating Your Workflow",
6167
"This tab allows you to create your projects workflow. All the nodes in "
6268
"this graph will be exported to the RO-Crate.",
@@ -74,10 +80,12 @@ def setup_ui(self):
7480
"ask for a title and description.",
7581
"Step 3: Add connections between the graphs layers and processing steps. "
7682
"This creates a complete workflow diagram. Without this, the dependencies "
77-
"of the layers will be missing in the final export.",
83+
"of the layers will be missing in the final export. "
84+
"The user can zoom in and out of the graph using the keyboard shortcuts "
85+
"Ctrl & '+' / Ctrl & '-', respectively.",
7886
)
7987
self._add_section(
80-
main_layout,
88+
container_layout,
8189
"Export Tab - Exporting Your Workflow",
8290
"This tab allows you to export your projects workflow to RO-Crate.",
8391
"Step 1: Enter author information. This must include the authors name. "
@@ -90,12 +98,13 @@ def setup_ui(self):
9098
"hit Export RO-Crate.",
9199
)
92100
self._add_section(
93-
main_layout,
101+
container_layout,
94102
"Import Tab - Importing an Existing RO-Crate",
95103
"This tab allows you to import an existing RO-Crate. This can be used to "
96104
"recreate the workflow graph from a previously exported RO-Crate.",
97105
)
98106

107+
scroll_area.setWidget(container)
99108
main_layout.addWidget(scroll_area)
100109
self.setLayout(main_layout)
101110

0 commit comments

Comments
 (0)