ui.import_page

  1import gc
  2import os
  3
  4from PyQt6.QtCore import Qt, QCoreApplication
  5from PyQt6.QtGui import QFont
  6from PyQt6.QtWidgets import (
  7    QHBoxLayout, QLabel, QFileDialog, QListView, QTreeView, QMessageBox,
  8    QProgressDialog, QApplication
  9)
 10
 11from threads.import_thread import ImportThread
 12from ui.patient_selection_page import PatientSelectionPage
 13from page import Page
 14from logger import get_logger
 15
 16log = get_logger()
 17
 18
 19class ImportPage(Page):
 20    """
 21    GUI page that allows importing patient data directories.
 22
 23    This class provides both drag-and-drop and dialog-based import options.
 24    It starts a background thread for each import operation and displays
 25    progress with cancellable dialogs.
 26
 27    Attributes
 28    ----------
 29    context : dict
 30        Shared context across application pages (contains settings, paths, signals, etc.).
 31    next_page : Page | None
 32        Reference to the next page in the workflow.
 33    workspace_path : str
 34        Root path where imported data is stored.
 35    threads : list[ImportThread]
 36        Active import threads currently running.
 37    progress_dialogs : list[QProgressDialog]
 38        Dialogs tracking progress of ongoing imports.
 39    """
 40
 41    def __init__(self, context=None):
 42        """
 43        Initialize the ImportPage with the given context.
 44
 45        Parameters
 46        ----------
 47        context : dict, optional
 48            Application context including 'workspace_path', 'main_window', and signals.
 49        """
 50        super().__init__()
 51
 52        self.context = context
 53        self.next_page = None
 54        self.workspace_path = context["workspace_path"]
 55        self.threads = []
 56        self.progress_dialogs = []
 57
 58        # Enable drag & drop and apply a dashed border
 59        self.setAcceptDrops(True)
 60        self.setEnabled(True)
 61        self.setStyleSheet("border: 2px dashed gray;")
 62
 63        # Central label
 64        frame_layout = QHBoxLayout(self)
 65        self.drop_label = QLabel("Import or select patients' data")
 66        self.drop_label.setStyleSheet("font-size: 18px; font-weight: bold;")
 67        font = QApplication.font()
 68        font.setPointSize(14)
 69        self.drop_label.setFont(font)
 70        self.drop_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
 71        frame_layout.addWidget(self.drop_label)
 72
 73        # Handle localization updates
 74        self._translate_ui()
 75        if context and "language_changed" in context:
 76            context["language_changed"].connect(self._translate_ui)
 77
 78    # ----------------------------
 79    # Navigation
 80    # ----------------------------
 81
 82    def is_ready_to_advance(self):
 83        """
 84        Check if the page can advance to the next step.
 85
 86        Returns
 87        -------
 88        bool
 89            True if the workspace contains at least one visible directory or link.
 90        """
 91        has_content = any(
 92            os.path.isdir(os.path.join(self.workspace_path, name)) or
 93            os.path.islink(os.path.join(self.workspace_path, name))
 94            for name in os.listdir(self.workspace_path)
 95            if not name.startswith(".")
 96        )
 97        return has_content
 98
 99    def is_ready_to_go_back(self):
