@@ -503,10 +503,12 @@ def _default_converter_options(self) -> List[ConverterOption]:
503503 options.append(ConverterOption(identifier=name, label=label, description=description))
504504 return options
505505
506-
507506class ManualConversionWidget(QWidget):
508507 """Widget that lets the user map table columns to the NTGS configuration."""
509508
509+ OUTPUT_DATA_TYPES: Tuple[str, ...] = AutomaticConversionWidget.OUTPUT_DATA_TYPES
510+ OUTPUT_GROUP_NAME = AutomaticConversionWidget.OUTPUT_GROUP_NAME
511+
510512 def __init__(
511513 self,
512514 parent: Optional[QWidget] = None,
@@ -554,6 +556,21 @@ def __init__(
554556 self.form_layout.setLabelAlignment(Qt.AlignLeft | Qt.AlignTop)
555557 self.scroll_area.setWidget(self.form_widget)
556558
559+ actions_widget = QWidget()
560+ actions_layout = QHBoxLayout(actions_widget)
561+ actions_layout.setContentsMargins(0, 0, 0, 0)
562+
563+ self.run_button = QPushButton("Run Conversion")
564+ self.run_button.clicked.connect(self._handle_run_conversion)
565+ actions_layout.addWidget(self.run_button)
566+ actions_layout.addStretch()
567+
568+ self.status_label = QLabel("")
569+ self.status_label.setObjectName("manualConversionStatus")
570+ actions_layout.addWidget(self.status_label)
571+
572+ layout.addWidget(actions_widget)
573+
557574 self.data_type_combo.currentIndexChanged.connect(self._handle_data_type_changed)
558575 self.layer_combo.layerChanged.connect(self._handle_layer_changed)
559576
@@ -668,6 +685,258 @@ def _persist_current_values(self) -> None:
668685 else:
669686 self.layer_selections[self.current_data_type] = None
670687
688+ def _collect_data_sources(self) -> Dict[Datatype | str, str]:
689+ data_sources: Dict[Datatype | str, str] = {}
690+ for data_type, selection in self.layer_selections.items():
691+ layer = self._layer_from_selection(selection or {})
692+ if layer and isinstance(layer, QgsVectorLayer) and layer.isValid():
693+ path = layer.source()
694+ if path:
695+ data_sources[self._datatype_for_name(data_type)] = path
696+ return data_sources
697+
698+ def _handle_run_conversion(self) -> None:
699+ self._persist_current_values()
700+ sources = self._collect_data_sources()
701+ if not sources:
702+ self._update_status("Select at least one data source layer before running.", error=True)
703+ return
704+
705+ converter: Any = None
706+ result: Any = None
707+ added_layers = 0
708+ try:
709+ survey = self._manual_survey_name()
710+ converter, result = _run_loop_conversion(survey, sources)
711+ layers = self._build_layers_from_converter(converter)
712+ if not layers:
713+ layers = self._materialise_layers_from_result(result)
714+ if layers:
715+ added_layers = self._add_layers_to_project_group(layers)
716+ except Exception as exc: # pragma: no cover - UI feedback
717+ self._update_status(f"Conversion failed: {exc}", error=True)
718+ return
719+
720+ if added_layers:
721+ message = f"Conversion completed: {added_layers} layer(s) added to '{self.OUTPUT_GROUP_NAME}'."
722+ elif result not in (None, True):
723+ message = f"Conversion completed: {result}"
724+ else:
725+ message = "Conversion completed successfully."
726+ self._update_status(message)
727+
728+ def _update_status(self, message: str, *, error: bool = False) -> None:
729+ color = "#c00000" if error else "#006400"
730+ self.status_label.setStyleSheet(f"color: {color};")
731+ self.status_label.setText(message)
732+
733+ def _manual_survey_name(self) -> SurveyName:
734+ options = self._default_converter_options()
735+ identifier: SurveyName | str | None = None
736+ for option in options:
737+ if str(option.identifier).upper() == "NTGS":
738+ identifier = option.identifier
739+ break
740+ if identifier is None and options:
741+ identifier = options[0].identifier
742+ if identifier is None:
743+ members = getattr(SurveyName, "__members__", {}) or {}
744+ if "NTGS" in members:
745+ identifier = members["NTGS"]
746+ elif members:
747+ identifier = next(iter(members.values()))
748+ else:
749+ raise ValueError("No survey definitions available for manual conversion.")
750+ return AutomaticConversionWidget._normalise_survey_name(identifier)
751+
752+ def _default_converter_options(self) -> List[ConverterOption]:
753+ members = getattr(SurveyName, "__members__", None)
754+ if not isinstance(members, Mapping):
755+ return []
756+ options: List[ConverterOption] = []
757+ for name, survey in members.items():
758+ label = getattr(survey, "value", None)
759+ if not isinstance(label, str) or not label.strip():
760+ label = self._format_identifier_label(name)
761+ description = getattr(survey, "description", "")
762+ options.append(ConverterOption(identifier=name, label=label, description=description))
763+ return options
764+
765+ def _build_layers_from_converter(self, converter: Any) -> List[QgsVectorLayer]:
766+ if converter is None:
767+ return []
768+ data_store = getattr(converter, "data", None)
769+ if not data_store:
770+ return []
771+
772+ layers: List[QgsVectorLayer] = []
773+ for identifier in self.OUTPUT_DATA_TYPES:
774+ dtype = self._datatype_for_name(identifier)
775+ dataset = self._extract_dataset(data_store, dtype)
776+ if dataset is None:
777+ continue
778+ layer = self._vector_layer_from_value(dtype, dataset)
779+ if layer is not None and layer.isValid():
780+ layers.append(layer)
781+ return layers
782+
783+ def _datatype_for_name(self, identifier: str | Datatype) -> Datatype | str:
784+ if isinstance(identifier, Datatype):
785+ return identifier
786+ try:
787+ return Datatype[str(identifier)]
788+ except Exception:
789+ return str(identifier)
790+
791+ def _extract_dataset(self, data_store: Any, key: Datatype | str) -> Any:
792+ if data_store is None:
793+ return None
794+
795+ candidates: List[Any] = []
796+ if isinstance(key, Datatype):
797+ candidates.append(key)
798+ candidates.append(key.name)
799+ value = getattr(key, "value", None)
800+ if value is not None:
801+ candidates.append(value)
802+ else:
803+ candidates.append(key)
804+
805+ text_key = str(key)
806+ if "." in text_key:
807+ text_key = text_key.split(".", 1)[-1]
808+ candidates.extend(
809+ [
810+ text_key,
811+ text_key.upper(),
812+ text_key.lower(),
813+ ]
814+ )
815+
816+ for candidate in candidates:
817+ if candidate is None:
818+ continue
819+ if isinstance(data_store, Mapping) and candidate in data_store:
820+ return data_store[candidate]
821+ getter = getattr(data_store, "__getitem__", None)
822+ if callable(getter):
823+ try:
824+ return getter(candidate)
825+ except Exception:
826+ pass
827+ attr_name = str(candidate).lower()
828+ if hasattr(data_store, attr_name):
829+ return getattr(data_store, attr_name)
830+ return None
831+
832+ def _materialise_layers_from_result(self, result: Any) -> List[QgsVectorLayer]:
833+ layers: List[QgsVectorLayer] = []
834+ self._collect_layers_from_result(result, layers, prefix="output")
835+ return layers
836+
837+ def _collect_layers_from_result(
838+ self, payload: Any, layers: List[QgsVectorLayer], *, prefix: str
839+ ) -> None:
840+ if payload is None:
841+ return
842+ if isinstance(payload, Mapping):
843+ for key, value in payload.items():
844+ label = str(key)
845+ self._collect_layers_from_result(value, layers, prefix=label or prefix)
846+ return
847+ if isinstance(payload, (list, tuple, set)):
848+ for index, value in enumerate(payload, start=1):
849+ self._collect_layers_from_result(value, layers, prefix=f"{prefix}_{index}")
850+ return
851+ layer = self._vector_layer_from_value(prefix, payload)
852+ if layer is not None and layer.isValid():
853+ layers.append(layer)
854+
855+ def _vector_layer_from_value(self, name: Any, value: Any) -> Optional[QgsVectorLayer]:
856+ label = self._format_identifier_label(str(name))
857+ if isinstance(value, QgsVectorLayer):
858+ value.setName(label)
859+ return value
860+ if GeoDataFrame is not None and isinstance(value, GeoDataFrame):
861+ try:
862+ return QgsLayerFromGeoDataFrame(value, layer_name=label)
863+ except ValueError:
864+ return QgsLayerFromDataFrame(value, layer_name=label)
865+ if DataFrame is not None and isinstance(value, DataFrame):
866+ return QgsLayerFromDataFrame(value, layer_name=label)
867+ if (
868+ DataFrame is not None
869+ and isinstance(value, list)
870+ and value
871+ and all(isinstance(item, Mapping) for item in value)
872+ ):
873+ try:
874+ dataframe = DataFrame(value)
875+ except Exception:
876+ dataframe = None
877+ if dataframe is not None:
878+ return QgsLayerFromDataFrame(dataframe, layer_name=label)
879+ if isinstance(value, str):
880+ layer = QgsVectorLayer(value, label, "ogr")
881+ return layer if layer.isValid() else None
882+ if isinstance(value, Mapping):
883+ provider = str(value.get("provider") or "ogr")
884+ nested_layer = value.get("layer")
885+ if isinstance(nested_layer, QgsVectorLayer):
886+ nested_layer.setName(str(value.get("name") or label))
887+ return nested_layer if nested_layer.isValid() else None
888+ for key in ("path", "source", "file"):
889+ path = value.get(key)
890+ if isinstance(path, str):
891+ layer_name = str(value.get("name") or label)
892+ layer = QgsVectorLayer(path, layer_name, provider)
893+ return layer if layer.isValid() else None
894+ return None
895+
896+ def _add_layers_to_project_group(self, layers: Iterable[QgsVectorLayer]) -> int:
897+ project = self.project or QgsProject.instance()
898+ if project is None:
899+ return 0
900+ root = project.layerTreeRoot()
901+ if root is None:
902+ return 0
903+ group = root.findGroup(self.OUTPUT_GROUP_NAME)
904+ if group is None:
905+ group = root.insertGroup(0, self.OUTPUT_GROUP_NAME)
906+ added = 0
907+ for layer in layers:
908+ if project.mapLayer(layer.id()) is None:
909+ project.addMapLayer(layer, False)
910+ group.addLayer(layer)
911+ added += 1
912+ return added
913+
914+ def _format_identifier_label(self, identifier: Datatype | str) -> str:
915+ member: Optional[Datatype] = identifier if isinstance(identifier, Datatype) else None
916+ text_value = None if isinstance(identifier, Datatype) else str(identifier).strip()
917+
918+ if member is None and text_value:
919+ candidates = getattr(Datatype, "__members__", {})
920+ key = text_value.split(".", 1)[-1].upper()
921+ member = candidates.get(key)
922+
923+ if member is None and text_value:
924+ lowered = text_value.lower()
925+ for enum_member in getattr(Datatype, "__members__", {}).values():
926+ if str(getattr(enum_member, "value", "")).lower() == lowered:
927+ member = enum_member
928+ break
929+
930+ if member is not None:
931+ name = member.name
932+ else:
933+ name = text_value or "Data"
934+ if "." in name:
935+ name = name.split(".")[-1]
936+
937+ parts = name.replace("_", " ").split()
938+ return " ".join(part.capitalize() for part in parts) or "Data"
939+
671940 def get_configuration(self) -> Dict[str, Dict[str, Any]]:
672941 """Return the configuration map built from the user selections."""
673942 self._persist_current_values()
0 commit comments