From 333c6e164d2810e614c76052467c8c892c6d9e19 Mon Sep 17 00:00:00 2001 From: nicevibesplus Date: Sat, 18 Apr 2026 11:53:24 +0200 Subject: [PATCH 1/5] fixed library imports --- .github/workflows/release.yml | 2 +- .gitignore | 2 +- pyproject.toml | 2 +- {plugin => qflowcrate}/.gitignore | 0 .../Plugin/Export/export_tab.py | 2 +- .../Plugin/Graph/connection_arrow.py | 4 +- .../Plugin/Graph/graph_tab.py | 16 +- .../Plugin/Graph/graph_view.py | 6 +- .../Plugin/Graph/layer_node.py | 2 +- .../Plugin/Graph/process_node.py | 4 +- qflowcrate/Plugin/Import/import_tab.py | 328 ++++++++++++++++++ .../Plugin/Instruction/instruction_tab.py | 0 .../Plugin/Layer/gpkg_layer.py | 4 +- {plugin => qflowcrate}/Plugin/Layer/layer.py | 2 +- .../Plugin/Layer/layer_factory.py | 12 +- .../Plugin/Layer/layer_metadata_dialog.py | 2 +- .../Plugin/Layer/memory_layer.py | 4 +- .../Plugin/Layer/shp_layer.py | 4 +- .../Plugin/Layer/wfs_layer.py | 4 +- .../Plugin/Layer/wms_layer.py | 4 +- .../Plugin/Process/instrument.py | 2 +- .../Plugin/Process/process.py | 4 +- .../Plugin/Process/process_metadata_dialog.py | 0 {plugin => qflowcrate}/Plugin/main_dialog.py | 6 +- {plugin => qflowcrate}/Plugin/mimetypes.json | 0 {plugin => qflowcrate}/Plugin/utility.py | 0 {plugin => qflowcrate}/__init__.py | 2 +- .../automated_workflow_documentation.py | 2 +- {plugin => qflowcrate}/icon.svg | 0 {plugin => qflowcrate}/metadata.txt | 0 scripts/create_release.sh | 2 +- tests/test_export_tab.py | 2 +- tests/test_graph_tab.py | 2 +- tests/test_instruction_tab.py | 2 +- 34 files changed, 378 insertions(+), 50 deletions(-) rename {plugin => qflowcrate}/.gitignore (100%) rename {plugin => qflowcrate}/Plugin/Export/export_tab.py (99%) rename {plugin => qflowcrate}/Plugin/Graph/connection_arrow.py (98%) rename {plugin => qflowcrate}/Plugin/Graph/graph_tab.py (96%) rename {plugin => qflowcrate}/Plugin/Graph/graph_view.py (97%) rename {plugin => qflowcrate}/Plugin/Graph/layer_node.py (99%) rename {plugin => qflowcrate}/Plugin/Graph/process_node.py (98%) create mode 100644 qflowcrate/Plugin/Import/import_tab.py rename {plugin => qflowcrate}/Plugin/Instruction/instruction_tab.py (100%) rename {plugin => qflowcrate}/Plugin/Layer/gpkg_layer.py (89%) rename {plugin => qflowcrate}/Plugin/Layer/layer.py (99%) rename {plugin => qflowcrate}/Plugin/Layer/layer_factory.py (83%) rename {plugin => qflowcrate}/Plugin/Layer/layer_metadata_dialog.py (99%) rename {plugin => qflowcrate}/Plugin/Layer/memory_layer.py (97%) rename {plugin => qflowcrate}/Plugin/Layer/shp_layer.py (95%) rename {plugin => qflowcrate}/Plugin/Layer/wfs_layer.py (97%) rename {plugin => qflowcrate}/Plugin/Layer/wms_layer.py (97%) rename {plugin => qflowcrate}/Plugin/Process/instrument.py (96%) rename {plugin => qflowcrate}/Plugin/Process/process.py (97%) rename {plugin => qflowcrate}/Plugin/Process/process_metadata_dialog.py (100%) rename {plugin => qflowcrate}/Plugin/main_dialog.py (96%) rename {plugin => qflowcrate}/Plugin/mimetypes.json (100%) rename {plugin => qflowcrate}/Plugin/utility.py (100%) rename {plugin => qflowcrate}/__init__.py (95%) rename {plugin => qflowcrate}/automated_workflow_documentation.py (99%) rename {plugin => qflowcrate}/icon.svg (100%) rename {plugin => qflowcrate}/metadata.txt (100%) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3fdbf44..6de5b39 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,7 +24,7 @@ jobs: - name: Prepare Plugin directory run: | mkdir -p release/qflowcrate - cp -r plugin/* release/qflowcrate/ + cp -r qflowcrate/* release/qflowcrate/ - name: Install dependencies to libs run: | diff --git a/.gitignore b/.gitignore index e44a1cb..5a2f87f 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,4 @@ __pycache__/ .vscode/ .idea/ -plugin/libs/ \ No newline at end of file +qflowcrate/libs/ \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 9ff070e..3429913 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ dependencies = ["rocrate>=0.14.0"] [tool.ruff] line-length = 120 target-version = "py312" -exclude = ["plugin/libs/*"] +exclude = ["qflowcrate/libs/*"] [tool.ruff.lint] select = ["E", "F", "I", "N", "W"] diff --git a/plugin/.gitignore b/qflowcrate/.gitignore similarity index 100% rename from plugin/.gitignore rename to qflowcrate/.gitignore diff --git a/plugin/Plugin/Export/export_tab.py b/qflowcrate/Plugin/Export/export_tab.py similarity index 99% rename from plugin/Plugin/Export/export_tab.py rename to qflowcrate/Plugin/Export/export_tab.py index 0fa38e9..4cac9ec 100644 --- a/plugin/Plugin/Export/export_tab.py +++ b/qflowcrate/Plugin/Export/export_tab.py @@ -31,7 +31,7 @@ from qgis.utils import Qgis from rocrate.rocrate import ROCrate -from ..utility import Logger, display_error_message, get_logger +from qflowcrate.Plugin.utility import Logger, display_error_message, get_logger class ExportTab(QWidget): diff --git a/plugin/Plugin/Graph/connection_arrow.py b/qflowcrate/Plugin/Graph/connection_arrow.py similarity index 98% rename from plugin/Plugin/Graph/connection_arrow.py rename to qflowcrate/Plugin/Graph/connection_arrow.py index 1639153..cc3e76b 100644 --- a/plugin/Plugin/Graph/connection_arrow.py +++ b/qflowcrate/Plugin/Graph/connection_arrow.py @@ -9,8 +9,8 @@ from qgis.PyQt.QtGui import QBrush, QPen, QPolygonF from qgis.PyQt.QtWidgets import QGraphicsLineItem, QGraphicsPolygonItem -from .layer_node import LayerNode -from .process_node import ProcessNode +from qflowcrate.Plugin.Graph.layer_node import LayerNode +from qflowcrate.Plugin.Graph.process_node import ProcessNode class ConnectionArrow(QGraphicsLineItem): diff --git a/plugin/Plugin/Graph/graph_tab.py b/qflowcrate/Plugin/Graph/graph_tab.py similarity index 96% rename from plugin/Plugin/Graph/graph_tab.py rename to qflowcrate/Plugin/Graph/graph_tab.py index c6c57db..e359132 100644 --- a/plugin/Plugin/Graph/graph_tab.py +++ b/qflowcrate/Plugin/Graph/graph_tab.py @@ -21,14 +21,14 @@ QWidget, ) -from ..Layer.layer_factory import LayerFactory -from ..Layer.layer_metadata_dialog import LayerMetadataDialog -from ..Process.process import Process -from ..Process.process_metadata_dialog import ProcessMetadataDialog -from ..utility import get_logger -from .graph_view import GraphView -from .layer_node import LayerNode -from .process_node import ProcessNode +from qflowcrate.Plugin.Layer.layer_factory import LayerFactory +from qflowcrate.Plugin.Layer.layer_metadata_dialog import LayerMetadataDialog +from qflowcrate.Plugin.Process.process import Process +from qflowcrate.Plugin.Process.process_metadata_dialog import ProcessMetadataDialog +from qflowcrate.Plugin.utility import get_logger +from qflowcrate.Plugin.Graph.graph_view import GraphView +from qflowcrate.Plugin.Graph.layer_node import LayerNode +from qflowcrate.Plugin.Graph.process_node import ProcessNode class GraphTab(QWidget): diff --git a/plugin/Plugin/Graph/graph_view.py b/qflowcrate/Plugin/Graph/graph_view.py similarity index 97% rename from plugin/Plugin/Graph/graph_view.py rename to qflowcrate/Plugin/Graph/graph_view.py index a9a664d..e2b633c 100644 --- a/plugin/Plugin/Graph/graph_view.py +++ b/qflowcrate/Plugin/Graph/graph_view.py @@ -12,9 +12,9 @@ QMessageBox, ) -from .connection_arrow import ConnectionArrow -from .layer_node import LayerNode -from .process_node import ProcessNode +from qflowcrate.Plugin.Graph.connection_arrow import ConnectionArrow +from qflowcrate.Plugin.Graph.layer_node import LayerNode +from qflowcrate.Plugin.Graph.process_node import ProcessNode class GraphView(QGraphicsView): diff --git a/plugin/Plugin/Graph/layer_node.py b/qflowcrate/Plugin/Graph/layer_node.py similarity index 99% rename from plugin/Plugin/Graph/layer_node.py rename to qflowcrate/Plugin/Graph/layer_node.py index 828e2b7..91f6e50 100644 --- a/plugin/Plugin/Graph/layer_node.py +++ b/qflowcrate/Plugin/Graph/layer_node.py @@ -12,7 +12,7 @@ QMenu, ) -from ..Layer.layer_metadata_dialog import LayerMetadataDialog +from qflowcrate.Plugin.Layer.layer_metadata_dialog import LayerMetadataDialog class LayerNode(QGraphicsRectItem): diff --git a/plugin/Plugin/Graph/process_node.py b/qflowcrate/Plugin/Graph/process_node.py similarity index 98% rename from plugin/Plugin/Graph/process_node.py rename to qflowcrate/Plugin/Graph/process_node.py index ab69f5b..2b4da4b 100644 --- a/plugin/Plugin/Graph/process_node.py +++ b/qflowcrate/Plugin/Graph/process_node.py @@ -12,8 +12,8 @@ QMenu, ) -from ..Process.process_metadata_dialog import ProcessMetadataDialog -from ..utility import get_logger +from qflowcrate.Plugin.Process.process_metadata_dialog import ProcessMetadataDialog +from qflowcrate.Plugin.utility import get_logger class ProcessNode(QGraphicsEllipseItem): diff --git a/qflowcrate/Plugin/Import/import_tab.py b/qflowcrate/Plugin/Import/import_tab.py new file mode 100644 index 0000000..73bc50a --- /dev/null +++ b/qflowcrate/Plugin/Import/import_tab.py @@ -0,0 +1,328 @@ +# -*- coding: utf-8 -*- +""" +Import Tab Widget - Widget for importing and visualizing RO-Crate workflows +""" + +import json +import zipfile +from pathlib import Path + +from qgis.PyQt.QtCore import Qt +from qgis.PyQt.QtWidgets import ( + QFileDialog, + QHBoxLayout, + QLabel, + QLineEdit, + QMessageBox, + QPushButton, + QVBoxLayout, + QWidget, +) + +from ..Graph.connection_arrow import ConnectionArrow +from ..Graph.graph_view import GraphView +from ..Graph.layer_node import LayerNode +from .process_node import ProcessNode +from ..utility import get_logger + + +class ImportedLayer: + """Mock layer object to satisfy LayerNode requirements for display only""" + def __init__(self, layer_id, name, layer_type="Unknown"): + self.id = layer_id + self.name = name + self.type = layer_type + self.visible = True + self.external = False + + +class ImportedProcess: + """Mock process object to satisfy ProcessNode requirements for display only""" + def __init__(self, process_id, name, algorithm_id="Unknown"): + self.id = process_id + self.name = name + self.algorithm_id = algorithm_id + + def set_input(self, ids): + pass + + def set_result(self, ids): + pass + + +class ImportTab(QWidget): + """Widget for importing and visualizing RO-Crate workflows""" + + # ============================================================================ + # INITIALIZATION + # ============================================================================ + + def __init__(self, parent=None): + """Initialize the Import tab widget. + + :param parent: Parent widget + :type parent: QWidget + """ + super().__init__(parent) + self.logger = get_logger("ImportTab") + self.nodes_map = {} # Map of @id -> Node instance + + self.setup_ui() + + # ============================================================================ + # UI SETUP + # ============================================================================ + + def setup_ui(self): + """Setup the user interface programmatically""" + main_layout = QVBoxLayout() + main_layout.setContentsMargins(10, 10, 10, 10) + main_layout.setSpacing(12) + + # Instruction label + instruction_label = QLabel( + "Select an exported RO-Crate (.zip) file to visualize its workflow graph. " + "This view is read-only and will not load data into your QGIS project." + ) + instruction_label.setWordWrap(True) + main_layout.addWidget(instruction_label) + + # File selection layout + file_layout = QHBoxLayout() + file_layout.setSpacing(8) + + self.file_path_lineedit = QLineEdit() + self.file_path_lineedit.setPlaceholderText("Select RO-Crate .zip file...") + self.file_path_lineedit.setReadOnly(True) + file_layout.addWidget(self.file_path_lineedit) + + self.browse_btn = QPushButton("Browse...") + self.browse_btn.clicked.connect(self.browse_file) + file_layout.addWidget(self.browse_btn) + + self.clear_btn = QPushButton("Clear Graph") + self.clear_btn.clicked.connect(self.clear_graph) + file_layout.addWidget(self.clear_btn) + + main_layout.addLayout(file_layout) + + # Graph view (reusing the custom GraphView) + self.graph_view = GraphView() + self.graph_view.setMinimumHeight(400) + + # Disable connection mode features to ensure it remains read-only + self.graph_view.setAcceptDrops(False) + main_layout.addWidget(self.graph_view) + + self.setLayout(main_layout) + + # ============================================================================ + # FILE HANDLING + # ============================================================================ + + def browse_file(self): + """Open file dialog to select RO-Crate zip""" + file_path, _ = QFileDialog.getOpenFileName( + self, + "Select RO-Crate Archive", + "", + "ZIP Files (*.zip);;All Files (*)" + ) + + if file_path: + self.file_path_lineedit.setText(file_path) + self.load_graph_from_crate(file_path) + + def clear_graph(self): + """Clear all items from graph""" + self.graph_view.scene.clear() + self.nodes_map.clear() + self.file_path_lineedit.clear() + + # ============================================================================ + # GRAPH PARSING & RENDERING + # ============================================================================ + + def load_graph_from_crate(self, zip_path): + """Parse RO-Crate zip and build the visual graph. + + :param zip_path: Path to the .zip file + :type zip_path: str + """ + self.graph_view.scene.clear() + self.nodes_map.clear() + + try: + with zipfile.ZipFile(zip_path, 'r') as zf: + if 'ro-crate-metadata.json' not in zf.namelist(): + raise ValueError("ro-crate-metadata.json not found in the archive.") + + with zf.open('ro-crate-metadata.json') as f: + metadata = json.load(f) + + graph_data = metadata.get("@graph", []) + + # Map all entities by their @id for easy lookup + entity_map = {item.get("@id"): item for item in graph_data} + + # 1. Identify Processing Steps (CreateAction) and Data Entities + actions = [] + for item in graph_data: + types = item.get("@type", []) + if isinstance(types, str): + types = [types] + + if "CreateAction" in types: + actions.append(item) + + # 2. Reconstruct Nodes + connections_to_make = [] # Store tuples of (source_id, target_id) + + for action in actions: + action_id = action.get("@id") + action_name = action.get("name", "Processing Step") + + # Fetch instrument/algorithm info if available + instrument_ref = action.get("instrument", {}).get("@id") + algo_id = "Unknown Algorithm" + if instrument_ref and instrument_ref in entity_map: + algo_id = entity_map[instrument_ref].get("name", instrument_ref) + + # Create Process Node + process_obj = ImportedProcess(action_id, action_name, algo_id) + p_node = ProcessNode(process_obj) + self._disable_interactions(p_node) + self.nodes_map[action_id] = p_node + self.graph_view.scene.addItem(p_node) + + # Process Inputs (Data -> Process) + inputs = action.get("object", []) + if isinstance(inputs, dict): + inputs = [inputs] + + for input_ref in inputs: + in_id = input_ref.get("@id") + if in_id: + self._ensure_layer_node_exists(in_id, entity_map) + connections_to_make.append((in_id, action_id)) + + # Process Outputs (Process -> Data) + outputs = action.get("result", []) + if isinstance(outputs, dict): + outputs = [outputs] + + for output_ref in outputs: + out_id = output_ref.get("@id") + if out_id: + self._ensure_layer_node_exists(out_id, entity_map) + connections_to_make.append((action_id, out_id)) + + # 3. Draw Connections + for source_id, target_id in connections_to_make: + source_node = self.nodes_map.get(source_id) + target_node = self.nodes_map.get(target_id) + + if source_node and target_node: + arrow = ConnectionArrow(source_node, target_node) + self.graph_view.scene.addItem(arrow) + + # 4. Apply auto-layout to make the graph readable + self._apply_auto_layout(connections_to_make) + + except Exception as e: + self.logger.error(f"Failed to load RO-Crate: {e}") + QMessageBox.critical(self, "Import Error", f"Failed to parse RO-Crate:\n{str(e)}") + self.clear_graph() + + def _ensure_layer_node_exists(self, layer_id, entity_map): + """Create a LayerNode if it hasn't been created yet.""" + if layer_id not in self.nodes_map: + entity = entity_map.get(layer_id, {}) + name = entity.get("name", Path(layer_id).stem if not layer_id.startswith('#') else layer_id) + + layer_type = "Unknown" + # Attempt to extract type from custom context or additionalType + additional_type = entity.get("additionalType", "") + if additional_type: + layer_type = additional_type if isinstance(additional_type, str) else additional_type[0] + + layer_obj = ImportedLayer(layer_id, name, layer_type) + l_node = LayerNode(layer_obj) + self._disable_interactions(l_node) + self.nodes_map[layer_id] = l_node + self.graph_view.scene.addItem(l_node) + + def _disable_interactions(self, node): + """Override context menu to prevent crashes from mock objects.""" + node.contextMenuEvent = lambda event: None + node.setToolTip(node.toolTip() + "\n(Read-Only)") + + # ============================================================================ + # GRAPH LAYOUT ENGINE + # ============================================================================ + + def _apply_auto_layout(self, edges): + """A simple left-to-right topological layout algorithm.""" + if not self.nodes_map: + return + + # Track incoming/outgoing edges to find root nodes + in_degrees = {node_id: 0 for node_id in self.nodes_map.keys()} + out_edges = {node_id: [] for node_id in self.nodes_map.keys()} + + for source, target in edges: + in_degrees[target] = in_degrees.get(target, 0) + 1 + out_edges[source].append(target) + + # Find nodes with no incoming edges (roots) + queue = [node_id for node_id, degree in in_degrees.items() if degree == 0] + + # If there's a cycle or isolated graph structure, just grab arbitrary nodes + if not queue and self.nodes_map: + queue = [list(self.nodes_map.keys())[0]] + + levels = {} + while queue: + current_id = queue.pop(0) + current_level = levels.get(current_id, 0) + + for neighbor in out_edges.get(current_id, []): + # Push neighbor to the next level + levels[neighbor] = max(levels.get(neighbor, 0), current_level + 1) + in_degrees[neighbor] -= 1 + if in_degrees[neighbor] <= 0: + queue.append(neighbor) + + # Handle any floating/isolated nodes that didn't get a level + for node_id in self.nodes_map: + if node_id not in levels: + levels[node_id] = 0 + + # Group nodes by their calculated horizontal level + level_groups = {} + for node_id, lvl in levels.items(): + level_groups.setdefault(lvl, []).append(node_id) + + # Apply positional coordinates + horizontal_spacing = 250 + vertical_spacing = 150 + + for lvl, node_ids in level_groups.items(): + x_pos = 50 + (lvl * horizontal_spacing) + + # Center the vertical stack + total_height = len(node_ids) * vertical_spacing + start_y = (self.graph_view.scene.sceneRect().height() - total_height) / 2 + + for index, node_id in enumerate(node_ids): + y_pos = max(50, start_y + (index * vertical_spacing)) + self.nodes_map[node_id].setPos(x_pos, y_pos) + + # Force arrows to redraw to snap to their new node coordinates + for node in self.nodes_map.values(): + if isinstance(node, LayerNode): + for arrow in node.connections + node.input_arrows: + arrow.update_position() + elif isinstance(node, ProcessNode): + for arrow in node.input_arrows + node.output_arrows: + arrow.update_position() \ No newline at end of file diff --git a/plugin/Plugin/Instruction/instruction_tab.py b/qflowcrate/Plugin/Instruction/instruction_tab.py similarity index 100% rename from plugin/Plugin/Instruction/instruction_tab.py rename to qflowcrate/Plugin/Instruction/instruction_tab.py diff --git a/plugin/Plugin/Layer/gpkg_layer.py b/qflowcrate/Plugin/Layer/gpkg_layer.py similarity index 89% rename from plugin/Plugin/Layer/gpkg_layer.py rename to qflowcrate/Plugin/Layer/gpkg_layer.py index bf6fc3f..a1863c0 100644 --- a/plugin/Plugin/Layer/gpkg_layer.py +++ b/qflowcrate/Plugin/Layer/gpkg_layer.py @@ -1,5 +1,5 @@ -from ..Layer.layer import Layer -from ..utility import get_logger +from qflowcrate.Plugin.Layer.layer import Layer +from qflowcrate.Plugin.utility import get_logger class GPKGLayer(Layer): diff --git a/plugin/Plugin/Layer/layer.py b/qflowcrate/Plugin/Layer/layer.py similarity index 99% rename from plugin/Plugin/Layer/layer.py rename to qflowcrate/Plugin/Layer/layer.py index 680b365..d62977e 100644 --- a/plugin/Plugin/Layer/layer.py +++ b/qflowcrate/Plugin/Layer/layer.py @@ -6,7 +6,7 @@ from qgis.core import QgsMapLayer, QgsMapLayerType, QgsProject, QgsWkbTypes -from ..utility import get_logger, get_mimetype +from qflowcrate.Plugin.utility import get_logger, get_mimetype class Layer: diff --git a/plugin/Plugin/Layer/layer_factory.py b/qflowcrate/Plugin/Layer/layer_factory.py similarity index 83% rename from plugin/Plugin/Layer/layer_factory.py rename to qflowcrate/Plugin/Layer/layer_factory.py index f32ad17..f33fd18 100644 --- a/plugin/Plugin/Layer/layer_factory.py +++ b/qflowcrate/Plugin/Layer/layer_factory.py @@ -1,9 +1,9 @@ -from .gpkg_layer import GPKGLayer -from .layer import Layer -from .memory_layer import MemoryLayer -from .shp_layer import SHPLayer -from .wfs_layer import WFSLayer -from .wms_layer import WMSLayer +from qflowcrate.Plugin.Layer.gpkg_layer import GPKGLayer +from qflowcrate.Plugin.Layer.layer import Layer +from qflowcrate.Plugin.Layer.memory_layer import MemoryLayer +from qflowcrate.Plugin.Layer.shp_layer import SHPLayer +from qflowcrate.Plugin.Layer.wfs_layer import WFSLayer +from qflowcrate.Plugin.Layer.wms_layer import WMSLayer class LayerFactory: diff --git a/plugin/Plugin/Layer/layer_metadata_dialog.py b/qflowcrate/Plugin/Layer/layer_metadata_dialog.py similarity index 99% rename from plugin/Plugin/Layer/layer_metadata_dialog.py rename to qflowcrate/Plugin/Layer/layer_metadata_dialog.py index 4666db2..9d7a29b 100644 --- a/plugin/Plugin/Layer/layer_metadata_dialog.py +++ b/qflowcrate/Plugin/Layer/layer_metadata_dialog.py @@ -18,7 +18,7 @@ QVBoxLayout, ) -from ..utility import display_error_message +from qflowcrate.Plugin.utility import display_error_message class LayerMetadataDialog(QDialog): diff --git a/plugin/Plugin/Layer/memory_layer.py b/qflowcrate/Plugin/Layer/memory_layer.py similarity index 97% rename from plugin/Plugin/Layer/memory_layer.py rename to qflowcrate/Plugin/Layer/memory_layer.py index 60ebb5a..397f45b 100644 --- a/plugin/Plugin/Layer/memory_layer.py +++ b/qflowcrate/Plugin/Layer/memory_layer.py @@ -2,8 +2,8 @@ from qgis.core import QgsRasterFileWriter, QgsRasterPipe, QgsVectorFileWriter -from ..Layer.layer import Layer -from ..utility import get_logger, get_mimetype +from qflowcrate.Plugin.Layer.layer import Layer +from qflowcrate.Plugin.utility import get_logger, get_mimetype class MemoryLayer(Layer): diff --git a/plugin/Plugin/Layer/shp_layer.py b/qflowcrate/Plugin/Layer/shp_layer.py similarity index 95% rename from plugin/Plugin/Layer/shp_layer.py rename to qflowcrate/Plugin/Layer/shp_layer.py index 53a2829..7c8fa28 100644 --- a/plugin/Plugin/Layer/shp_layer.py +++ b/qflowcrate/Plugin/Layer/shp_layer.py @@ -3,8 +3,8 @@ import tempfile import zipfile -from ..Layer.layer import Layer -from ..utility import get_logger +from qflowcrate.Plugin.Layer.layer import Layer +from qflowcrate.Plugin.utility import get_logger class SHPLayer(Layer): diff --git a/plugin/Plugin/Layer/wfs_layer.py b/qflowcrate/Plugin/Layer/wfs_layer.py similarity index 97% rename from plugin/Plugin/Layer/wfs_layer.py rename to qflowcrate/Plugin/Layer/wfs_layer.py index 6ff941f..78cbe4f 100644 --- a/plugin/Plugin/Layer/wfs_layer.py +++ b/qflowcrate/Plugin/Layer/wfs_layer.py @@ -1,8 +1,8 @@ import re from urllib.parse import urlencode -from ..Layer.layer import Layer -from ..utility import get_logger, get_mimetype +from qflowcrate.Plugin.Layer.layer import Layer +from qflowcrate.Plugin.utility import get_logger, get_mimetype class WFSLayer(Layer): diff --git a/plugin/Plugin/Layer/wms_layer.py b/qflowcrate/Plugin/Layer/wms_layer.py similarity index 97% rename from plugin/Plugin/Layer/wms_layer.py rename to qflowcrate/Plugin/Layer/wms_layer.py index fe37a55..693c7cd 100644 --- a/plugin/Plugin/Layer/wms_layer.py +++ b/qflowcrate/Plugin/Layer/wms_layer.py @@ -1,7 +1,7 @@ from urllib.parse import unquote -from ..Layer.layer import Layer -from ..utility import get_logger, get_mimetype +from qflowcrate.Plugin.Layer.layer import Layer +from qflowcrate.Plugin.utility import get_logger, get_mimetype class WMSLayer(Layer): diff --git a/plugin/Plugin/Process/instrument.py b/qflowcrate/Plugin/Process/instrument.py similarity index 96% rename from plugin/Plugin/Process/instrument.py rename to qflowcrate/Plugin/Process/instrument.py index 719be69..e762082 100644 --- a/plugin/Plugin/Process/instrument.py +++ b/qflowcrate/Plugin/Process/instrument.py @@ -3,7 +3,7 @@ Instrument - Represents a QGIS processing algorithm as a software instrument """ -from ..utility import get_logger +from qflowcrate.Plugin.utility import get_logger class Instrument: diff --git a/plugin/Plugin/Process/process.py b/qflowcrate/Plugin/Process/process.py similarity index 97% rename from plugin/Plugin/Process/process.py rename to qflowcrate/Plugin/Process/process.py index 367fdd4..5464960 100644 --- a/plugin/Plugin/Process/process.py +++ b/qflowcrate/Plugin/Process/process.py @@ -5,8 +5,8 @@ import re -from ..utility import get_logger -from .instrument import Instrument +from qflowcrate.Plugin.utility import get_logger +from qflowcrate.Plugin.Process.instrument import Instrument class Process: diff --git a/plugin/Plugin/Process/process_metadata_dialog.py b/qflowcrate/Plugin/Process/process_metadata_dialog.py similarity index 100% rename from plugin/Plugin/Process/process_metadata_dialog.py rename to qflowcrate/Plugin/Process/process_metadata_dialog.py diff --git a/plugin/Plugin/main_dialog.py b/qflowcrate/Plugin/main_dialog.py similarity index 96% rename from plugin/Plugin/main_dialog.py rename to qflowcrate/Plugin/main_dialog.py index 5e7c66f..ae3aa73 100644 --- a/plugin/Plugin/main_dialog.py +++ b/qflowcrate/Plugin/main_dialog.py @@ -15,9 +15,9 @@ import os # Import individual tab widgets -from .Export.export_tab import ExportTab -from .Graph.graph_tab import GraphTab -from .Instruction.instruction_tab import InstructionTab +from qflowcrate.Plugin.Export.export_tab import ExportTab +from qflowcrate.Plugin.Graph.graph_tab import GraphTab +from qflowcrate.Plugin.Instruction.instruction_tab import InstructionTab class MainDialog(QDialog): diff --git a/plugin/Plugin/mimetypes.json b/qflowcrate/Plugin/mimetypes.json similarity index 100% rename from plugin/Plugin/mimetypes.json rename to qflowcrate/Plugin/mimetypes.json diff --git a/plugin/Plugin/utility.py b/qflowcrate/Plugin/utility.py similarity index 100% rename from plugin/Plugin/utility.py rename to qflowcrate/Plugin/utility.py diff --git a/plugin/__init__.py b/qflowcrate/__init__.py similarity index 95% rename from plugin/__init__.py rename to qflowcrate/__init__.py index 5f7d21d..25c79b5 100644 --- a/plugin/__init__.py +++ b/qflowcrate/__init__.py @@ -39,6 +39,6 @@ def classFactory(iface): # pylint: disable=invalid-name # noqa: N802 if libs_path not in sys.path: sys.path.insert(0, libs_path) # - from .automated_workflow_documentation import AutomatedWorkflowDocumentation + from qflowcrate.automated_workflow_documentation import AutomatedWorkflowDocumentation return AutomatedWorkflowDocumentation(iface) diff --git a/plugin/automated_workflow_documentation.py b/qflowcrate/automated_workflow_documentation.py similarity index 99% rename from plugin/automated_workflow_documentation.py rename to qflowcrate/automated_workflow_documentation.py index ca572e0..f1b81c4 100644 --- a/plugin/automated_workflow_documentation.py +++ b/qflowcrate/automated_workflow_documentation.py @@ -32,7 +32,7 @@ from qgis.PyQt.QtWidgets import QAction, QMessageBox # Import the code for the dialog -from .Plugin.main_dialog import MainDialog +from qflowcrate.Plugin.main_dialog import MainDialog class AutomatedWorkflowDocumentation: diff --git a/plugin/icon.svg b/qflowcrate/icon.svg similarity index 100% rename from plugin/icon.svg rename to qflowcrate/icon.svg diff --git a/plugin/metadata.txt b/qflowcrate/metadata.txt similarity index 100% rename from plugin/metadata.txt rename to qflowcrate/metadata.txt diff --git a/scripts/create_release.sh b/scripts/create_release.sh index bcde1a5..f33568e 100755 --- a/scripts/create_release.sh +++ b/scripts/create_release.sh @@ -2,7 +2,7 @@ set -e VERSION=$1 -METADATA_FILE="plugin/metadata.txt" +METADATA_FILE="qflowcrate/metadata.txt" # Show help if no version provided if [ -z "$VERSION" ]; then diff --git a/tests/test_export_tab.py b/tests/test_export_tab.py index d64bd03..ade0cf2 100644 --- a/tests/test_export_tab.py +++ b/tests/test_export_tab.py @@ -11,7 +11,7 @@ QVBoxLayout, ) -from plugin.Plugin.Export.export_tab import ExportTab +from qflowcrate.Plugin.Export.export_tab import ExportTab # ============================================================================ # FIXTURES diff --git a/tests/test_graph_tab.py b/tests/test_graph_tab.py index bae52f1..ca4c30e 100644 --- a/tests/test_graph_tab.py +++ b/tests/test_graph_tab.py @@ -8,7 +8,7 @@ QVBoxLayout ) -from plugin.Plugin.Graph.graph_tab import GraphTab +from qflowcrate.Plugin.Graph.graph_tab import GraphTab # ========================================================================== # FIXTURES diff --git a/tests/test_instruction_tab.py b/tests/test_instruction_tab.py index 4a278eb..46c9aec 100644 --- a/tests/test_instruction_tab.py +++ b/tests/test_instruction_tab.py @@ -6,7 +6,7 @@ QScrollArea, QVBoxLayout, ) -from plugin.Plugin.Instruction.instruction_tab import InstructionTab +from qflowcrate.Plugin.Instruction.instruction_tab import InstructionTab # ============================================================================ # FIXTURES From cb2a49d49ac404a71fbdedcb0281bf4a023ec839 Mon Sep 17 00:00:00 2001 From: nicevibesplus Date: Sat, 18 Apr 2026 11:55:28 +0200 Subject: [PATCH 2/5] add import tab --- qflowcrate/Plugin/Import/import_tab.py | 10 +++++----- qflowcrate/Plugin/main_dialog.py | 3 +++ 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/qflowcrate/Plugin/Import/import_tab.py b/qflowcrate/Plugin/Import/import_tab.py index 73bc50a..c956ec4 100644 --- a/qflowcrate/Plugin/Import/import_tab.py +++ b/qflowcrate/Plugin/Import/import_tab.py @@ -19,11 +19,11 @@ QWidget, ) -from ..Graph.connection_arrow import ConnectionArrow -from ..Graph.graph_view import GraphView -from ..Graph.layer_node import LayerNode -from .process_node import ProcessNode -from ..utility import get_logger +from qflowcrate.Plugin.Graph.connection_arrow import ConnectionArrow +from qflowcrate.Plugin.Graph.graph_view import GraphView +from qflowcrate.Plugin.Graph.layer_node import LayerNode +from qflowcrate.Plugin.Graph.process_node import ProcessNode +from qflowcrate.Plugin.utility import get_logger class ImportedLayer: diff --git a/qflowcrate/Plugin/main_dialog.py b/qflowcrate/Plugin/main_dialog.py index ae3aa73..9c64e71 100644 --- a/qflowcrate/Plugin/main_dialog.py +++ b/qflowcrate/Plugin/main_dialog.py @@ -18,6 +18,7 @@ from qflowcrate.Plugin.Export.export_tab import ExportTab from qflowcrate.Plugin.Graph.graph_tab import GraphTab from qflowcrate.Plugin.Instruction.instruction_tab import InstructionTab +from qflowcrate.Plugin.Import.import_tab import ImportTab class MainDialog(QDialog): @@ -103,11 +104,13 @@ def _initialize_tab_widgets(self): self.instruction_tab = InstructionTab(parent=self) self.graph_tab = GraphTab(parent=self) self.export_tab = ExportTab(parent=self) + self.import_tab = ImportTab(parent=self) # Add tabs to the main tab widget self.tab_widget.addTab(self.instruction_tab, "Instructions") self.tab_widget.addTab(self.graph_tab, "Graph") self.tab_widget.addTab(self.export_tab, "Export") + self.tab_widget.addTab(self.import_tab, "Import") # Set initial tab self.tab_widget.setCurrentIndex(0) # Start with Instructions tab From 8c4fa35a505f463dbf570e987b16eed1a23c2967 Mon Sep 17 00:00:00 2001 From: nicevibesplus Date: Sat, 18 Apr 2026 13:28:59 +0200 Subject: [PATCH 3/5] fix import tab --- qflowcrate/Plugin/Import/import_tab.py | 302 ++++++++++++++++--------- 1 file changed, 192 insertions(+), 110 deletions(-) diff --git a/qflowcrate/Plugin/Import/import_tab.py b/qflowcrate/Plugin/Import/import_tab.py index c956ec4..07f0fda 100644 --- a/qflowcrate/Plugin/Import/import_tab.py +++ b/qflowcrate/Plugin/Import/import_tab.py @@ -3,7 +3,9 @@ Import Tab Widget - Widget for importing and visualizing RO-Crate workflows """ +import ast import json +import re import zipfile from pathlib import Path @@ -13,6 +15,7 @@ QHBoxLayout, QLabel, QLineEdit, + QMenu, QMessageBox, QPushButton, QVBoxLayout, @@ -26,68 +29,140 @@ from qflowcrate.Plugin.utility import get_logger +# ============================================================================ +# MOCK OBJECTS FOR READ-ONLY METADATA DIALOGS +# ============================================================================ + class ImportedLayer: - """Mock layer object to satisfy LayerNode requirements for display only""" - def __init__(self, layer_id, name, layer_type="Unknown"): - self.id = layer_id - self.name = name - self.type = layer_type - self.visible = True + """Mock layer object rich enough to satisfy LayerMetadataDialog""" + def __init__(self, dataset_entity, entity_map): + self.id = dataset_entity["@id"] + self.name = dataset_entity["name"] + self.clean_name = self.name + self.description = dataset_entity["description"] + + visible_val = dataset_entity["layerVisible"] + self.visible = str(visible_val).lower() == "true" if isinstance(visible_val, str) else bool(visible_val) + + # Prepare default values + self.type = "Unknown" + self.source = self.id self.external = False + self.source_title = "" + self.source_url = "" + self.source_date = "" + self.source_comment = "" + + # Find geometry entity which contains technical & external metadata + geometry_entity = None + has_parts = dataset_entity["hasPart"] + if isinstance(has_parts, dict): + has_parts = [has_parts] + + for part_ref in has_parts: + part_id = part_ref if isinstance(part_ref, str) else part_ref["@id"] + if part_id in entity_map and "geometry" in part_id.lower(): + geometry_entity = entity_map[part_id] + break + + # Extract metadata from geometry entity + if geometry_entity: + self.source = geometry_entity["@id"] + + # Extract layer type (fall back to additionalType if layerType is somehow missing) + raw_type = geometry_entity.get("layerType", geometry_entity.get("additionalType", "Unknown")) + if isinstance(raw_type, list): + raw_type = raw_type[0] + + if "Vector" in raw_type or "Point" in raw_type or "Line" in raw_type or "Polygon" in raw_type: + self.type = "Vector" + elif "Raster" in raw_type: + self.type = "Raster" + else: + self.type = raw_type + + # Extract optional external source info + self.source_title = geometry_entity.get("sourceTitle", "") + self.source_url = geometry_entity.get("sourceURL", "") + self.source_date = geometry_entity.get("sourceDate", "") + self.source_comment = geometry_entity.get("sourceComment", "") + + # Enable 'External Source' flag if data is present + if self.source_title or self.source_url or self.source_comment: + self.external = True class ImportedProcess: - """Mock process object to satisfy ProcessNode requirements for display only""" - def __init__(self, process_id, name, algorithm_id="Unknown"): - self.id = process_id - self.name = name - self.algorithm_id = algorithm_id + """Mock process object rich enough to satisfy ProcessMetadataDialog""" + def __init__(self, action_entity, instrument_entity=None): + self.id = action_entity["@id"] + self.name = action_entity["name"] + self.description = action_entity["description"] - def set_input(self, ids): - pass + if instrument_entity: + self.algorithm_id = instrument_entity["name"] + else: + self.algorithm_id = "Unknown Algorithm" + + # Reconstruct timestamp from ID since the process exporter doesn't write it explicitly + self.timestamp = action_entity.get("endTime", action_entity.get("startTime", "")) + if not self.timestamp: + match = re.search(r'(\d{2})(\d{2})(\d{4})(\d{2})(\d{2})(\d{2})$', self.id) + if match: + dd, mm, yyyy, hh, mnt, ss = match.groups() + self.timestamp = f"{dd}.{mm}.{yyyy} {hh}:{mnt}:{ss}" + else: + self.timestamp = "Unknown Time" - def set_result(self, ids): - pass + # Read custom QGIS properties + self.log = action_entity["qgisLog"] + raw_params = action_entity["qgisParameters"] + raw_results = action_entity["qgisResults"] + + # Convert JSON strings to dictionaries for UI rendering in the metadata dialog + try: + self.parameters = ast.literal_eval(raw_params) if isinstance(raw_params, str) else raw_params + except Exception: + self.parameters = raw_params + + try: + self.results = ast.literal_eval(raw_results) if isinstance(raw_results, str) else raw_results + except Exception: + self.results = raw_results + + def set_input(self, ids): pass + def set_result(self, ids): pass +# ============================================================================ +# MAIN IMPORT TAB WIDGET +# ============================================================================ + class ImportTab(QWidget): """Widget for importing and visualizing RO-Crate workflows""" - # ============================================================================ - # INITIALIZATION - # ============================================================================ - def __init__(self, parent=None): - """Initialize the Import tab widget. - - :param parent: Parent widget - :type parent: QWidget - """ super().__init__(parent) self.logger = get_logger("ImportTab") - self.nodes_map = {} # Map of @id -> Node instance + self.nodes_map = {} # Map of NORMALIZED @id -> Node instance + self.entity_map = {} # Map of raw @id -> JSON entity + self.normalized_datasets = {} # Map of NORMALIZED @id -> Dataset JSON self.setup_ui() - # ============================================================================ - # UI SETUP - # ============================================================================ - def setup_ui(self): """Setup the user interface programmatically""" main_layout = QVBoxLayout() main_layout.setContentsMargins(10, 10, 10, 10) main_layout.setSpacing(12) - # Instruction label instruction_label = QLabel( "Select an exported RO-Crate (.zip) file to visualize its workflow graph. " - "This view is read-only and will not load data into your QGIS project." + "This view is read-only. Right-click any node to inspect its metadata." ) instruction_label.setWordWrap(True) main_layout.addWidget(instruction_label) - # File selection layout file_layout = QHBoxLayout() file_layout.setSpacing(8) @@ -106,27 +181,17 @@ def setup_ui(self): main_layout.addLayout(file_layout) - # Graph view (reusing the custom GraphView) self.graph_view = GraphView() self.graph_view.setMinimumHeight(400) - - # Disable connection mode features to ensure it remains read-only self.graph_view.setAcceptDrops(False) main_layout.addWidget(self.graph_view) self.setLayout(main_layout) - # ============================================================================ - # FILE HANDLING - # ============================================================================ - def browse_file(self): """Open file dialog to select RO-Crate zip""" file_path, _ = QFileDialog.getOpenFileName( - self, - "Select RO-Crate Archive", - "", - "ZIP Files (*.zip);;All Files (*)" + self, "Select RO-Crate Archive", "", "ZIP Files (*.zip);;All Files (*)" ) if file_path: @@ -137,87 +202,97 @@ def clear_graph(self): """Clear all items from graph""" self.graph_view.scene.clear() self.nodes_map.clear() + self.entity_map.clear() + self.normalized_datasets.clear() self.file_path_lineedit.clear() - # ============================================================================ - # GRAPH PARSING & RENDERING - # ============================================================================ + def _normalize_id(self, raw_id): + """Normalizes IDs by stripping './' prefix and '/' suffix.""" + if not raw_id: + return "" + return raw_id.lstrip('./').rstrip('/') def load_graph_from_crate(self, zip_path): - """Parse RO-Crate zip and build the visual graph. - - :param zip_path: Path to the .zip file - :type zip_path: str - """ + """Parse RO-Crate zip and build the visual graph.""" self.graph_view.scene.clear() self.nodes_map.clear() + self.entity_map.clear() + self.normalized_datasets.clear() try: - with zipfile.ZipFile(zip_path, 'r') as zf: - if 'ro-crate-metadata.json' not in zf.namelist(): - raise ValueError("ro-crate-metadata.json not found in the archive.") - - with zf.open('ro-crate-metadata.json') as f: + with zipfile.ZipFile(zip_path, "r") as zf: + if "ro-crate-metadata.json" not in zf.namelist(): + raise ValueError("ro-crate-metadata.json missing in archive.") + with zf.open("ro-crate-metadata.json") as f: metadata = json.load(f) - graph_data = metadata.get("@graph", []) - - # Map all entities by their @id for easy lookup - entity_map = {item.get("@id"): item for item in graph_data} + graph_data = metadata["@graph"] + + # 1. Map all entities + self.entity_map = {item["@id"]: item for item in graph_data} - # 1. Identify Processing Steps (CreateAction) and Data Entities - actions = [] + # 2. Map Datasets specially to handle ID normalization for item in graph_data: - types = item.get("@type", []) - if isinstance(types, str): + types = item["@type"] + if not isinstance(types, list): types = [types] + if "Dataset" in types and item["@id"] not in ["./", ".\\"]: + norm_id = self._normalize_id(item["@id"]) + self.normalized_datasets[norm_id] = item + + # 3. Identify Processing Steps + actions = [] + for item in graph_data: + types = item["@type"] + if not isinstance(types, list): + types = [types] if "CreateAction" in types: actions.append(item) - # 2. Reconstruct Nodes - connections_to_make = [] # Store tuples of (source_id, target_id) + # 4. Reconstruct Nodes and Connections + connections_to_make = [] for action in actions: - action_id = action.get("@id") - action_name = action.get("name", "Processing Step") + action_id = action["@id"] - # Fetch instrument/algorithm info if available - instrument_ref = action.get("instrument", {}).get("@id") - algo_id = "Unknown Algorithm" - if instrument_ref and instrument_ref in entity_map: - algo_id = entity_map[instrument_ref].get("name", instrument_ref) + # Fetch instrument entity to pass to our Mock Process + instrument_refs = action["instrument"] + instrument_entity = None + if instrument_refs: + ref_id = instrument_refs[0]["@id"] if isinstance(instrument_refs, list) else instrument_refs["@id"] + instrument_entity = self.entity_map[ref_id] # Create Process Node - process_obj = ImportedProcess(action_id, action_name, algo_id) + process_obj = ImportedProcess(action, instrument_entity) p_node = ProcessNode(process_obj) self._disable_interactions(p_node) self.nodes_map[action_id] = p_node self.graph_view.scene.addItem(p_node) # Process Inputs (Data -> Process) - inputs = action.get("object", []) - if isinstance(inputs, dict): + inputs = action["object"] + if isinstance(inputs, dict): inputs = [inputs] for input_ref in inputs: - in_id = input_ref.get("@id") + in_id = input_ref["@id"] if in_id: - self._ensure_layer_node_exists(in_id, entity_map) - connections_to_make.append((in_id, action_id)) + norm_in_id = self._ensure_layer_node_exists(in_id) + connections_to_make.append((norm_in_id, action_id)) # Process Outputs (Process -> Data) - outputs = action.get("result", []) - if isinstance(outputs, dict): + outputs = action["result"] + if isinstance(outputs, dict): outputs = [outputs] for output_ref in outputs: - out_id = output_ref.get("@id") + out_id = output_ref["@id"] if out_id: - self._ensure_layer_node_exists(out_id, entity_map) - connections_to_make.append((action_id, out_id)) + norm_out_id = self._ensure_layer_node_exists(out_id) + connections_to_make.append((action_id, norm_out_id)) - # 3. Draw Connections + # 5. Draw Connections for source_id, target_id in connections_to_make: source_node = self.nodes_map.get(source_id) target_node = self.nodes_map.get(target_id) @@ -226,7 +301,7 @@ def load_graph_from_crate(self, zip_path): arrow = ConnectionArrow(source_node, target_node) self.graph_view.scene.addItem(arrow) - # 4. Apply auto-layout to make the graph readable + # 6. Apply auto-layout self._apply_auto_layout(connections_to_make) except Exception as e: @@ -234,28 +309,44 @@ def load_graph_from_crate(self, zip_path): QMessageBox.critical(self, "Import Error", f"Failed to parse RO-Crate:\n{str(e)}") self.clear_graph() - def _ensure_layer_node_exists(self, layer_id, entity_map): - """Create a LayerNode if it hasn't been created yet.""" - if layer_id not in self.nodes_map: - entity = entity_map.get(layer_id, {}) - name = entity.get("name", Path(layer_id).stem if not layer_id.startswith('#') else layer_id) + def _ensure_layer_node_exists(self, raw_layer_ref): + """Create a LayerNode. Handles the ID mismatch between inputs/outputs and datasets.""" + norm_id = self._normalize_id(raw_layer_ref) + + if norm_id not in self.nodes_map: + # Get the correct dataset via normalized ID + dataset_entity = self.normalized_datasets.get(norm_id) - layer_type = "Unknown" - # Attempt to extract type from custom context or additionalType - additional_type = entity.get("additionalType", "") - if additional_type: - layer_type = additional_type if isinstance(additional_type, str) else additional_type[0] + # Fallback if it is not a dataset + if not dataset_entity: + dataset_entity = self.entity_map.get(raw_layer_ref, {"@id": raw_layer_ref}) - layer_obj = ImportedLayer(layer_id, name, layer_type) + layer_obj = ImportedLayer(dataset_entity, self.entity_map) l_node = LayerNode(layer_obj) self._disable_interactions(l_node) - self.nodes_map[layer_id] = l_node + + # Register node under NORMALIZED id for connections + self.nodes_map[norm_id] = l_node self.graph_view.scene.addItem(l_node) + + return norm_id def _disable_interactions(self, node): - """Override context menu to prevent crashes from mock objects.""" - node.contextMenuEvent = lambda event: None - node.setToolTip(node.toolTip() + "\n(Read-Only)") + """Replace standard context menu with a read-only inspect menu.""" + node.setToolTip(node.toolTip() + "\n(Right-click to Inspect)") + + def custom_context_menu(event): + menu = QMenu() + inspect_action = menu.addAction("Inspect Details") + action = menu.exec_(event.screenPos()) + + if action == inspect_action: + if isinstance(node, LayerNode): + node._inspect_layer() + elif isinstance(node, ProcessNode): + node._inspect_process() + + node.contextMenuEvent = custom_context_menu # ============================================================================ # GRAPH LAYOUT ENGINE @@ -266,7 +357,6 @@ def _apply_auto_layout(self, edges): if not self.nodes_map: return - # Track incoming/outgoing edges to find root nodes in_degrees = {node_id: 0 for node_id in self.nodes_map.keys()} out_edges = {node_id: [] for node_id in self.nodes_map.keys()} @@ -274,10 +364,8 @@ def _apply_auto_layout(self, edges): in_degrees[target] = in_degrees.get(target, 0) + 1 out_edges[source].append(target) - # Find nodes with no incoming edges (roots) queue = [node_id for node_id, degree in in_degrees.items() if degree == 0] - # If there's a cycle or isolated graph structure, just grab arbitrary nodes if not queue and self.nodes_map: queue = [list(self.nodes_map.keys())[0]] @@ -287,30 +375,25 @@ def _apply_auto_layout(self, edges): current_level = levels.get(current_id, 0) for neighbor in out_edges.get(current_id, []): - # Push neighbor to the next level levels[neighbor] = max(levels.get(neighbor, 0), current_level + 1) in_degrees[neighbor] -= 1 if in_degrees[neighbor] <= 0: queue.append(neighbor) - # Handle any floating/isolated nodes that didn't get a level for node_id in self.nodes_map: if node_id not in levels: levels[node_id] = 0 - # Group nodes by their calculated horizontal level level_groups = {} for node_id, lvl in levels.items(): level_groups.setdefault(lvl, []).append(node_id) - # Apply positional coordinates horizontal_spacing = 250 vertical_spacing = 150 for lvl, node_ids in level_groups.items(): x_pos = 50 + (lvl * horizontal_spacing) - # Center the vertical stack total_height = len(node_ids) * vertical_spacing start_y = (self.graph_view.scene.sceneRect().height() - total_height) / 2 @@ -318,7 +401,6 @@ def _apply_auto_layout(self, edges): y_pos = max(50, start_y + (index * vertical_spacing)) self.nodes_map[node_id].setPos(x_pos, y_pos) - # Force arrows to redraw to snap to their new node coordinates for node in self.nodes_map.values(): if isinstance(node, LayerNode): for arrow in node.connections + node.input_arrows: From d830cd4b8400f33557b0658ab83cd12461051009 Mon Sep 17 00:00:00 2001 From: nicevibesplus Date: Mon, 20 Apr 2026 10:42:01 +0200 Subject: [PATCH 4/5] build zip on feat branches --- .github/workflows/build-test.yml | 45 ++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 .github/workflows/build-test.yml diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml new file mode 100644 index 0000000..c11a31d --- /dev/null +++ b/.github/workflows/build-test.yml @@ -0,0 +1,45 @@ +name: Build and Test Branch + +on: + push: + branches: + - '**' # Runs on every branch + - '!main' # exclude main + pull_request: + +jobs: + build-artifact: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Install uv + uses: astral-sh/setup-uv@v2 + + - name: Set up Python + run: uv python install 3.12 + + - name: Prepare Plugin directory + run: | + mkdir -p release/qflowcrate + cp -r qflowcrate/* release/qflowcrate/ + + - name: Install dependencies to libs + run: | + mkdir -p release/qflowcrate/libs + uv pip install --target release/qflowcrate/libs rocrate + + - name: Create ZIP + run: | + cd release + # Use the branch name or commit hash for the zip name + zip -r qflowcrate-test-build.zip qflowcrate/ -x "*/__pycache__/*" + + + - name: Upload Artifact for Testing + uses: actions/upload-artifact@v4 + with: + name: qflowcrate-test-build + path: release/qflowcrate-test-build.zip + retention-days: 3 \ No newline at end of file From ef7671385cc07e67607a90723e62e502b0360e09 Mon Sep 17 00:00:00 2001 From: nicevibesplus Date: Tue, 21 Apr 2026 14:25:05 +0200 Subject: [PATCH 5/5] space tabs and add instruction --- .../Plugin/Instruction/instruction_tab.py | 6 ++++++ qflowcrate/Plugin/main_dialog.py | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/qflowcrate/Plugin/Instruction/instruction_tab.py b/qflowcrate/Plugin/Instruction/instruction_tab.py index 2609cff..618fbf9 100644 --- a/qflowcrate/Plugin/Instruction/instruction_tab.py +++ b/qflowcrate/Plugin/Instruction/instruction_tab.py @@ -89,6 +89,12 @@ def setup_ui(self): "also create a log file in the same directory. After you have done that, " "hit Export RO-Crate.", ) + self._add_section( + main_layout, + "Import Tab - Importing an Existing RO-Crate", + "This tab allows you to import an existing RO-Crate. This can be used to " + "recreate the workflow graph from a previously exported RO-Crate.", + ) main_layout.addWidget(scroll_area) self.setLayout(main_layout) diff --git a/qflowcrate/Plugin/main_dialog.py b/qflowcrate/Plugin/main_dialog.py index 9c64e71..1d220da 100644 --- a/qflowcrate/Plugin/main_dialog.py +++ b/qflowcrate/Plugin/main_dialog.py @@ -8,6 +8,7 @@ QDialog, QDialogButtonBox, QTabWidget, + QWidget, QVBoxLayout, ) from qgis.PyQt import QtGui @@ -108,9 +109,27 @@ def _initialize_tab_widgets(self): # Add tabs to the main tab widget self.tab_widget.addTab(self.instruction_tab, "Instructions") + + # Keep your existing setup code + self.tab_widget.addTab(QWidget(), "") + self.tab_widget.setTabEnabled(1, False) + self.tab_widget.addTab(self.graph_tab, "Graph") self.tab_widget.addTab(self.export_tab, "Export") + self.tab_widget.addTab(QWidget(), "") + self.tab_widget.setTabEnabled(4, False) + self.tab_widget.addTab(self.import_tab, "Import") + + # Apply this stylesheet to the tab bar to hide index 3 + self.tab_widget.setStyleSheet(""" + QTabBar::tab:disabled { + width: 10px; + color: transparent; + background: transparent; + border: none; + } + """) # Set initial tab self.tab_widget.setCurrentIndex(0) # Start with Instructions tab