100        """
101        Check if the user can navigate back to the previous page.
102
103        Returns
104        -------
105        bool
106            Always returns False (first step in workflow).
107        """
108        return False
109
110    def next(self, context):
111        """
112        Transition to the next page (PatientSelectionPage).
113
114        Returns
115        -------
116        Page
117            The newly created or existing PatientSelectionPage.
118        """
119        if self.next_page:
120            self.next_page.on_enter()
121            return self.next_page
122        else:
123            self.next_page = PatientSelectionPage(context, self)
124            self.context["history"].append(self.next_page)
125            return self.next_page
126
127    def back(self):
128        """This page has no previous step."""
129        return False
130
131    # ----------------------------
132    # Drag & Drop Import
133    # ----------------------------
134
135    def dragEnterEvent(self, event):
136        """Accept drag if the event contains file URLs."""
137        if event.mimeData().hasUrls():
138            event.acceptProposedAction()
139
140    def dropEvent(self, event):
141        """Handle dropped folders."""
142        urls = event.mimeData().urls()
143        if urls:
144                file_path = [url.toLocalFile() for url in urls]
145                self._handle_import(file_path)
146
147    # ----------------------------
148    # Folder Selection Dialog
149    # ----------------------------
150
151    def mousePressEvent(self, event):
152        """Open a folder dialog on left-click."""
153        if event.button() == Qt.MouseButton.LeftButton:
154            self.open_folder_dialog()
155
156    def open_folder_dialog(self):
157        """Open dialog to select one or more folders for import."""
158        dialog = QFileDialog(
159            self.context["main_window"],
160            QCoreApplication.translate("ImportPage", "Select Folder")
161        )
162        dialog.setFileMode(QFileDialog.FileMode.Directory)
163        dialog.setOption(QFileDialog.Option.DontUseNativeDialog, True)
164        dialog.setOption(QFileDialog.Option.ReadOnly, True)
165        dialog.setDirectory(os.path.expanduser("~"))
166
167        # Allow multi-selection in both list and tree views
168        for view in dialog.findChildren((QListView, QTreeView)):
169            view.setSelectionMode(view.SelectionMode.ExtendedSelection)
170
171        if dialog.exec():
172            folders = [os.path.abspath(path) for path in dialog.selectedFiles() if os.path.isdir(path)]
173            # Avoid nested duplicates (e.g., selecting parent + child folder)
174            unique_folders = [
175                f for f in folders
176                if not any(f != other and other.startswith(f + os.sep) for other in folders)
177            ]
178            self._handle_import(unique_folders)
179
180    # ----------------------------
181    # Import Handling
182    # ----------------------------
183
184    def _handle_import(self, folders_path):
185        self.progress_dialogs.append(QProgressDialog(QCoreApplication.translate("ImportFrame", "Importing files..."), QCoreApplication.translate("ImportFrame", "Cancel"),
186                                               0, 100, self.context["main_window"]))
187        self.progress_dialogs[-1].setWindowModality(Qt.WindowModality.NonModal)
188        self.progress_dialogs[-1].setMinimumDuration(0)
189
190        # Start importing thread
191        self.threads.append(ImportThread(self.context, folders_path, self.workspace_path))
192        self.threads[-1].finished.connect(self.on_import_finished)
193        self.threads[-1].error.connect(self.on_import_error)
194        self.threads[-1].progress.connect(self.progress_dialogs[-1].setValue)
195        self.threads[-1].start()
196
197        self.progress_dialogs[-1].canceled.connect(self.on_import_canceled)
198
199    # ----------------------------
200    # Thread Callbacks
201    # ----------------------------
202
203    def on_import_error(self, error):
204        """Handle file import errors from background threads."""
205        thread = self.sender()
206        if thread not in self.threads:
207            log.warning(f"Ignored error from already removed thread: {error}")
208            return
209
210        index = self.threads.index(thread)
211        self.progress_dialogs[index].close()
212        QMessageBox.critical(
213            self.context["main_window"],
214            QCoreApplication.translate("ImportPage", "Error Importing Files"),
215            QCoreApplication.translate("ImportPage", "Failed to import files:\n{0}").format(error),
216        )
217        log.error(f"Error Importing Files: {error}")
218
219    def on_import_finished(self):
220        """Cleanup after an import finishes successfully."""
221        thread = self.sender()
222        if thread not in self.threads:
223            log.warning("Ignored 'finished' signal from an already removed thread.")
224            return
225
226        index = self.threads.index(thread)
227        self.threads.remove(thread)
228        self.progress_dialogs[index].close()
229
230        if self.context and "update_main_buttons" in self.context:
231            self.context["update_main_buttons"]()
232        log.info("Import completed.")
233
234    def on_import_canceled(self):
235        """Handle user-canceled import operations."""
236        dialog = self.sender()
237        index = self.progress_dialogs.index(dialog)
238
239        if len(self.threads) > index:
240            thread = self.threads[index]
241            thread.cancel()
242            thread.terminate()
243            thread.wait()
244            self.threads.remove(thread)
245
246    def closeEvent(self, event):
247        """Clean up threads and dialogs on application exit."""
248        for dialog in self.progress_dialogs:
249            dialog.destroy()
250            self.progress_dialogs.remove(dialog)
251        for thread in self.threads:
252            thread.cancel()
253            self.threads.remove(thread)
254        gc.collect()
255        event.accept()
256
257    # ----------------------------
258    # Translation
259    # ----------------------------
260
261    def _translate_ui(self):
262        """Apply translated text to UI elements."""
263        self.drop_label.setText(QCoreApplication.translate("ImportPage", "Import or select patients' data"))
log = <Logger GliAAns-UI (WARNING)>
class ImportPage(page.Page):
 20class ImportPage(Page):
 21    """
 22    GUI page that allows importing patient data directories.
 23
 24    This class provides both drag-and-drop and dialog-based import options.
 25    It starts a background thread for each import operation and displays
 26    progress with cancellable dialogs.
 27
 28    Attributes
 29    ----------
 30    context : dict
 31        Shared context across application pages (contains settings, paths, signals, etc.).
 32    next_page : Page | None
 33        Reference to the next page in the workflow.
 34    workspace_path : str
 35        Root path where imported data is stored.
 36    threads : list[ImportThread]
 37        Active import threads currently running.
 38    progress_dialogs : list[QProgressDialog]
 39        Dialogs tracking progress of ongoing imports.
 40    """
 41
 42    def __init__(self, context=None):
 43        """
 44        Initialize the ImportPage with the given context.
 45
 46        Parameters
 47        ----------
 48        context : dict, optional
 49            Application context including 'workspace_path', 'main_window', and signals.
 50        """
 51        super().__init__()
 52
 53        self.context = context
 54        self.next_page = None
 55        self.workspace_path = context["workspace_path"]
 56        self.threads = []
 57        self.progress_dialogs = []
 58
 59        # Enable drag & drop and apply a dashed border
 60        self.setAcceptDrops(True)
 61        self.setEnabled(True)
 62        self.setStyleSheet("border: 2px dashed gray;")
 63
 64        # Central label
 65        frame_layout = QHBoxLayout(self)
 66        self.drop_label = QLabel("Import or select patients' data")
 67        self.drop_label.setStyleSheet("font-size: 18px; font-weight: bold;")
 68        font = QApplication.font()
 69        font.setPointSize(14)
 70        self.drop_label.setFont(font)
 71        self.drop_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
 72        frame_layout.addWidget(self.drop_label)
 73
 74        # Handle localization updates
 75        self._translate_ui()
 76        if context and "language_changed" in context:
 77            context["language_changed"].connect(self._translate_ui)
 78
 79    # ----------------------------
 80    # Navigation
 81    # ----------------------------
 82
 83    def is_ready_to_advance(self):
 84        """
 85        Check if the page can advance to the next step.
 86
 87        Returns
 88        -------
 89        bool
 90            True if the workspace contains at least one visible directory or link.
 91        """
 92        has_content = any(
 93            os.path.isdir(os.path.join(self.workspace_path, name)) or
 94            os.path.islink(os.path.join(self.workspace_path, name))
 95            for name in os.listdir(self.workspace_path)
 96            if not name.startswith(".")
 97        )
 98        return has_content
 99
