@@ -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