100    def is_ready_to_go_back(self):
101        """
102        Check if the user can navigate back to the previous page.
103
104        Returns
105        -------
106        bool
107            Always returns False (first step in workflow).
108        """
109        return False
110
111    def next(self, context):
112        """
113        Transition to the next page (PatientSelectionPage).
114
115        Returns
116        -------
117        Page
118            The newly created or existing PatientSelectionPage.
119        """
120        if self.next_page:
121            self.next_page.on_enter()
122            return self.next_page
123        else:
124            self.next_page = PatientSelectionPage(context, self)
125            self.context["history"].append(self.next_page)
126            return self.next_page
127
128    def back(self):
129        """This page has no previous step."""
130        return False
131
132    # ----------------------------
133    # Drag & Drop Import
134    # ----------------------------
135
136    def dragEnterEvent(self, event):
137        """Accept drag if the event contains file URLs."""
138        if event.mimeData().hasUrls():
139            event.acceptProposedAction()
140
141    def dropEvent(self, event):
142        """Handle dropped folders."""
143        urls = event.mimeData().urls()
144        if urls:
145                file_path = [url.toLocalFile() for url in urls]
146                self._handle_import(file_path)
147
148    # ----------------------------
149    # Folder Selection Dialog
150    # ----------------------------
151
152    def mousePressEvent(self, event):
153        """Open a folder dialog on left-click."""
154        if event.button() == Qt.MouseButton.LeftButton:
155            self.open_folder_dialog()
156
157    def open_folder_dialog(self):
158        """Open dialog to select one or more folders for import."""
159        dialog = QFileDialog(
160            self.context["main_window"],
161            QCoreApplication.translate("ImportPage", "Select Folder")
162        )
163        dialog.setFileMode(QFileDialog.FileMode.Directory)
164        dialog.setOption(QFileDialog.Option.DontUseNativeDialog, True)
165        dialog.setOption(QFileDialog.Option.ReadOnly, True)
166        dialog.setDirectory(os.path.expanduser("~"))
167
168        # Allow multi-selection in both list and tree views
169        for view in dialog.findChildren((QListView, QTreeView)):
170            view.setSelectionMode(view.SelectionMode.ExtendedSelection)
171
172        if dialog.exec():
173            folders = [os.path.abspath(path) for path in dialog.selectedFiles() if os.path.isdir(path)]
174            # Avoid nested duplicates (e.g., selecting parent + child folder)
175            unique_folders = [
176                f for f in folders
177                if not any(f != other and other.startswith(f + os.sep) for other in folders)
178            ]
179            self._handle_import(unique_folders)
180
181    # ----------------------------
182    # Import Handling
183    # ----------------------------
184
185    def _handle_import(self, folders_path):
186        self.progress_dialogs.append(QProgressDialog(QCoreApplication.translate("ImportFrame", "Importing files..."), QCoreApplication.translate("ImportFrame", "Cancel"),
187                                               0, 100, self.context["main_window"]))
188        self.progress_dialogs[-1].setWindowModality(Qt.WindowModality.NonModal)
189        self.progress_dialogs[-1].setMinimumDuration(0)
190
191        # Start importing thread
192        self.threads.append(ImportThread(self.context, folders_path, self.workspace_path))
193        self.threads[-1].finished.connect(self.on_import_finished)
194        self.threads[-1].error.connect(self.on_import_error)
195        self.threads[-1].progress.connect(self.progress_dialogs[-1].setValue)
196        self.threads[-1].start()
197
198        self.progress_dialogs[-1].canceled.connect(self.on_import_canceled)
199
200    # ----------------------------
201    # Thread Callbacks
202    # ----------------------------
203
204    def on_import_error(self, error):
205        """Handle file import errors from background threads."""
206        thread = self.sender()
207        if thread not in self.threads:
208            log.warning(f"Ignored error from already removed thread: {error}")
209            return
210
211        index = self.threads.index(thread)
212        self.progress_dialogs[index].close()
213        QMessageBox.critical(
214            self.context["main_window"],
215            QCoreApplication.translate("ImportPage", "Error Importing Files"),
216            QCoreApplication.translate("ImportPage", "Failed to import files:\n{0}").format(error),
217        )
218        log.error(f"Error Importing Files: {error}")
219
220    def on_import_finished(self):
221        """Cleanup after an import finishes successfully."""
222        thread = self.sender()
223        if thread not in self.threads:
224            log.warning("Ignored 'finished' signal from an already removed thread.")
225            return
226
227        index = self.threads.index(thread)
228        self.threads.remove(thread)
229        self.progress_dialogs[index].close()
230
231        if self.context and "update_main_buttons" in self.context:
232            self.context["update_main_buttons"]()
233        log.info("Import completed.")
234
235    def on_import_canceled(self):
236        """Handle user-canceled import operations."""
237        dialog = self.sender()
238        index = self.progress_dialogs.index(dialog)
239
240        if len(self.threads) > index:
241            thread = self.threads[index]
242            thread.cancel()
243            thread.terminate()
244            thread.wait()
245            self.threads.remove(thread)
246
247    def closeEvent(self, event):
248        """Clean up threads and dialogs on application exit."""
249        for dialog in self.progress_dialogs:
250            dialog.destroy()
251            self.progress_dialogs.remove(dialog)
252        for thread in self.threads:
253            thread.cancel()
254            self.threads.remove(thread)
255        gc.collect()
256        event.accept()
257
258    # ----------------------------
259    # Translation
260    # ----------------------------
261
262    def _translate_ui(self):
263        """Apply translated text to UI elements."""
264        self.drop_label.setText(QCoreApplication.translate("ImportPage", "Import or select patients' data"))

GUI page that allows importing patient data directories.

This class provides both drag-and-drop and dialog-based import options. It starts a background thread for each import operation and displays progress with cancellable dialogs.

Attributes

context : dict Shared context across application pages (contains settings, paths, signals, etc.). next_page : Page | None Reference to the next page in the workflow. workspace_path : str Root path where imported data is stored. threads : list[ImportThread] Active import threads currently running. progress_dialogs : list[QProgressDialog] Dialogs tracking progress of ongoing imports.

ImportPage(context=None)
42    def __init__(self, context=None):
43        """
44        Initialize the ImportPage with the given context.
45
46        Parameters
47        ----------
48        context : dict, optional
49            Application context including 'workspace_path', 'main_window', and signals.
50        """
51        super().__init__()
52
53        self.context = context
54        self.next_page = None
55        self.workspace_path = context["workspace_path"]
56        self.threads = []
57        self.progress_dialogs = []
58
59        # Enable drag & drop and apply a dashed border
60        self.setAcceptDrops(True)
61        self.setEnabled(True)
62        self.setStyleSheet("border: 2px dashed gray;")
63
64        # Central label
65        frame_layout = QHBoxLayout(self)
66        self.drop_label = QLabel("Import or select patients' data")
67        self.drop_label.setStyleSheet("font-size: 18px; font-weight: bold;")
68        font = QApplication.font()
69        font.setPointSize(14)
70        self.drop_label.setFont(font)
71        self.drop_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
72        frame_layout.addWidget(self.drop_label)
73
74        # Handle localization updates
75        self._translate_ui()
76        if context and "language_changed" in context:
77            context["language_changed"].connect(self._translate_ui)

Initialize the ImportPage with the given context.

Parameters

context : dict, optional Application context including 'workspace_path', 'main_window', and signals.

context
next_page
workspace_path
threads
progress_dialogs
drop_label
def is_ready_to_advance(self):
83    def is_ready_to_advance(self):
84        """
85        Check if the page can advance to the next step.
86
87        Returns
88        -------
89        bool
90            True if the workspace contains at least one visible directory or link.
91        """
92        has_content = any(
93            os.path.isdir(os.path.join(self.workspace_path, name)) or
94            os.path.islink(os.path.join(self.workspace_path, name))
95            for name in os.listdir(self.workspace_path)
96            if not name.startswith(".")
97        )
98        return has_content

Check if the page can advance to the next step.

Returns

bool True if the workspace contains at least one visible directory or link.

def is_ready_to_go_back(self):
100    def is_ready_to_go_back(self):
101        """
102        Check if the user can navigate back to the previous page.
103
104        Returns
105        -------
106        bool
107            Always returns False (first step in workflow).
108        """
109        return False

Check if the user can navigate back to the previous page.

Returns

bool Always returns False (first step in workflow).

def next(self, context):
111    def next(self, context):
112        """
113        Transition to the next page (PatientSelectionPage).
114
115        Returns
116        -------
117        Page
118            The newly created or existing PatientSelectionPage.
119        """
120        if self.next_page:
121            self.next_page.on_enter()
122            return self.next_page
123        else:
124            self.next_page = PatientSelectionPage(context, self)
125            self.context["history"].append(self.next_page)
126            return self.next_page

Transition to the next page (PatientSelectionPage).

Returns

Page The newly created or existing PatientSelectionPage.

def back(self):
128    def back(self):
129        """This page has no previous step."""
130        return False

This page has no previous step.

def dragEnterEvent(self, event):
136    def dragEnterEvent(self, event):
137        """Accept drag if the event contains file URLs."""
138        if event.mimeData().hasUrls():
139            event.acceptProposedAction()

Accept drag if the event contains file URLs.

def dropEvent(self, event):
141    def dropEvent(self, event):
142        """Handle dropped folders."""
143        urls = event.mimeData().urls()
144        if urls:
145                file_path = [url.toLocalFile() for url in urls]
146                self._handle_import(file_path)

Handle dropped folders.

def mousePressEvent(self, event):
152    def mousePressEvent(self, event):
153        """Open a folder dialog on left-click."""
154        if event.button() == Qt.MouseButton.LeftButton:
155            self.open_folder_dialog()

Open a folder dialog on left-click.

def open_folder_dialog(self):
157    def open_folder_dialog(self):
158        """Open dialog to select one or more folders for import."""
159        dialog = QFileDialog(
160            self.context["main_window"],
161            QCoreApplication.translate("ImportPage", "Select Folder")
162        )
163        dialog.setFileMode(QFileDialog.FileMode.Directory)
164        dialog.setOption(QFileDialog.Option.DontUseNativeDialog, True)
165        dialog.setOption(QFileDialog.Option.ReadOnly, True)
166        dialog.setDirectory(os.path.expanduser("~"))
167
168        # Allow multi-selection in both list and tree views
169        for view in dialog.findChildren((QListView, QTreeView)):
170            view.setSelectionMode(view.SelectionMode.ExtendedSelection)
171
172        if dialog.exec():
173            folders = [os.path.abspath(path) for path in dialog.selectedFiles() if os.path.isdir(path)]
174            # Avoid nested duplicates (e.g., selecting parent + child folder)
175            unique_folders = [
176                f for f in folders
177                if not any(f != other and other.startswith(f + os.sep) for other in folders)
178            ]
179            self._handle_import(unique_folders)

Open dialog to select one or more folders for import.

def on_import_error(self, error):
204    def on_import_error(self, error):
205        """Handle file import errors from background threads."""
206        thread = self.sender()
207        if thread not in self.threads:
208            log.warning(f"Ignored error from already removed thread: {error}")
209            return
210
211        index = self.threads.index(thread)
212        self.progress_dialogs[index].close()
213        QMessageBox.critical(
214            self.context["main_window"],
215            QCoreApplication.translate("ImportPage", "Error Importing Files"),
216            QCoreApplication.translate("ImportPage", "Failed to import files:\n{0}").format(error),
217        )
218        log.error(f"Error Importing Files: {error}")

Handle file import errors from background threads.

def on_import_finished(self):
220    def on_import_finished(self):
221        """Cleanup after an import finishes successfully."""
222        thread = self.sender()
223        if thread not in self.threads:
224            log.warning("Ignored 'finished' signal from an already removed thread.")
225            return
226
227        index = self.threads.index(thread)
228        self.threads.remove(thread)
229        self.progress_dialogs[index].close()
230
231        if self.context and "update_main_buttons" in self.context:
232            self.context["update_main_buttons"]()
233        log.info("Import completed.")

Cleanup after an import finishes successfully.

def on_import_canceled(self):
235    def on_import_canceled(self):
236        """Handle user-canceled import operations."""
237        dialog = self.sender()
238        index = self.progress_dialogs.index(dialog)
239
240        if len(self.threads) > index:
241            thread = self.threads[index]
242            thread.cancel()
243            thread.terminate()
244            thread.wait()
245            self.threads.remove(thread)

Handle user-canceled import operations.

def closeEvent(self, event):
247    def closeEvent(self, event):
248        """Clean up threads and dialogs on application exit."""
249        for dialog in self.progress_dialogs:
250            dialog.destroy()
251            self.progress_dialogs.remove(dialog)
252        for thread in self.threads:
253            thread.cancel()
254            self.threads.remove(thread)
255        gc.collect()
256        event.accept()

Clean up threads and dialogs on application exit.