Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2456daca7 | ||
|
|
d1c9346e57 | ||
|
|
63785d3a55 | ||
|
|
3a8ffbdf70 | ||
|
|
4059a3ef93 | ||
|
|
66242c1a5c | ||
|
|
2a5e3f5606 | ||
|
|
613d2b9103 | ||
|
|
cd0b55df34 | ||
|
|
5d46d3b3e1 | ||
|
|
05dc9203ba | ||
|
|
4d00ef86e4 | ||
|
|
e9ef78c872 | ||
|
|
ca203fcb56 | ||
|
|
815f342ead | ||
|
|
57492a479b | ||
|
|
8ef352add3 |
No files matched your search
@@ -175,6 +175,7 @@ cython_debug/
|
||||
.pypirc
|
||||
|
||||
/individual_images
|
||||
/plugins
|
||||
*.xlsx
|
||||
*.csv
|
||||
*.snirf
|
||||
@@ -184,3 +185,4 @@ flares-*
|
||||
*.cfg
|
||||
tempCodeRunnerFile.py
|
||||
*.pkl
|
||||
*.joblib
|
||||
@@ -1,3 +1,43 @@
|
||||
# Version 1.8.0
|
||||
|
||||
- Plugin Manager now supports checking and downloading plugin updates to ensure plugins can be updated
|
||||
- Plugin Manager now properly uninstalls plugins when requested rather than doing nothing
|
||||
- Installing plugins now displays a progress bar when downloading and no longer hangs the application when downloading a big plugin
|
||||
- Project loading now displays a loading message when loading big projects and no longer hangs the application when loading
|
||||
- New preferences options and menus are now available! All existing preferences have been moved into new sub categories
|
||||
- The UI preference category now has the Show Advanced Parameters preference
|
||||
- The Updates preference category has two ney options to prevent the automatic checking of application or plugin updates
|
||||
- The Theme category has remain untouched and all other preferences have been moved to the Processing category
|
||||
- Added a new preference in the Processing category called "Separate Events". This allows events to have their own values, and not be collapsed into a single value
|
||||
- Export To CSV Viewer export options now perform the actions they claim rather than having no functionality
|
||||
- Logs on a per participant basis are now available to be viewed from within the application
|
||||
- They can be accessed by right clicking a participant bubble and selecting "Show Logs". Logs will be empty if they have not been processed
|
||||
- A notification now appears when processing completes even if the application is not focused
|
||||
- Fixed a regression where opening a folder would no longer load all the files in that folder
|
||||
- Added inspirational quotes to the About window
|
||||
- Licence is now clickable from the About window
|
||||
- Improved code stability and functionality
|
||||
|
||||
|
||||
# Version 1.7.2
|
||||
|
||||
- Fixed the Linux build not starting if shared library files were missing from the host machine
|
||||
- Fixed the macOS version not loading plugins due to not having an HTTPS certificate
|
||||
|
||||
|
||||
# Version 1.7.1
|
||||
|
||||
- Added support for Plugins! Plugins can be downloaded from the official repository or from custom ones. Support for creating a plugin can be found on the wiki
|
||||
- Files and folders can now be dragged and dropped into the loaded files area to be loaded instead of relying on the file menu
|
||||
- Fixed an issue where shadow snirf files would attempt to be loaded by the application. Fixes [Issue 92](https://git.research.dezeeuw.ca/tyler/flares/issues/92)
|
||||
- Fixed an issue where the application could not automatically update between versions 1.5.1 to 1.7.1. Sorry! 1.7.1 upgrading onward should be fixed and more robust
|
||||
- Fixed an issue where some log files would not generate, or would generate in an incorrect location
|
||||
- Fixed an issue where updating events in a snirf file would not release memory properly
|
||||
- Fixed an issue where loading multiple files in quick succession could cause some of the files to never finish loading
|
||||
- Fixed an issue where loading a saved project, pushing clear, and re-opening snirf files would cause them to appear incorrectly in the loaded files area
|
||||
- Fixed a crucial bug where short channels were still being sent to the donor pool to assist in interpolating long channels from. Fixes [Issue 80](https://git.research.dezeeuw.ca/tyler/flares/issues/80)
|
||||
|
||||
|
||||
# Version 1.7.0
|
||||
|
||||
- This is potentially a save-changing release due to adding more data into the save file. Please update your project files to ensure compatibility
|
||||
@@ -12,6 +52,7 @@
|
||||
- Added a new parameter to the PSP section on the right side of the screen: PSP_USE_HEART_RATE_BAND. This functions similarly to the existing SCI_USE_HEART_RATE_BAND
|
||||
- Added description text to the Inter-Group and Intra-Group Brain and Image Viewers, as well as the Functional Connectivity windows to explain what output can be expected
|
||||
- Added a new Preference option of Theme. Allows from selecting Auto (System default), Light, or Dark. Fixes [Issue 7](https://git.research.dezeeuw.ca/tyler/flares/issues/7)
|
||||
- Optode updater can now update optode locations in multiple snirf files at once
|
||||
- Removed image index 1 (Significance) from the Intra-Group Brain and Image Viewer as it is now provided more in depth with the Stats viewers
|
||||
- Modified the timeout when waiting for the application to close while performing updates down to a reasonable number
|
||||
- Modified the help messages for parameters in the SCI and PSP areas to better reflect how the parameters are used
|
||||
@@ -32,6 +73,7 @@
|
||||
- Fixed an issue where certain parameters would not enable or disable depending on other parameters when they should've
|
||||
- Fixed an issue where not all widgets would close when attempting to close the application causing the application to crash
|
||||
- Fixed an issue where events were not created correctly after the data had been resampled by the design matrix
|
||||
- Fixed an issue where progress bar colors would not reset if the data was reprocessed
|
||||
|
||||
|
||||
# Version 1.6.0
|
||||
|
||||
+54
-34
@@ -1,35 +1,55 @@
|
||||
# Version 1.6.0
|
||||
# Version 1.7.2
|
||||
|
||||
- This is potentially a save-changing release due to adding more data into the save file, renaming existing data, and changing what part of the code saves data. Please update your project files to ensure compatibility
|
||||
- It is still possible to load older saves by enabling 'Incompatible Save Bypass' from the Preferences menu, but your mileage may vary
|
||||
- Optimized calculations being performed when calculating the Heart Rate to speed up step 5 by up to ~35% on a per-file basis
|
||||
- Optimized calculations being performed when running the General Linear Model to speed up step 5 by up to ~35% on a per-file basis
|
||||
- The Group Stats Viewer windows will now properly load the Right/Left or Front/Back fallback ROIs if JSON_LOCATION is not set
|
||||
- Changed the warning message for lots of short channels and changed when it shows to be percentage based (35%) instead of numerical based (6)
|
||||
- Renames GENDER to SEX to better represent the values that the field expects and to be more BIDS compliant
|
||||
- Changed the Assign Groups by AGE window to now be the Assign Groups by Metadata window and added support for grouping by other metadata values
|
||||
- Added a new metadata value of HAND to allow for grouping participants based on their handiness
|
||||
- Updated the layout of the participant metadata area to better accommodate current and future metadata values
|
||||
- Updated the message displayed when "Why are these useful?" is clicked to better represent what the values are used for and to accommodate the new metadata values
|
||||
- Changed the application title to now show what project you are working on. If you are not working on a project, it will display 'Untitled'
|
||||
- Tracking of current save states is now present and an asterisk will now appear in the title bar when a project has not been saved
|
||||
- Currently tracking is only supported on loaded files, parameters on the right side of the screen, and when processing has completed. Per-file metadata or processing stages are not currently implemented
|
||||
- Clicking close on the Main Window will now properly close all sub windows when closing the application and closing the application with unsaved changes will now prompt to save a project or discard changes. Fixes [Issue 87](https://git.research.dezeeuw.ca/tyler/flares/issues/87)
|
||||
- The Save button under the File menu no longer asks for a destination if a saved project was already loaded. Fixes [Issue 72](https://git.research.dezeeuw.ca/tyler/flares/issues/72)
|
||||
- Added an icon to the 'Toggle Status Bar' view option
|
||||
- Status bar visibility and window layout sizes are now stored between application sessions. Fixes [Issue 88](https://git.research.dezeeuw.ca/tyler/flares/issues/88)
|
||||
- The popup that appeared when a project was saved has now been moved to a message displayed from the status bar
|
||||
- Moved all loading of files, loading of folders, loading of projects, and saving of projects to a new file for easier future development
|
||||
- Changed the working of some messages on the status bar to better reflect what has occurred
|
||||
- Added a new terminal command 'assoc' to associate .flare files to the application. This is undergoing testing and may not function correctly.
|
||||
- Removed the debug flag from the application package, which may speed up the application. Fixes [Issue 11](https://git.research.dezeeuw.ca/tyler/flares/issues/11)
|
||||
- Fixed an issue where the two Group Stats Viewer windows would crash the application once attempted to be opened if JSON_LOCATION was not set
|
||||
- Fixed an issue where participants could be skipped when processing multiple participants at once which could prevent overall processing from completing
|
||||
- Fixed an issue where when loading a save file some list dropdowns could go blue and bold even if the value was default
|
||||
- Fixed an issue where the Analysis button would not be clickable if the user had previously pushed Clear. Fixes [Issue 83](https://git.research.dezeeuw.ca/tyler/flares/issues/83)
|
||||
- Fixed an issue where removing all files via their right click feature would cause the Process button to still be visible. Fixes [Issue 91](https://git.research.dezeeuw.ca/tyler/flares/issues/91)
|
||||
- Fixed an issue where some popup windows would not properly display the application name when they would open
|
||||
- Fixed an issue where the welcome dialog image would not display correctly on macOS
|
||||
- Fixed an issue where the Linux version would fail at 0 seconds when attempting to process files
|
||||
- Fixed an issue where fOLDing channels could cause the same channels to be repeated misaligning the labels
|
||||
- Fixed all instances of the word MacOS to now read macOS to match Apple branding
|
||||
- Fixed the Linux build not starting if shared library files were missing from the host machine
|
||||
- Fixed the macOS version not loading plugins due to not having an HTTPS certificate
|
||||
|
||||
|
||||
# Version 1.7.1
|
||||
|
||||
- Added support for Plugins! Plugins can be downloaded from the official repository or from custom ones. Support for creating a plugin can be found on the wiki
|
||||
- Files and folders can now be dragged and dropped into the loaded files area to be loaded instead of relying on the file menu
|
||||
- Fixed an issue where shadow snirf files would attempt to be loaded by the application. Fixes [Issue 92](https://git.research.dezeeuw.ca/tyler/flares/issues/92)
|
||||
- Fixed an issue where the application could not automatically update between versions 1.5.1 to 1.7.1. Sorry! 1.7.1 upgrading onward should be fixed and more robust
|
||||
- Fixed an issue where some log files would not generate, or would generate in an incorrect location
|
||||
- Fixed an issue where updating events in a snirf file would not release memory properly
|
||||
- Fixed an issue where loading multiple files in quick succession could cause some of the files to never finish loading
|
||||
- Fixed an issue where loading a saved project, pushing clear, and re-opening snirf files would cause them to appear incorrectly in the loaded files area
|
||||
- Fixed a crucial bug where short channels were still being sent to the donor pool to assist in interpolating long channels from. Fixes [Issue 80](https://git.research.dezeeuw.ca/tyler/flares/issues/80)
|
||||
|
||||
|
||||
# Version 1.7.0
|
||||
|
||||
- This is potentially a save-changing release due to adding more data into the save file. Please update your project files to ensure compatibility
|
||||
- Renamed all instances of "Inter" to properly read as "Intra" and changed "Cross" to now read as "Inter"
|
||||
- Changed RESAMPLE to only apply where it is required to avoid having Functional Connectivity analysis methods running on data that has been resampled
|
||||
- Added parameters that appear when attempting to generate results from the Participant and Intra-Group Functional Connectivity viewers and removed the non-functional placeholder parameters
|
||||
- Modified the Participant and Intra-Group Functional Connectivity analysis options to better perform their expected tasks. This remains as a BETA feature
|
||||
- Removed the existing Intra-Group Functional Connectivity option and replaced it with two new ones: Beta-Series Correlation and Spectral Coherence (epochs)
|
||||
- Updated the names of the methods provided for the Participant Functional Connectivity Viewer to better match the actions they perform
|
||||
- Updated the warnings for the Functional Connectivity Viewers to better represent the challenges these analysis options now face
|
||||
- Added basic unit testing to hopefully prevent any accidental processing changes from occurring in the future
|
||||
- Added a new parameter to the PSP section on the right side of the screen: PSP_USE_HEART_RATE_BAND. This functions similarly to the existing SCI_USE_HEART_RATE_BAND
|
||||
- Added description text to the Inter-Group and Intra-Group Brain and Image Viewers, as well as the Functional Connectivity windows to explain what output can be expected
|
||||
- Added a new Preference option of Theme. Allows from selecting Auto (System default), Light, or Dark. Fixes [Issue 7](https://git.research.dezeeuw.ca/tyler/flares/issues/7)
|
||||
- Optode updater can now update optode locations in multiple snirf files at once
|
||||
- Removed image index 1 (Significance) from the Intra-Group Brain and Image Viewer as it is now provided more in depth with the Stats viewers
|
||||
- Modified the timeout when waiting for the application to close while performing updates down to a reasonable number
|
||||
- Modified the help messages for parameters in the SCI and PSP areas to better reflect how the parameters are used
|
||||
- Modified the heart rate calculation to be more precise and correct when dealing with good data and not messy data
|
||||
- Modified the heart rate calculation to not take only one channel in the data to use, but rather an average of channels. This still prefers short channels if they are present
|
||||
- Fixed an issue where loading a project from a saved project file would not allow the data to be reprocessed with new parameters
|
||||
- Fixed an issue where the "Hand" BIDS metadata value would not populate on bubbles correctly
|
||||
- Fixed an issue where performing PSP could ignore the bad channels that were marked by SCI and SNR
|
||||
- Fixed an issue where the heart rate calculation would disregard all calculations and fall back to an extremely rudimentary calculation
|
||||
- Fixed an issue that could prevent log file generation while the application was in the middle of an update
|
||||
- Fixed an issue where a rare crash could occur while the application was in the middle of an update
|
||||
- Fixed an issue that could have passed multiple conditions when generating an Intra-Group Stats image
|
||||
- Fixed an issue that could pass NaN values when attempting to collapse channels
|
||||
- Fixed an issue that was causing the OLS model to always be used for brain images with multiple participants, and not the MixedLM model
|
||||
- Fixed an issue that could cause the Wavelet filtering step to crash
|
||||
- Fixed an issue where file associations appeared to work as intended but would not load the project on macOS and only open the application
|
||||
- Fixed an issue where file associations would refuse to associate on macOS once they have attempted to be associated
|
||||
- Fixed an issue where certain parameters would not enable or disable depending on other parameters when they should've
|
||||
- Fixed an issue where not all widgets would close when attempting to close the application causing the application to crash
|
||||
- Fixed an issue where events were not created correctly after the data had been resampled by the design matrix
|
||||
- Fixed an issue where progress bar colors would not reset if the data was reprocessed
|
||||
@@ -105,8 +105,7 @@ from mne_nirs.statistics._glm_level_first import RegressionResults # type: igno
|
||||
from mne_connectivity.viz import plot_connectivity_circle # type: ignore
|
||||
from mne_connectivity import envelope_correlation, spectral_connectivity_epochs, spectral_connectivity_time # type: ignore
|
||||
|
||||
from src.shared.shareddata import PLATFORM_NAME, resource_path
|
||||
|
||||
from src.shared.shareddata import PLATFORM_NAME, resource_path, get_app_dir, METRIC_REGISTRY
|
||||
|
||||
|
||||
PRIMARY_COLORS = {
|
||||
@@ -162,43 +161,6 @@ QC_METRIC_LABELS = {
|
||||
"total_processing_seconds": "Processing Time (s)",
|
||||
}
|
||||
|
||||
|
||||
ROI_MAP = {
|
||||
'ROI_OccipitoParietal_BA18_19_7': [
|
||||
'S1_D1', 'S1_D2', 'S4_D3'
|
||||
],
|
||||
'ROI_SuperiorParietal_BA7_5': [
|
||||
'S2_D1', 'S3_D1', 'S2_D2', 'S2_D4', 'S4_D4'
|
||||
],
|
||||
'ROI_TPJ_AngularGyrus_BA39_19_40': [
|
||||
'S4_D2', 'S5_D3', 'S4_D5', 'S5_D5'
|
||||
],
|
||||
# --- SPLIT BA40 REGIONS ---
|
||||
'ROI_Posterior_BA40_Parietal': [
|
||||
'S6_D4', 'S6_D5'
|
||||
],
|
||||
'ROI_Anterior_BA40_Sensorimotor': [
|
||||
'S10_D5', 'S8_D5'
|
||||
],
|
||||
# --------------------------
|
||||
'ROI_Supramarginal_Inferior_BA40_6': [
|
||||
'S6_D6'
|
||||
],
|
||||
'ROI_VentralSomatosensory_BA1_2_3_43_48': [
|
||||
'S8_D6', 'S9_D6'
|
||||
],
|
||||
'ROI_Sensorimotor_BA1_2_3_4_6': [
|
||||
'S6_D8', 'S10_D8'
|
||||
],
|
||||
'ROI_DLPFC_FEF_BA6_8_9': [
|
||||
'S7_D7', 'S7_D8'
|
||||
],
|
||||
'ROI_Broca_VLPFC_BA6_44_45_4': [
|
||||
'S9_D7', 'S10_D7'
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
DOWNSAMPLE: bool
|
||||
DOWNSAMPLE_FREQUENCY: int
|
||||
|
||||
@@ -338,6 +300,8 @@ GROUP: str = "Default"
|
||||
|
||||
FOLDING_BYP: bool = False
|
||||
|
||||
SEPARATE_EVENTS: bool = False
|
||||
|
||||
FEATURE_1: bool = False
|
||||
FEATURE_2: bool = False
|
||||
|
||||
@@ -345,29 +309,24 @@ FEATURE_2: bool = False
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
os.chdir(script_dir)
|
||||
|
||||
|
||||
# Configure logging to file with timestamps and realtime flush
|
||||
if PLATFORM_NAME == 'darwin':
|
||||
logging.basicConfig(
|
||||
filename=os.path.join(os.path.dirname(sys.executable), "../../../fnirs_analysis.log"), # Needed to get out of the bundled application
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(processName)s - %(levelname)s - %(message)s',
|
||||
datefmt='%Y-%m-%d %H:%M:%S',
|
||||
filemode='a'
|
||||
)
|
||||
|
||||
log_path = os.path.abspath(os.path.join(os.path.dirname(sys.executable), "../../../fnirs_analysis.log"))
|
||||
else:
|
||||
logging.basicConfig(
|
||||
filename='fnirs_analysis.log',
|
||||
log_path = os.path.join(get_app_dir(), "fnirs_analysis.log")
|
||||
|
||||
logging.basicConfig(
|
||||
filename=log_path,
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(processName)s - %(levelname)s - %(message)s',
|
||||
datefmt='%Y-%m-%d %H:%M:%S',
|
||||
filemode='a'
|
||||
)
|
||||
)
|
||||
|
||||
logger = logging.getLogger()
|
||||
|
||||
|
||||
|
||||
def set_config_me(config: dict[str, Any]) -> None:
|
||||
"""
|
||||
Validates and applies the given configuration dictionary.
|
||||
@@ -463,6 +422,27 @@ def gui_entry(config: dict[str, Any], gui_queue: mp.Queue, progress_queue: mp.Qu
|
||||
def process_participant_worker(file_path, file_params, file_metadata, result_queue, progress_queue):
|
||||
file_start = time.time()
|
||||
stage_tracker = {"value": 0.0}
|
||||
|
||||
class QueueStreamAlt:
|
||||
def __init__(self, fp, q):
|
||||
self.fp = fp
|
||||
self.q = q
|
||||
|
||||
def write(self, msg):
|
||||
if msg and self.q:
|
||||
try:
|
||||
self.q.put(("log", self.fp, msg))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def flush(self):
|
||||
pass
|
||||
|
||||
old_stdout = sys.stdout
|
||||
old_stderr = sys.stderr
|
||||
sys.stdout = QueueStreamAlt(file_path, progress_queue)
|
||||
sys.stderr = QueueStreamAlt(file_path, progress_queue)
|
||||
|
||||
try:
|
||||
set_config_me(file_params)
|
||||
set_metadata(file_path, file_metadata)
|
||||
@@ -487,6 +467,8 @@ def process_participant_worker(file_path, file_params, file_metadata, result_que
|
||||
pass
|
||||
|
||||
finally:
|
||||
sys.stdout = old_stdout
|
||||
sys.stderr = old_stderr
|
||||
try:
|
||||
plt.close('all')
|
||||
gc.collect()
|
||||
@@ -514,7 +496,8 @@ def process_multiple_participants(file_paths, file_params, file_metadata,
|
||||
success_count = {"value": 0}
|
||||
failed_stages = {"value": []}
|
||||
qc_rows: list[dict[str, Any]] = []
|
||||
qc_summary_path="qc_summary.xlsx"
|
||||
if qc_summary_path is None:
|
||||
qc_summary_path = os.path.join(get_app_dir(), "qc_summary.xlsx")
|
||||
|
||||
def elapsed_heartbeat():
|
||||
# Ticks once a second so the GUI can show a live-updating timer,
|
||||
@@ -1155,10 +1138,10 @@ def interpolate_fNIRS_bads_weighted_average(raw, max_dist=0.03, min_neighbors=2,
|
||||
is_bad = (hbo_ch in raw.info['bads']) or (hbr_ch in raw.info['bads'])
|
||||
is_short = pair_distances[i] < short_channels_threshold
|
||||
|
||||
if is_bad:
|
||||
bad_pairs.append(i)
|
||||
elif is_short:
|
||||
if is_short:
|
||||
n_short_excluded += 1
|
||||
elif is_bad:
|
||||
bad_pairs.append(i)
|
||||
else:
|
||||
good_pairs.append(i)
|
||||
|
||||
@@ -1222,7 +1205,7 @@ def interpolate_fNIRS_bads_weighted_average(raw, max_dist=0.03, min_neighbors=2,
|
||||
constrained_layout=True)
|
||||
if n_bad == 1: axes = [axes] # Handle single subplot case
|
||||
|
||||
axes_flat = np.asarray(axes).get_data().flatten() if hasattr(axes, 'get_data') else np.asarray(axes).ravel()
|
||||
axes_flat = np.asarray(axes).ravel()
|
||||
for j in range(n_bad, len(axes_flat)):
|
||||
if j >= n_bad:
|
||||
axes_flat[j].axis('off')
|
||||
@@ -1262,9 +1245,6 @@ def interpolate_fNIRS_bads_weighted_average(raw, max_dist=0.03, min_neighbors=2,
|
||||
print("Bads cleared:", raw.info['bads'])
|
||||
raw.info['bads'] = []
|
||||
|
||||
for ch in raw.info['bads']:
|
||||
print(f"Channel {ch} still marked as bad.")
|
||||
|
||||
fig_raw_after = raw.plot(duration=raw.times[-1], n_channels=raw.info['nchan'], title="After interpolation", show=False)
|
||||
|
||||
return raw, fig_raw_after, fig_compare
|
||||
@@ -2144,7 +2124,6 @@ def plot_glm_results(file_path, raw_haemo, glm_est, design_matrix):
|
||||
axes[2].set_ylabel("Haemoglobin (ΔμMol)")
|
||||
axes[2].legend(["Oxyhaemoglobin", "Deoxyhaemoglobin"])
|
||||
|
||||
|
||||
print(f"Number of FIR bins: {len(vals_hbo)}")
|
||||
print(f"Mean theta (HbO): {np.mean(vals_hbo):.4f}")
|
||||
print(f"Sum of theta (HbO): {np.sum(vals_hbo):.4f}")
|
||||
@@ -2415,7 +2394,6 @@ def brain_3d_visualization(
|
||||
|
||||
model_df = cast(DataFrame, statsmodels_to_results(ch_model, order=ch_summary["ch_name"].unique()))
|
||||
|
||||
|
||||
valid_channels = ch_summary["ch_name"].unique().tolist() # type: ignore
|
||||
raw_for_plot = raw_haemo.copy().pick(picks=valid_channels) # type: ignore
|
||||
|
||||
@@ -2519,7 +2497,6 @@ def brain_landmarks_3d(raw_haemo: BaseRaw, show_optodes: Literal['sensors', 'lab
|
||||
scale=0.002
|
||||
)
|
||||
|
||||
|
||||
if show_brodmann:# Add Brodmann labels
|
||||
labels = cast(list[Label], read_labels_from_annot("fsaverage", "PALS_B12_Brodmann", "lh", verbose=False)) # type: ignore
|
||||
|
||||
@@ -6033,6 +6010,24 @@ def process_participant(file_path, file_start, progress_callback=None):
|
||||
logger.info("Step 21 Completed.")
|
||||
step_start = lap(step_start, timings, "Step 21")
|
||||
|
||||
# Step 21.5: Make annotations trial-specific for individual trial GLM/FIR
|
||||
if SEPARATE_EVENTS:
|
||||
annots = raw_haemo.annotations
|
||||
if len(annots) > 0:
|
||||
new_descriptions = []
|
||||
cond_counts = {}
|
||||
for desc in annots.description:
|
||||
# Optional: filter for specific task conditions if you have other markers like 'bad' or 'iseg'
|
||||
cond_counts[desc] = cond_counts.get(desc, 0) + 1
|
||||
new_descriptions.append(f"{desc}_trial_{cond_counts[desc]}")
|
||||
|
||||
# Update raw_haemo annotations with trial-specific names
|
||||
raw_haemo.set_annotations(Annotations(
|
||||
onset=annots.onset,
|
||||
duration=annots.duration,
|
||||
description=new_descriptions
|
||||
))
|
||||
|
||||
# Step 22: Design Matrix
|
||||
raw_haemo, raw_haemo_dm, df_design_matrix, fig_design_matrix = make_design_matrix(
|
||||
raw_haemo=raw_haemo,
|
||||
@@ -6097,22 +6092,22 @@ def process_participant(file_path, file_start, progress_callback=None):
|
||||
# Step 27.5: Extract FIR Waveform Features & Enqueue Metric Plots
|
||||
fir_feature_dict = {'features': np.array([]), 'feature_names': [], 'feature_channels': []}
|
||||
|
||||
# if HRF_MODEL.lower() == "fir":
|
||||
# try:
|
||||
# fir_feature_dict = extract_fir_features_real_data(
|
||||
# raw=raw_haemo,
|
||||
# target_condition=None, # e.g., 'reach'
|
||||
# fir_delays=FIR_DELAYS, # e.g., np.arange(0, 15)
|
||||
# selected_metrics=tuple(METRIC_REGISTRY.keys()), # e.g., ('Peak_Amp', 'TTP', 'AUC')
|
||||
# roi_map=ROI_MAP,
|
||||
# glm_est=glm_est,
|
||||
# df_design_matrix=df_design_matrix,
|
||||
# png_queue=png_queue
|
||||
# )
|
||||
# logger.info("Step 27.5: FIR features successfully extracted and metric images enqueued.")
|
||||
# except Exception as e:
|
||||
# logger.warning(f"Step 27.5 Failed to extract FIR features: {e}")
|
||||
|
||||
if HRF_MODEL.lower() == "fir":
|
||||
try:
|
||||
fir_feature_dict = extract_fir_features_real_data(
|
||||
raw=raw_haemo,
|
||||
target_condition=None, # e.g., 'reach'
|
||||
fir_delays=FIR_DELAYS, # e.g., np.arange(0, 15)
|
||||
selected_metrics=tuple(METRIC_REGISTRY.keys()), # e.g., ('Peak_Amp', 'TTP', 'AUC')
|
||||
roi_map=JSON_LOCATION,
|
||||
chromophores=('hbo', 'hbr', 'hbt'),
|
||||
glm_est=glm_est,
|
||||
df_design_matrix=df_design_matrix,
|
||||
png_queue=png_queue
|
||||
)
|
||||
logger.info("Step 27.5: FIR features successfully extracted and metric images enqueued.")
|
||||
except Exception as e:
|
||||
logger.warning(f"Step 27.5 Failed to extract FIR features: {e}")
|
||||
|
||||
# Step 28: Finishing Up
|
||||
png_queue.put(None) # sentinel
|
||||
@@ -6446,11 +6441,6 @@ def _log_matrix_diagnostics(name: str, mat: np.ndarray) -> None:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Shared: channel alignment across participants
|
||||
# ============================================================================
|
||||
@@ -6839,13 +6829,6 @@ def run_group_functional_connectivity_epochs(
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def sparks_csv_export(
|
||||
haemo_obj: BaseRaw,
|
||||
save_path: str,
|
||||
@@ -6870,7 +6853,6 @@ def sparks_csv_export(
|
||||
df.to_csv(save_path, index=False)
|
||||
|
||||
|
||||
|
||||
def peak_power_fast(
|
||||
raw,
|
||||
time_window=10,
|
||||
@@ -7170,20 +7152,6 @@ def write_qc_excel_summary(
|
||||
wb.save(output_path)
|
||||
|
||||
|
||||
METRIC_REGISTRY = {
|
||||
'Peak_Amp': 'Peak_Amp',
|
||||
'TTP': 'Time_to_Peak',
|
||||
'AUC': 'AUC',
|
||||
'Rising_Slope': 'Rising_Slope',
|
||||
'Recovery_Slope': 'Recovery_Slope',
|
||||
'FWHM': 'FWHM',
|
||||
'Onset_Latency': 'Onset_Latency',
|
||||
'P2P_Amp': 'Peak_to_Peak_Amp',
|
||||
'Signal_Std': 'Signal_Std',
|
||||
'RMS': 'RMS'
|
||||
}
|
||||
|
||||
|
||||
def plot_and_enqueue_waveform_metrics(
|
||||
roi_curves,
|
||||
fir_delays,
|
||||
@@ -7235,8 +7203,8 @@ def _compute_roi_fir_curves(
|
||||
raw=None,
|
||||
target_condition='reach',
|
||||
fir_delays=np.arange(0, 15),
|
||||
roi_map=ROI_MAP,
|
||||
chromophores=('hbr',),
|
||||
roi_map={},
|
||||
chromophores=('hbo', 'hbr', 'hbt'),
|
||||
glm_est=None,
|
||||
df_design_matrix=None
|
||||
):
|
||||
@@ -7245,6 +7213,7 @@ def _compute_roi_fir_curves(
|
||||
it reuses pre-calculated GLM results directly to avoid duplicate processing.
|
||||
"""
|
||||
roi_curves = {}
|
||||
active_roi_map = normalize_roi_map(roi_map)
|
||||
|
||||
# --- SHORT-CIRCUIT: Reuse pre-calculated GLM estimation if available ---
|
||||
if glm_est is not None and df_design_matrix is not None:
|
||||
@@ -7263,25 +7232,36 @@ def _compute_roi_fir_curves(
|
||||
fir_df = glm_df[glm_df[cond_col].astype(str).str.lower().str.contains(target_condition.lower())].copy()
|
||||
print(f"Matched rows for '{target_condition}': {len(fir_df)}")
|
||||
|
||||
active_roi_map = normalize_roi_map(roi_map)
|
||||
|
||||
if fir_df.empty:
|
||||
logger.warning(f"Condition '{target_condition}' not found in precalculated GLM estimates.")
|
||||
return roi_curves
|
||||
|
||||
for chromo in chromophores:
|
||||
chromo_df = fir_df[fir_df[ch_col].str.lower().str.contains(chromo.lower())] if ch_col in fir_df.columns else fir_df
|
||||
# 1. Extract base measured chromophores (e.g., hbo, hbr) directly from GLM data
|
||||
base_chromos = [c.lower() for c in chromophores if c.lower() != 'hbt']
|
||||
|
||||
for chromo in base_chromos:
|
||||
chromo_df = fir_df[fir_df[ch_col].str.lower().str.contains(chromo)] if ch_col in fir_df.columns else fir_df
|
||||
|
||||
ch_curves = {}
|
||||
for ch_name, ch_group in chromo_df.groupby(ch_col):
|
||||
pair = ch_name.split(' ')[0]
|
||||
ch_curves[pair] = ch_group['theta'].values if 'theta' in ch_group.columns else ch_group['beta'].values
|
||||
|
||||
print("Extracted channel keys:", list(ch_curves.keys())[:5])
|
||||
|
||||
for roi_name, channels in roi_map.items():
|
||||
for roi_name, channels in active_roi_map.items():
|
||||
matching_curves = [ch_curves[ch] for ch in channels if ch in ch_curves]
|
||||
if matching_curves:
|
||||
roi_curves[(chromo, roi_name)] = np.mean(matching_curves, axis=0)
|
||||
|
||||
# 2. Derive HbT (HbO + HbR) dynamically if requested
|
||||
if 'hbt' in [c.lower() for c in chromophores]:
|
||||
for roi_name in active_roi_map.keys():
|
||||
hbo_key = ('hbo', roi_name)
|
||||
hbr_key = ('hbr', roi_name)
|
||||
if hbo_key in roi_curves and hbr_key in roi_curves:
|
||||
roi_curves[('hbt', roi_name)] = roi_curves[hbo_key] + roi_curves[hbr_key]
|
||||
|
||||
return roi_curves
|
||||
|
||||
|
||||
@@ -7290,7 +7270,8 @@ def extract_fir_features_real_data(
|
||||
target_condition=None,
|
||||
fir_delays=np.arange(0, 15),
|
||||
selected_metrics=('Peak_Amp',),
|
||||
roi_map=ROI_MAP,
|
||||
roi_map={},
|
||||
chromophores=('hbo', 'hbr', 'hbt'),
|
||||
glm_est=None,
|
||||
df_design_matrix=None,
|
||||
png_queue=None
|
||||
@@ -7323,7 +7304,7 @@ def extract_fir_features_real_data(
|
||||
target_condition=cond,
|
||||
fir_delays=fir_delays,
|
||||
roi_map=roi_map,
|
||||
chromophores=('hbr',),
|
||||
chromophores=chromophores,
|
||||
glm_est=glm_est,
|
||||
df_design_matrix=df_design_matrix
|
||||
)
|
||||
@@ -7338,9 +7319,10 @@ def extract_fir_features_real_data(
|
||||
for (chromo, roi_name), roi_fir_curve in roi_curves.items():
|
||||
metrics = compute_waveform_metrics(roi_fir_curve, fir_delays=fir_delays, selected_metrics=selected_metrics)
|
||||
collapsed_features.extend(metrics)
|
||||
# Prefix feature names with condition
|
||||
feature_names.extend([f"{cond}_{roi_name}_{m}" for m in metric_labels])
|
||||
feature_channels.extend([roi_name] * len(metric_labels))
|
||||
|
||||
# Prefix feature names with condition AND chromophore
|
||||
feature_names.extend([f"{cond}_{chromo.upper()}_{roi_name}_{m}" for m in metric_labels])
|
||||
feature_channels.extend([f"{roi_name} ({chromo.upper()})"] * len(metric_labels))
|
||||
|
||||
# Enqueue plots for this specific condition
|
||||
plot_and_enqueue_waveform_metrics(
|
||||
@@ -7404,6 +7386,58 @@ def compute_waveform_metrics(fir_curve, fir_delays, selected_metrics=('Peak_Amp'
|
||||
return [calculated_metrics[m] for m in selected_metrics]
|
||||
|
||||
|
||||
def normalize_roi_map(roi_map):
|
||||
"""
|
||||
Normalizes ROI mapping inputs into a flat {roi_name: [channel_list]} dict.
|
||||
|
||||
Accepts:
|
||||
1. String or Path pointing to a JSON file (JSON_LOCATION).
|
||||
2. Dict with 'regions_of_interest' list (loaded JSON).
|
||||
3. Standard flat dict {roi_name: [channels]}.
|
||||
"""
|
||||
# 1. If roi_map is a file path string or Path, load JSON from disk
|
||||
if isinstance(roi_map, (str, Path)):
|
||||
json_path = Path(roi_map)
|
||||
if json_path.is_file():
|
||||
try:
|
||||
with open(json_path, 'r', encoding='utf-8') as f:
|
||||
roi_map = json.load(f)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load ROI JSON file from {json_path}: {e}")
|
||||
return {}
|
||||
else:
|
||||
logger.error(f"ROI JSON file path does not exist: {json_path}")
|
||||
return {}
|
||||
|
||||
# 2. Handle nested JSON structure with 'regions_of_interest'
|
||||
if isinstance(roi_map, dict) and 'regions_of_interest' in roi_map:
|
||||
return {
|
||||
roi['name']: roi['channels']
|
||||
for roi in roi_map['regions_of_interest']
|
||||
}
|
||||
|
||||
# 3. Fallback for flat dictionary {roi_name: [channels]}
|
||||
if isinstance(roi_map, dict):
|
||||
return roi_map
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
|
||||
def extract_epoch_features(epochs, file_path, window=(4.0, 12.0)):
|
||||
"""One row per (epoch, channel): mean haemoglobin amplitude within `window` seconds."""
|
||||
tmask = (epochs.times >= window[0]) & (epochs.times <= window[1])
|
||||
feat = epochs.get_data()[:, :, tmask].mean(axis=2) * 1e6 # (n_epochs, n_channels), µM
|
||||
id_to_name = {v: k for k, v in epochs.event_id.items()}
|
||||
df = pd.DataFrame(feat, columns=epochs.ch_names)
|
||||
df["Condition"] = [id_to_name[e] for e in epochs.events[:, 2]]
|
||||
df["trial"] = epochs.selection # index of the epoch in the original event list (survives rejection)
|
||||
long = df.melt(id_vars=["Condition", "trial"], var_name="ch_name", value_name="theta")
|
||||
long["Chroma"] = long["ch_name"].map(dict(zip(epochs.ch_names, epochs.get_channel_types())))
|
||||
long["ID"] = file_path
|
||||
return long[["ch_name", "Chroma", "ID", "Condition", "trial", "theta"]]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("This file has no functionality when not used in tandem with the FLARES application.")
|
||||
|
||||
|
||||
+38
-18
@@ -20,21 +20,24 @@ from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
# External library imports
|
||||
from src.shared.shareddata import APP_NAME, PLATFORM_NAME
|
||||
from src.shared.shareddata import APP_NAME, PLATFORM_NAME, get_app_dir
|
||||
|
||||
|
||||
if PLATFORM_NAME == 'darwin':
|
||||
_log_path = os.path.join(os.path.dirname(sys.executable), f"../../../{APP_NAME}_updater.log")
|
||||
else:
|
||||
_log_path = os.path.join(os.getcwd(), f"{APP_NAME}_updater.log")
|
||||
_log_path = os.path.join(get_app_dir(), f"{APP_NAME}_updater.log")
|
||||
|
||||
LOG_FILE = _log_path
|
||||
|
||||
|
||||
def log(msg: str) -> None:
|
||||
try:
|
||||
with open(LOG_FILE, "a", encoding="utf-8") as f:
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
f.write(f"{timestamp} - {msg}\n")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def kill_all_processes_by_executable(exe_path: Union[str, Path]) -> bool:
|
||||
@@ -45,22 +48,28 @@ def kill_all_processes_by_executable(exe_path: Union[str, Path]) -> bool:
|
||||
for proc in psutil.process_iter(['pid', 'exe']):
|
||||
try:
|
||||
proc_exe = proc.info.get('exe')
|
||||
if proc_exe and os.path.samefile(os.path.realpath(proc_exe), exe_path):
|
||||
if proc_exe and os.path.exists(proc_exe) and os.path.exists(exe_path):
|
||||
if os.path.samefile(os.path.realpath(proc_exe), exe_path):
|
||||
log(f"Terminating process: PID {proc.pid}")
|
||||
_terminate_process(proc)
|
||||
terminated_any = True
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied, FileNotFoundError) as e:
|
||||
continue
|
||||
except Exception as e:
|
||||
log(f"Error terminating process (Windows): {e}")
|
||||
|
||||
elif PLATFORM_NAME == 'linux':
|
||||
for proc in psutil.process_iter(['pid', 'cmdline']):
|
||||
try:
|
||||
cmdline = proc.info.get('cmdline', [])
|
||||
if cmdline:
|
||||
if cmdline and os.path.exists(cmdline[0]) and os.path.exists(exe_path):
|
||||
proc_cmd = os.path.realpath(cmdline[0])
|
||||
if os.path.samefile(proc_cmd, exe_path):
|
||||
log(f"Terminating process: PID {proc.pid}")
|
||||
_terminate_process(proc)
|
||||
terminated_any = True
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied, FileNotFoundError) as e:
|
||||
continue
|
||||
except Exception as e:
|
||||
log(f"Error terminating process (Linux): {e}")
|
||||
|
||||
@@ -82,6 +91,9 @@ def _terminate_process(proc: psutil.Process) -> None:
|
||||
|
||||
|
||||
def wait_for_unlock(path: Union[str, Path], timeout: Union[int, float] = 100) -> None:
|
||||
if not os.path.exists(path):
|
||||
return
|
||||
|
||||
start_time = time.time()
|
||||
while time.time() - start_time < timeout:
|
||||
try:
|
||||
@@ -130,7 +142,7 @@ def copy_update_files(src_folder: Union[str, Path], dest_folder: Union[str, Path
|
||||
|
||||
|
||||
def copy_update_files_darwin(src_folder: Union[str, Path], dest_folder: Union[str, Path], updater_name: str) -> None:
|
||||
|
||||
if not updater_name.endswith(".app"):
|
||||
updater_name = updater_name + ".app"
|
||||
|
||||
for item in os.listdir(src_folder):
|
||||
@@ -152,10 +164,7 @@ def copy_update_files_darwin(src_folder: Union[str, Path], dest_folder: Union[st
|
||||
|
||||
|
||||
def remove_quarantine(app_path: Union[str, Path]) -> bool:
|
||||
"""Removes the macOS quarantine extended attribute from an application bundle using osascript.
|
||||
Returns True on success, False on error or cancellation.
|
||||
"""
|
||||
|
||||
"""Removes the macOS quarantine extended attribute from an application bundle using osascript."""
|
||||
clean_path: str = str(app_path)
|
||||
escaped_path: str = shlex.quote(clean_path)
|
||||
|
||||
@@ -172,7 +181,7 @@ def remove_quarantine(app_path: Union[str, Path]) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
def main() -> None:
|
||||
main_exe: str = ""
|
||||
app_dir: Path = Path()
|
||||
bundle_dir: Path = Path()
|
||||
@@ -190,16 +199,23 @@ def main():
|
||||
|
||||
main_exe_path = Path(main_exe).resolve()
|
||||
app_dir = main_exe_path.parent
|
||||
|
||||
if PLATFORM_NAME == 'darwin':
|
||||
# Bundle hierarchy on macOS: App.app/Contents/MacOS/executable
|
||||
if len(main_exe_path.parents) >= 4:
|
||||
bundle_dir = main_exe_path.parents[2]
|
||||
parent_bundle_dir = main_exe_path.parents[3]
|
||||
else:
|
||||
bundle_dir = app_dir
|
||||
parent_bundle_dir = app_dir.parent
|
||||
|
||||
updater_name = os.path.basename(sys.argv[0])
|
||||
updater_name = os.path.basename(sys.executable if getattr(sys, 'frozen', False) else sys.argv[0])
|
||||
|
||||
log("Updater started.")
|
||||
log(f"Update folder: {update_folder}")
|
||||
log(f"Main EXE: {main_exe}")
|
||||
log(f"Updater EXE: {updater_name}")
|
||||
if PLATFORM_NAME == 'darwin':
|
||||
if PLATFORM_NAME == 'darwin' and bundle_dir:
|
||||
log(f"Main App Folder: {bundle_dir}")
|
||||
|
||||
# Kill all instances of main app
|
||||
@@ -212,18 +228,22 @@ def main():
|
||||
try:
|
||||
if PLATFORM_NAME == 'windows':
|
||||
proc_exe = proc.info.get('exe')
|
||||
if proc_exe and os.path.samefile(os.path.realpath(proc_exe), os.path.realpath(main_exe)):
|
||||
if proc_exe and os.path.exists(proc_exe) and os.path.exists(main_exe):
|
||||
if os.path.samefile(os.path.realpath(proc_exe), os.path.realpath(main_exe)):
|
||||
running = True
|
||||
break
|
||||
elif PLATFORM_NAME == 'linux':
|
||||
cmdline = proc.info.get('cmdline', [])
|
||||
if cmdline:
|
||||
if cmdline and os.path.exists(cmdline[0]) and os.path.exists(main_exe):
|
||||
proc_cmd = os.path.realpath(cmdline[0])
|
||||
if os.path.samefile(proc_cmd, os.path.realpath(main_exe)):
|
||||
running = True
|
||||
break
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied, FileNotFoundError):
|
||||
continue
|
||||
except Exception as e:
|
||||
log(f"Polling error: {e}")
|
||||
|
||||
if not running:
|
||||
break
|
||||
time.sleep(0.5)
|
||||
@@ -231,11 +251,11 @@ def main():
|
||||
log("Warning: main executable still running after wait timeout.")
|
||||
|
||||
# Delete old version files
|
||||
if PLATFORM_NAME == 'darwin':
|
||||
if PLATFORM_NAME == 'darwin' and bundle_dir and parent_bundle_dir:
|
||||
log(f'Attempting to delete {bundle_dir}')
|
||||
delete_path(str(bundle_dir))
|
||||
update_folder = os.path.join(sys.argv[1], f"{APP_NAME}-darwin")
|
||||
copy_update_files_darwin(update_folder, str(parent_bundle_dir), updater_name)
|
||||
extracted_update_folder = os.path.join(update_folder, f"{APP_NAME}-darwin")
|
||||
copy_update_files_darwin(extracted_update_folder, str(parent_bundle_dir), updater_name)
|
||||
|
||||
else:
|
||||
delete_path(main_exe)
|
||||
@@ -253,7 +273,7 @@ def main():
|
||||
os.chmod(main_exe, 0o755)
|
||||
log("Added executable bit")
|
||||
|
||||
if PLATFORM_NAME == 'darwin':
|
||||
if PLATFORM_NAME == 'darwin' and bundle_dir:
|
||||
os.chmod(str(bundle_dir), 0o755)
|
||||
log("Added executable bit")
|
||||
remove_quarantine(str(bundle_dir))
|
||||
|
||||
@@ -15,6 +15,7 @@ import traceback
|
||||
import subprocess
|
||||
import configparser
|
||||
import concurrent.futures
|
||||
from typing import Any
|
||||
from queue import Empty
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
@@ -22,20 +23,26 @@ from datetime import datetime
|
||||
from functools import partial
|
||||
from multiprocessing import Process, current_process, freeze_support, Queue, set_start_method
|
||||
|
||||
# Fix for plotting on linux
|
||||
if sys.platform.startswith("linux"):
|
||||
os.environ["QT_QPA_PLATFORM"] = "xcb"
|
||||
|
||||
# External library imports
|
||||
import psutil
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication, QWidget, QMessageBox, QVBoxLayout, QHBoxLayout, QTextEdit, QScrollArea, QComboBox, QGridLayout, QSplitter, QDialogButtonBox, QHeaderView,
|
||||
QApplication, QStyle, QSystemTrayIcon, QWidget, QMessageBox, QVBoxLayout, QHBoxLayout, QTextEdit, QScrollArea, QComboBox, QGridLayout, QSplitter, QDialogButtonBox, QHeaderView,
|
||||
QPushButton, QMainWindow, QLabel, QLineEdit, QGroupBox, QDialog, QMenu, QSpinBox, QTableWidget, QTableWidgetItem
|
||||
)
|
||||
from PySide6.QtCore import QEvent, QObject, Signal, Qt, QTimer
|
||||
from PySide6.QtGui import QAction, QActionGroup, QFontMetrics, QKeySequence, QIcon
|
||||
from PySide6.QtGui import QAction, QActionGroup, QFontMetrics, QKeySequence, QIcon, QTextCursor
|
||||
from PySide6.QtSvgWidgets import QSvgWidget # needed to show svgs when app is not frozen
|
||||
|
||||
from file_ext_registration import register_file_association, ELEVATION_FLAG
|
||||
from plugin_manager import PluginManager, StartupUpdateWorker
|
||||
from project_manager import ProjectManager
|
||||
from src.window.about import AboutWindow
|
||||
from src.window.plugins import PluginsWindow
|
||||
from src.window.terminal import TerminalWindow
|
||||
from src.window.updateevents import EventUpdateMode, UpdateEventsBlazesWindow, UpdateEventsWindow
|
||||
from src.window.updateoptodes import UpdateOptodesWindow
|
||||
@@ -43,7 +50,7 @@ from src.window.userguide import UserGuideWindow
|
||||
from src.window.viewerlauncher import ViewerLauncherWidget
|
||||
from src.window.welcome import WelcomeDialog
|
||||
from src.shared.flaresbasewidget import FilePickerWidget, ParamSection, ProgressBubble
|
||||
from src.shared.shareddata import API_URL, API_URL_SECONDARY, APP_NAME, CURRENT_VERSION, PLATFORM_NAME, DATA_SCHEMA
|
||||
from src.shared.shareddata import API_URL, API_URL_SECONDARY, APP_NAME, CURRENT_VERSION, PLATFORM_NAME, DATA_SCHEMA, PLUGINS_URL, get_app_dir
|
||||
from startup_args import parse_startup_args
|
||||
from updater import finish_update_if_needed, UpdateManager, LocalPendingUpdateCheckThread
|
||||
|
||||
@@ -65,13 +72,19 @@ show_welcome_dialog = false
|
||||
first_startup = true
|
||||
|
||||
[Preferences]
|
||||
theme = auto
|
||||
2d_data_bypass = false
|
||||
incompatible_save_bypass = false
|
||||
missing_events_bypass = false
|
||||
analysis_clearing_bypass = false
|
||||
folding_bypass = false
|
||||
advanced_parameters = false
|
||||
theme_mode = auto
|
||||
|
||||
processing_2d_data_bypass = false
|
||||
processing_incompatible_save_bypass = false
|
||||
processing_missing_events_bypass = false
|
||||
processing_analysis_clearing_bypass = false
|
||||
processing_folding_bypass = false
|
||||
processing_separate_events = false
|
||||
|
||||
ui_advanced_parameters = false
|
||||
|
||||
updates_check_plugins_automatic = true
|
||||
updates_check_app_automatic = true
|
||||
|
||||
[Terminal]
|
||||
|
||||
@@ -79,6 +92,7 @@ advanced_parameters = false
|
||||
|
||||
"""
|
||||
|
||||
|
||||
# Selectable parameters on the right side of the window
|
||||
SECTIONS = [
|
||||
{
|
||||
@@ -328,6 +342,33 @@ BIDS_FIELD_MAP = {
|
||||
|
||||
|
||||
|
||||
class LogWindow(QDialog):
|
||||
|
||||
def __init__(self, title, file_path, log_text, parent=None):
|
||||
super().__init__(parent)
|
||||
self.file_path = file_path
|
||||
self.setWindowTitle(title)
|
||||
self.resize(700, 500)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
self.text_edit = QTextEdit(self)
|
||||
self.text_edit.setReadOnly(True)
|
||||
self.text_edit.setPlainText(log_text)
|
||||
|
||||
font = self.text_edit.font()
|
||||
font.setFamily("Courier")
|
||||
self.text_edit.setFont(font)
|
||||
|
||||
layout.addWidget(self.text_edit)
|
||||
|
||||
def append_log(self, text):
|
||||
# Auto-scroll to the bottom and insert incoming text chunk
|
||||
cursor = self.text_edit.textCursor()
|
||||
cursor.movePosition(QTextCursor.MoveOperation.End)
|
||||
self.text_edit.setTextCursor(cursor)
|
||||
self.text_edit.insertPlainText(text)
|
||||
|
||||
|
||||
class GroupAssignmentDialog(QDialog):
|
||||
"""Dialog allowing users to create groups and assign unique metadata values to them."""
|
||||
|
||||
@@ -360,7 +401,6 @@ class GroupAssignmentDialog(QDialog):
|
||||
|
||||
def _init_ui(self):
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# Field selector
|
||||
field_box_layout = QHBoxLayout()
|
||||
field_box_layout.addWidget(QLabel("Group by:"))
|
||||
@@ -533,6 +573,49 @@ class ThemeChangeWatcher(QObject):
|
||||
self.main_window.update_theme()
|
||||
|
||||
|
||||
class DropScrollArea(QScrollArea):
|
||||
"""QScrollArea that accepts .snirf file and folder drops."""
|
||||
|
||||
def __init__(self, on_files_dropped_callback, parent=None):
|
||||
super().__init__(parent)
|
||||
self.on_files_dropped_callback = on_files_dropped_callback
|
||||
self.setAcceptDrops(True)
|
||||
# ScrollAreas require setting acceptDrops on their viewport as well
|
||||
self.viewport().setAcceptDrops(True)
|
||||
|
||||
def dragEnterEvent(self, event) -> None:
|
||||
if event.mimeData().hasUrls():
|
||||
event.acceptProposedAction()
|
||||
else:
|
||||
event.ignore()
|
||||
|
||||
def dropEvent(self, event) -> None:
|
||||
if not event.mimeData().hasUrls():
|
||||
return
|
||||
|
||||
dropped_paths = [
|
||||
url.toLocalFile() for url in event.mimeData().urls() if url.isLocalFile()
|
||||
]
|
||||
|
||||
files_to_load = []
|
||||
for path_str in dropped_paths:
|
||||
p = Path(path_str)
|
||||
if p.is_file() and p.suffix.lower() == ".snirf":
|
||||
files_to_load.append(os.path.normpath(str(p)))
|
||||
elif p.is_dir():
|
||||
files_to_load.extend(
|
||||
os.path.normpath(str(f)) for f in p.rglob("*.snirf")
|
||||
)
|
||||
|
||||
if files_to_load:
|
||||
self.on_files_dropped_callback(files_to_load)
|
||||
else:
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"No Valid Files",
|
||||
"None of the dropped items were .snirf files or directories containing .snirf files.",
|
||||
)
|
||||
|
||||
class MainApplication(QMainWindow):
|
||||
"""
|
||||
Main application window that creates and sets up the UI.
|
||||
@@ -556,45 +639,73 @@ class MainApplication(QMainWindow):
|
||||
self.optodes = None
|
||||
self.events = None
|
||||
self.events_blazes = None
|
||||
self.plugins = None
|
||||
self.terminal = None
|
||||
self.bubble_widgets = {}
|
||||
self.param_sections = []
|
||||
self.folder_paths = []
|
||||
self.section_widget = None
|
||||
self.first_run = True
|
||||
self.show_status_bar = False
|
||||
self.is_2d_bypass = False
|
||||
self.incompatible_save_bypass = False
|
||||
self.missing_events_bypass = False
|
||||
self.analysis_clearing_bypass = False
|
||||
self.folding_bypass = False
|
||||
self.advanced_parameters = False
|
||||
|
||||
self.is_saved = True
|
||||
self.current_project_path = None
|
||||
self.current_file = None
|
||||
self.selected_paths = []
|
||||
self.saved_selected_paths = []
|
||||
self.files_are_dirty = False
|
||||
|
||||
self.project_manager = ProjectManager(self, file_cfg=file_cfg, cfg_path=cfg_path)
|
||||
|
||||
# Initialization to ensure that saving can occur
|
||||
for item in DATA_SCHEMA:
|
||||
setattr(self, item["key"], {})
|
||||
|
||||
self.file_metadata = {} # AGE, SEX, HAND, GROUP
|
||||
self.metadata_cache = {} # Internal file/path information metadata cache
|
||||
self.bubble_widgets = {} # References to the UI "Bubble" objects
|
||||
self.current_file = None # Tracks the currently selected absolute path
|
||||
|
||||
self.metadata_processed.connect(self._safe_ui_update)
|
||||
self.metadata_ui_signal.connect(self._handle_metadata_ui_update)
|
||||
self.participant_logs = {}
|
||||
|
||||
self.files_total = 0 # total number of files to process
|
||||
self.files_done = set() # set of file paths done (success or fail)
|
||||
self.files_failed = set() # set of failed file paths
|
||||
self.files_results = {} # dict for successful results (if needed)
|
||||
|
||||
self.pending_update_version = None
|
||||
self.pending_update_path = None
|
||||
|
||||
# are these used?
|
||||
self.param_sections = []
|
||||
self.folder_paths = []
|
||||
self.last_clicked_bubble = None
|
||||
|
||||
self.project_manager = ProjectManager(self, file_cfg=file_cfg, cfg_path=cfg_path)
|
||||
self.plugin_manager = PluginManager(self)
|
||||
|
||||
# Initialization to ensure that saving can occur
|
||||
for item in DATA_SCHEMA:
|
||||
setattr(self, item["key"], {})
|
||||
|
||||
config_mappings = {
|
||||
"View": ["status_bar"],
|
||||
"Preferences": [
|
||||
"theme_mode",
|
||||
"processing_2d_data_bypass",
|
||||
"processing_incompatible_save_bypass",
|
||||
"processing_missing_events_bypass",
|
||||
"processing_analysis_clearing_bypass",
|
||||
"processing_folding_bypass",
|
||||
"processing_separate_events",
|
||||
"ui_advanced_parameters",
|
||||
"updates_check_plugins_automatic",
|
||||
"updates_check_app_automatic",
|
||||
]
|
||||
}
|
||||
|
||||
for section, keys in config_mappings.items():
|
||||
for key in keys:
|
||||
if section == "Preferences" and key == "theme_mode":
|
||||
val = file_cfg.get(section, key, fallback="auto")
|
||||
elif key in ["status_bar", "updates_check_plugins_automatic", "updates_check_app_automatic"]:
|
||||
val = file_cfg.getboolean(section, key, fallback=True)
|
||||
else:
|
||||
val = file_cfg.getboolean(section, key, fallback=False)
|
||||
setattr(self, key, val)
|
||||
|
||||
self.metadata_processed.connect(self._safe_ui_update)
|
||||
self.metadata_ui_signal.connect(self._handle_metadata_ui_update)
|
||||
|
||||
|
||||
self.updater = UpdateManager(
|
||||
main_window=self,
|
||||
api_url=API_URL,
|
||||
@@ -607,17 +718,37 @@ class MainApplication(QMainWindow):
|
||||
|
||||
self.init_ui()
|
||||
self.create_menu_bar()
|
||||
|
||||
self.pending_update_version = None
|
||||
self.pending_update_path = None
|
||||
self.last_clicked_bubble = None
|
||||
self.installEventFilter(self)
|
||||
|
||||
# Start local pending update check thread
|
||||
# Conditionally start background network threads using the loaded attributes
|
||||
if self.updates_check_app_automatic:
|
||||
self.local_check_thread = LocalPendingUpdateCheckThread(CURRENT_VERSION, "-" + PLATFORM_NAME, PLATFORM_NAME, APP_NAME)
|
||||
self.local_check_thread.pending_update_found.connect(self.updater.on_pending_update_found)
|
||||
self.local_check_thread.no_pending_update.connect(self.updater.on_no_pending_update)
|
||||
self.local_check_thread.start()
|
||||
else:
|
||||
print("[MainApplication] App automatic update check is disabled in preferences.")
|
||||
|
||||
if self.updates_check_plugins_automatic:
|
||||
self._startup_update_worker = StartupUpdateWorker(self.plugin_manager, [PLUGINS_URL])
|
||||
self._startup_update_worker.updates_checked.connect(self._on_startup_updates_found)
|
||||
self._startup_update_worker.start()
|
||||
else:
|
||||
print("[MainApplication] Plugin automatic update check is disabled in preferences.")
|
||||
|
||||
self.plugin_manager.reload_plugins()
|
||||
|
||||
self.tray_icon = QSystemTrayIcon(self)
|
||||
stock_icon = self.style().standardIcon(QStyle.StandardPixmap.SP_ComputerIcon) # Or use your own QIcon("path_to_logo.png")
|
||||
self.tray_icon.setIcon(stock_icon)
|
||||
self.tray_icon.show()
|
||||
|
||||
def bring_to_front():
|
||||
self.showNormal() # Restores if minimized
|
||||
self.raise_() # Brings to top of window stack (macOS/Linux)
|
||||
self.activateWindow() # Focuses the window (Windows)
|
||||
|
||||
self.tray_icon.messageClicked.connect(bring_to_front)
|
||||
|
||||
self.show()
|
||||
|
||||
@@ -651,7 +782,6 @@ class MainApplication(QMainWindow):
|
||||
self.project_manager.load_project(file_to_open)
|
||||
|
||||
|
||||
|
||||
def init_ui(self):
|
||||
central = QWidget()
|
||||
self.setCentralWidget(central)
|
||||
@@ -709,7 +839,9 @@ class MainApplication(QMainWindow):
|
||||
self.bubble_layout = QGridLayout(self.bubble_container)
|
||||
self.bubble_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
|
||||
|
||||
self.scroll_area = QScrollArea()
|
||||
self.scroll_area = DropScrollArea(
|
||||
on_files_dropped_callback=self.project_manager.load_dropped_files
|
||||
)
|
||||
self.scroll_area.setWidgetResizable(True)
|
||||
self.scroll_area.setWidget(self.bubble_container)
|
||||
self.scroll_area.setMinimumHeight(200)
|
||||
@@ -775,18 +907,40 @@ class MainApplication(QMainWindow):
|
||||
# for stat in top_stats[:10]:
|
||||
# print(stat)
|
||||
|
||||
def create_menu_bar(self):
|
||||
'''Menu Bar at the top of the screen'''
|
||||
def _on_startup_updates_found(self, outdated_plugins: list[dict[str, Any]]) -> None:
|
||||
"""Safely displays the update popup on the main thread once the background check finishes."""
|
||||
if not outdated_plugins:
|
||||
return
|
||||
|
||||
update_details = "\n".join(
|
||||
f"• {p['name']}: Installed v{p['version']} ➔ New v{p['remote_version']}"
|
||||
for p in outdated_plugins
|
||||
)
|
||||
summary_msg = f"The following plugin updates are available:\n\n{update_details}\n\nYou can update them in the Plugin Manager."
|
||||
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"Plugin Updates Available",
|
||||
summary_msg
|
||||
)
|
||||
|
||||
def create_menu_bar(self):
|
||||
"""Menu Bar at the top of the screen built dynamically from a unified schema."""
|
||||
menu_bar = self.menuBar()
|
||||
self.statusbar = self.statusBar()
|
||||
|
||||
self.view_actions = {}
|
||||
self.pref_actions = {}
|
||||
|
||||
def make_action(name, shortcut=None, slot=None, checkable=False, checked=False, icon=None):
|
||||
action = QAction(name, self)
|
||||
|
||||
if shortcut:
|
||||
action.setShortcut(QKeySequence(shortcut))
|
||||
if slot:
|
||||
if checkable:
|
||||
# Checkable actions use 'toggled' to reliably pass the boolean state
|
||||
action.toggled.connect(slot)
|
||||
else:
|
||||
action.triggered.connect(slot)
|
||||
if checkable:
|
||||
action.setCheckable(True)
|
||||
@@ -795,133 +949,142 @@ class MainApplication(QMainWindow):
|
||||
action.setIcon(QIcon(icon))
|
||||
return action
|
||||
|
||||
# File menu and actions
|
||||
file_menu = menu_bar.addMenu("File")
|
||||
file_actions = [
|
||||
("Open File...", "Ctrl+O", self.project_manager.open_file_dialog, resource_path("icons/file_open_24dp_1F1F1F.svg")),
|
||||
("Open Folder...", "Ctrl+Alt+O", self.project_manager.open_folder_dialog, resource_path("icons/folder_24dp_1F1F1F.svg")),
|
||||
("Load Project...", "Ctrl+L", self.project_manager.load_project_dialog, resource_path("icons/article_24dp_1F1F1F.svg")),
|
||||
("Save Project...", "Ctrl+S", lambda: self.project_manager.save_project(ask=False), resource_path("icons/save_24dp_1F1F1F.svg")),
|
||||
("Save Project As...", "Ctrl+Shift+S", lambda: self.project_manager.save_project(ask=True), resource_path("icons/save_as_24dp_1F1F1F.svg")),
|
||||
# Unified Menu Bar Schema
|
||||
MENU_BAR_SCHEMA = [
|
||||
# === FILE MENU ===
|
||||
{"menu": "File", "name": "Open File...", "shortcut": "Ctrl+O", "slot": self.project_manager.open_file_dialog, "icon": resource_path("icons/file_open_24dp_1F1F1F.svg")},
|
||||
{"menu": "File", "name": "Open Folder...", "shortcut": "Ctrl+Alt+O", "slot": self.project_manager.open_folder_dialog, "icon": resource_path("icons/folder_24dp_1F1F1F.svg")},
|
||||
{"menu": "File", "type": "recent_files"},
|
||||
{"menu": "File", "type": "separator"},
|
||||
{"menu": "File", "name": "Load Project...", "shortcut": "Ctrl+L", "slot": self.project_manager.load_project_dialog, "icon": resource_path("icons/article_24dp_1F1F1F.svg")},
|
||||
{"menu": "File", "type": "recent_projects"},
|
||||
{"menu": "File", "type": "separator"},
|
||||
{"menu": "File", "name": "Save Project...", "shortcut": "Ctrl+S", "slot": lambda: self.project_manager.save_project(ask=False), "icon": resource_path("icons/save_24dp_1F1F1F.svg")},
|
||||
{"menu": "File", "name": "Save Project As...", "shortcut": "Ctrl+Shift+S", "slot": lambda: self.project_manager.save_project(ask=True), "icon": resource_path("icons/save_as_24dp_1F1F1F.svg")},
|
||||
{"menu": "File", "type": "separator"},
|
||||
{"menu": "File", "name": "Exit", "shortcut": "Ctrl+Q", "slot": QApplication.instance().quit, "icon": resource_path("icons/exit_to_app_24dp_1F1F1F.svg")},
|
||||
|
||||
# === EDIT MENU ===
|
||||
{"menu": "Edit", "name": "Cut", "shortcut": "Ctrl+X", "slot": self.cut_text, "icon": resource_path("icons/content_cut_24dp_1F1F1F.svg")},
|
||||
{"menu": "Edit", "name": "Copy", "shortcut": "Ctrl+C", "slot": self.copy_text, "icon": resource_path("icons/content_copy_24dp_1F1F1F.svg")},
|
||||
{"menu": "Edit", "name": "Paste", "shortcut": "Ctrl+V", "slot": self.paste_text, "icon": resource_path("icons/content_paste_24dp_1F1F1F.svg")},
|
||||
|
||||
# === VIEW MENU ===
|
||||
{"menu": "View", "name": "Toggle Status Bar", "shortcut": "", "slot": self.toggle_statusbar, "checkable": True, "checked": True, "icon": resource_path("icons/short_text_24dp_1F1F1F.svg"), "key": "statusbar"},
|
||||
{"menu": "View", "type": "separator"},
|
||||
{"menu": "View", "name": "Reset Window Layout", "shortcut": "Ctrl+Shift+R", "slot": self.reset_window_layout, "icon": resource_path("icons/grid_layout_side_24dp_1F1F1F.svg")},
|
||||
|
||||
# === OPTIONS MENU ===
|
||||
{"menu": "Options", "name": "User Guide", "shortcut": "F1", "slot": self.user_guide, "icon": resource_path("icons/help_24dp_1F1F1F.svg")},
|
||||
{"menu": "Options", "name": "Regroup Files from Metadata", "shortcut": "F3", "slot": self.group_metadata, "icon": resource_path("icons/help_24dp_1F1F1F.svg")},
|
||||
{"menu": "Options", "name": "Check for Updates", "shortcut": "F5", "slot": self.updater.manual_check_for_updates, "icon": resource_path("icons/update_24dp_1F1F1F.svg")},
|
||||
{"menu": "Options", "name": "Show Update Changelog", "shortcut": "F6", "slot": self.show_update_changelog, "icon": resource_path("icons/article_shortcut_24dp_1F1F1F.svg")},
|
||||
{"menu": "Options", "type": "separator"},
|
||||
{"menu": "Options", "name": "Update events in snirf file (BORIS)...", "shortcut": "F7", "slot": self.update_event_markers, "icon": resource_path("icons/upgrade_24dp_1F1F1F.svg")},
|
||||
{"menu": "Options", "name": "Update events in snirf file (BLAZES)...", "shortcut": "F8", "slot": self.update_event_markers_blazes, "icon": resource_path("icons/upgrade_24dp_1F1F1F.svg")},
|
||||
{"menu": "Options", "name": "Update optodes in snirf file...", "shortcut": "F9", "slot": self.update_optode_positions, "icon": resource_path("icons/upgrade_24dp_1F1F1F.svg")},
|
||||
{"menu": "Options", "type": "separator"},
|
||||
{"menu": "Options", "name": "Reset to Default Configuration", "shortcut": "F10", "slot": self.reset_to_default_configuration, "icon": resource_path("icons/reset_settings_24dp_1F1F1F.svg")},
|
||||
{"menu": "Options", "type": "separator"},
|
||||
{"menu": "Options", "name": "About", "shortcut": "F12", "slot": self.about_window, "icon": resource_path("icons/info_24dp_1F1F1F.svg")},
|
||||
|
||||
# === PREFERENCES: THEME ===
|
||||
{"menu": "Preferences", "submenu": "Theme", "name": "Auto", "mode": "auto", "slot": partial(self.theme_change_func, "auto"), "icon": resource_path("icons/warning_off_24dp_1F1F1F.svg"), "key": "theme_auto", "group": "theme", "checkable": True},
|
||||
{"menu": "Preferences", "submenu": "Theme", "name": "Light", "mode": "light", "slot": partial(self.theme_change_func, "light"), "icon": resource_path("icons/warning_off_24dp_1F1F1F.svg"), "key": "theme_light", "group": "theme", "checkable": True},
|
||||
{"menu": "Preferences", "submenu": "Theme", "name": "Dark", "mode": "dark", "slot": partial(self.theme_change_func, "dark"), "icon": resource_path("icons/warning_off_24dp_1F1F1F.svg"), "key": "theme_dark", "group": "theme", "checkable": True},
|
||||
|
||||
# === PREFERENCES: PROCESSING ===
|
||||
{"menu": "Preferences", "submenu": "Processing", "name": "2D Data Bypass", "key": "processing_2d_data_bypass", "icon": resource_path("icons/warning_off_24dp_1F1F1F.svg")},
|
||||
{"menu": "Preferences", "submenu": "Processing", "name": "Incompatible Save Bypass", "key": "processing_incompatible_save_bypass", "icon": resource_path("icons/warning_off_24dp_1F1F1F.svg")},
|
||||
{"menu": "Preferences", "submenu": "Processing", "name": "Missing Events Bypass", "key": "processing_missing_events_bypass", "icon": resource_path("icons/warning_off_24dp_1F1F1F.svg")},
|
||||
{"menu": "Preferences", "submenu": "Processing", "name": "Analysis Clearing Bypass", "key": "processing_analysis_clearing_bypass", "icon": resource_path("icons/warning_off_24dp_1F1F1F.svg")},
|
||||
{"menu": "Preferences", "submenu": "Processing", "name": "Folding Bypass", "key": "processing_folding_bypass", "icon": resource_path("icons/warning_off_24dp_1F1F1F.svg")},
|
||||
{"menu": "Preferences", "submenu": "Processing", "name": "Separate Events", "key": "processing_separate_events", "icon": resource_path("icons/warning_off_24dp_1F1F1F.svg")},
|
||||
|
||||
# === PREFERENCES: UI ===
|
||||
{"menu": "Preferences", "submenu": "UI", "name": "Show Advanced Parameters", "key": "ui_advanced_parameters", "icon": resource_path("icons/warning_off_24dp_1F1F1F.svg")},
|
||||
|
||||
# === PREFERENCES: UPDATES ===
|
||||
{"menu": "Preferences", "submenu": "Updates", "name": "Automatic App Updates", "key": "updates_check_app_automatic", "icon": resource_path("icons/warning_off_24dp_1F1F1F.svg")},
|
||||
{"menu": "Preferences", "submenu": "Updates", "name": "Automatic Plugin Updates", "key": "updates_check_plugins_automatic", "icon": resource_path("icons/warning_off_24dp_1F1F1F.svg")},
|
||||
|
||||
# === PLUGINS MENU ===
|
||||
{"menu": "Plugins", "name": "Plugin Manager", "shortcut": "Ctrl+Alt+P", "slot": self.plugins_gui, "icon": resource_path("icons/terminal_24dp_1F1F1F.svg")},
|
||||
|
||||
# === TERMINAL MENU ===
|
||||
{"menu": "Terminal", "name": "New Terminal", "shortcut": "Ctrl+Alt+T", "slot": self.terminal_gui, "icon": resource_path("icons/terminal_24dp_1F1F1F.svg")},
|
||||
]
|
||||
|
||||
for i, (name, shortcut, slot, icon) in enumerate(file_actions):
|
||||
file_menu.addAction(make_action(name, shortcut, slot, icon=icon))
|
||||
if i == 1:
|
||||
self.recent_files_menu = file_menu.addMenu("Recent Files")
|
||||
self.recent_files_menu.setIcon(QIcon(resource_path("icons/history_24dp_1F1F1F.svg"))) # optional icon
|
||||
file_menu.addSeparator()
|
||||
elif i == 2:
|
||||
self.recent_projects_menu = file_menu.addMenu("Recent Projects")
|
||||
self.recent_projects_menu.setIcon(QIcon(resource_path("icons/history_2_24dp_1F1F1F.svg")))
|
||||
file_menu.addSeparator()
|
||||
|
||||
file_menu.addSeparator()
|
||||
file_menu.addAction(make_action("Exit", "Ctrl+Q", QApplication.instance().quit, icon=resource_path("icons/exit_to_app_24dp_1F1F1F.svg")))
|
||||
|
||||
# Edit menu
|
||||
edit_menu = menu_bar.addMenu("Edit")
|
||||
edit_actions = [
|
||||
("Cut", "Ctrl+X", self.cut_text, resource_path("icons/content_cut_24dp_1F1F1F.svg")),
|
||||
("Copy", "Ctrl+C", self.copy_text, resource_path("icons/content_copy_24dp_1F1F1F.svg")),
|
||||
("Paste", "Ctrl+V", self.paste_text, resource_path("icons/content_paste_24dp_1F1F1F.svg"))
|
||||
]
|
||||
for name, shortcut, slot, icon in edit_actions:
|
||||
edit_menu.addAction(make_action(name, shortcut, slot, icon=icon))
|
||||
|
||||
# View menu
|
||||
|
||||
self.view_actions = {}
|
||||
|
||||
view_menu = menu_bar.addMenu("View")
|
||||
toggle_statusbar_action = make_action("Toggle Status Bar", "", self.toggle_statusbar, True, True, resource_path("icons/short_text_24dp_1F1F1F.svg"))
|
||||
view_menu.addAction(toggle_statusbar_action)
|
||||
self.view_actions["statusbar"] = toggle_statusbar_action
|
||||
|
||||
# Reset Layout Action
|
||||
view_menu.addSeparator()
|
||||
reset_layout_action = make_action(
|
||||
"Reset Window Layout",
|
||||
"Ctrl+Shift+R",
|
||||
self.reset_window_layout,
|
||||
icon=resource_path("icons/grid_layout_side_24dp_1F1F1F.svg")
|
||||
)
|
||||
view_menu.addAction(reset_layout_action)
|
||||
|
||||
# Options menu (Help & About)
|
||||
options_menu = menu_bar.addMenu("Options")
|
||||
|
||||
options_actions = [
|
||||
("User Guide", "F1", self.user_guide, resource_path("icons/help_24dp_1F1F1F.svg")),
|
||||
("Regroup Files from Metadata", "F3", self.group_metadata, resource_path("icons/help_24dp_1F1F1F.svg")),
|
||||
("Check for Updates", "F5", self.updater.manual_check_for_updates, resource_path("icons/update_24dp_1F1F1F.svg")),
|
||||
("Show Update Changelog", "F6", self.show_update_changelog, resource_path("icons/article_shortcut_24dp_1F1F1.svg")),
|
||||
("Update events in snirf file (BORIS)...", "F7", self.update_event_markers, resource_path("icons/upgrade_24dp_1F1F1F.svg")),
|
||||
("Update events in snirf file (BLAZES)...", "F8", self.update_event_markers_blazes, resource_path("icons/upgrade_24dp_1F1F1F.svg")),
|
||||
("Update optodes in snirf file...", "F9", self.update_optode_positions, resource_path("icons/upgrade_24dp_1F1F1F.svg")),
|
||||
("Reset to Default Configuration", "F10", self.reset_to_default_configuration, resource_path("icons/reset_settings_24dp_1F1F1F.svg")),
|
||||
("About", "F12", self.about_window, resource_path("icons/info_24dp_1F1F1F.svg"))
|
||||
]
|
||||
|
||||
for i, (name, shortcut, slot, icon) in enumerate(options_actions):
|
||||
options_menu.addAction(make_action(name, shortcut, slot, icon=icon))
|
||||
if i == 3 or i == 6 or i == 7:
|
||||
options_menu.addSeparator()
|
||||
|
||||
self.pref_actions = {}
|
||||
|
||||
preferences_menu = menu_bar.addMenu("Preferences")
|
||||
theme_menu = preferences_menu.addMenu("Theme")
|
||||
|
||||
# Container references for submenus and groups
|
||||
menus = {}
|
||||
submenus = {}
|
||||
theme_group = QActionGroup(self)
|
||||
theme_group.setExclusive(True)
|
||||
|
||||
# 4. Define actions for the submenu
|
||||
theme_actions = [
|
||||
("Auto", "", "auto", resource_path("icons/warning_off_24dp_1F1F1F.svg"), "theme_auto"),
|
||||
("Light", "", "light", resource_path("icons/warning_off_24dp_1F1F1F.svg"), "theme_light"),
|
||||
("Dark", "", "dark", resource_path("icons/warning_off_24dp_1F1F1F.svg"), "theme_dark"),
|
||||
]
|
||||
for item in MENU_BAR_SCHEMA:
|
||||
menu_name = item["menu"]
|
||||
if menu_name not in menus:
|
||||
menus[menu_name] = menu_bar.addMenu(menu_name)
|
||||
parent_menu = menus[menu_name]
|
||||
|
||||
for name, shortcut, mode, icon, config_key in theme_actions:
|
||||
# Use partial to pass 'mode' to self.theme_change_func on click
|
||||
slot = partial(self.theme_change_func, mode)
|
||||
# Handle Submenus (e.g. Preferences -> Theme/Processing/Updates)
|
||||
if "submenu" in item:
|
||||
sub_name = item["submenu"]
|
||||
sub_key = f"{menu_name}_{sub_name}"
|
||||
if sub_key not in submenus:
|
||||
submenus[sub_key] = parent_menu.addMenu(sub_name)
|
||||
parent_menu = submenus[sub_key]
|
||||
|
||||
action = make_action(name, shortcut, slot, icon=icon, checkable=True)
|
||||
theme_menu.addAction(action)
|
||||
# Handle Special Types (Separators & Dynamic Submenus)
|
||||
item_type = item.get("type")
|
||||
if item_type == "separator":
|
||||
parent_menu.addSeparator()
|
||||
continue
|
||||
elif item_type == "recent_files":
|
||||
self.recent_files_menu = parent_menu.addMenu("Recent Files")
|
||||
self.recent_files_menu.setIcon(QIcon(resource_path("icons/history_24dp_1F1F1F.svg")))
|
||||
parent_menu.addSeparator()
|
||||
continue
|
||||
elif item_type == "recent_projects":
|
||||
self.recent_projects_menu = parent_menu.addMenu("Recent Projects")
|
||||
self.recent_projects_menu.setIcon(QIcon(resource_path("icons/history_2_24dp_1F1F1F.svg")))
|
||||
parent_menu.addSeparator()
|
||||
continue
|
||||
|
||||
# Handle Normal/Preference/View Actions
|
||||
name = item["name"]
|
||||
shortcut = item.get("shortcut", "")
|
||||
key = item.get("key")
|
||||
|
||||
# If it's a preference toggle item without an explicit slot, bind it to _set_preference
|
||||
slot = item.get("slot")
|
||||
checkable = item.get("checkable", False)
|
||||
checked = item.get("checked", False)
|
||||
|
||||
if not slot and key and "theme_" not in key:
|
||||
slot = partial(self._set_preference, key)
|
||||
checkable = True
|
||||
checked = getattr(self, key, False)
|
||||
|
||||
action = make_action(name, shortcut, slot, checkable=checkable, checked=checked, icon=item.get("icon"))
|
||||
parent_menu.addAction(action)
|
||||
|
||||
# Register into appropriate tracking dictionaries
|
||||
if key == "statusbar":
|
||||
self.view_actions["statusbar"] = action
|
||||
elif item.get("group") == "theme":
|
||||
theme_group.addAction(action)
|
||||
self.pref_actions[key] = action
|
||||
elif key:
|
||||
self.pref_actions[key] = action
|
||||
|
||||
self.pref_actions[config_key] = action
|
||||
|
||||
# Set default selection (e.g., Auto)
|
||||
# Set default theme selection if available
|
||||
if "theme_auto" in self.pref_actions:
|
||||
self.pref_actions["theme_auto"].setChecked(True)
|
||||
|
||||
preferences_actions = [
|
||||
("2D Data Bypass", "", self.is_2d_bypass_func, resource_path("icons/warning_off_24dp_1F1F1F.svg"), "2d_data_bypass"),
|
||||
("Incompatible Save Bypass", "", self.incompatable_save_bypass_func, resource_path("icons/warning_off_24dp_1F1F1F.svg"), "incompatible_save_bypass"),
|
||||
("Missing Events Bypass", "", self.missing_events_bypass_func, resource_path("icons/warning_off_24dp_1F1F1F.svg"), "missing_events_bypass"),
|
||||
("Analysis Clearing Bypass", "", self.analysis_clearing_bypass_func, resource_path("icons/warning_off_24dp_1F1F1F.svg"), "analysis_clearing_bypass"),
|
||||
("Folding Bypass", "", self.folding_bypass_func, resource_path("icons/warning_off_24dp_1F1F1F.svg"), "folding_bypass"),
|
||||
("Show Advanced Parameters", "", self.advanced_parameters_func, resource_path("icons/warning_off_24dp_1F1F1F.svg"), "advanced_parameters"),
|
||||
]
|
||||
|
||||
for name, shortcut, slot, icon, config_key in preferences_actions:
|
||||
action = make_action(name, shortcut, slot, icon=icon, checkable=True)
|
||||
preferences_menu.addAction(action)
|
||||
self.pref_actions[config_key] = action
|
||||
|
||||
terminal_menu = menu_bar.addMenu("Terminal")
|
||||
terminal_actions = [
|
||||
("New Terminal", "Ctrl+Alt+T", self.terminal_gui, resource_path("icons/terminal_24dp_1F1F1F.svg")),
|
||||
]
|
||||
for name, shortcut, slot, icon in terminal_actions:
|
||||
terminal_menu.addAction(make_action(name, shortcut, slot, icon=icon))
|
||||
|
||||
self.sync_app_with_config()
|
||||
|
||||
self.statusbar.showMessage("Ready")
|
||||
|
||||
|
||||
def update_theme(self):
|
||||
text = self.label_desc.text()
|
||||
self.label_desc.setText("")
|
||||
@@ -977,7 +1140,7 @@ class MainApplication(QMainWindow):
|
||||
# self.update_window_title()
|
||||
|
||||
# Defensive dictionary lookup for the preference action
|
||||
pref_action = getattr(self, "pref_actions", {}).get("advanced_parameters")
|
||||
pref_action = getattr(self, "pref_actions", {}).get("ui_advanced_parameters")
|
||||
show_advanced = pref_action.isChecked() if pref_action is not None else False
|
||||
|
||||
# Toggle visibility on all built sections
|
||||
@@ -1171,6 +1334,7 @@ class MainApplication(QMainWindow):
|
||||
# for stat in stats[:10]:
|
||||
# print(stat)
|
||||
# print("Top 10 growing object types in RAM:")
|
||||
# objgraph has been removed so it will need to be reinstalled for this to work
|
||||
# objgraph.show_most_common_types(limit=10)
|
||||
|
||||
def check_if_app_is_dirty(self):
|
||||
@@ -1217,7 +1381,7 @@ class MainApplication(QMainWindow):
|
||||
data_map["roi_channel_map_dict"],
|
||||
data_map["fir_feature_dict"],
|
||||
data_map["qc_dict"],
|
||||
self.folding_bypass,
|
||||
self.processing_folding_bypass,
|
||||
]
|
||||
|
||||
self.launcher_window = ViewerLauncherWidget(*args)
|
||||
@@ -1235,19 +1399,25 @@ class MainApplication(QMainWindow):
|
||||
self.top_left_widget.paste() # Trigger paste
|
||||
self.statusbar.showMessage("Pasted from clipboard") # Show status message
|
||||
|
||||
def theme_change_func(self, mode):
|
||||
def theme_change_func(self, mode: str, checked: bool = True) -> None:
|
||||
"""Handles theme switching when toggled from the menu."""
|
||||
if not checked:
|
||||
return # Ignore the 'False' state when an option gets unchecked
|
||||
|
||||
app = QApplication.instance()
|
||||
style_hints = app.styleHints()
|
||||
|
||||
if mode == "auto":
|
||||
style_hints.setColorScheme(Qt.ColorScheme.Unknown)
|
||||
|
||||
elif mode == "light":
|
||||
style_hints.setColorScheme(Qt.ColorScheme.Light)
|
||||
|
||||
elif mode == "dark":
|
||||
style_hints.setColorScheme(Qt.ColorScheme.Dark)
|
||||
|
||||
# Save the change to your config file
|
||||
self.theme_mode = mode
|
||||
self._update_config_setting("Preferences", "theme_mode", mode)
|
||||
|
||||
def _update_config_setting(self, group, key, value):
|
||||
"""Helper to update memory configuration and save to disk."""
|
||||
# configparser expects string values
|
||||
@@ -1263,29 +1433,11 @@ class MainApplication(QMainWindow):
|
||||
self._update_config_setting("View", "status_bar", checked)
|
||||
self.statusbar.setVisible(checked)
|
||||
|
||||
def is_2d_bypass_func(self, checked):
|
||||
self.is_2d_bypass = checked
|
||||
self._update_config_setting("Preferences", "2d_data_bypass", checked)
|
||||
|
||||
def incompatable_save_bypass_func(self, checked):
|
||||
self.incompatible_save_bypass = checked
|
||||
self._update_config_setting("Preferences", "incompatible_save_bypass", checked)
|
||||
|
||||
def missing_events_bypass_func(self, checked):
|
||||
self.missing_events_bypass = checked
|
||||
self._update_config_setting("Preferences", "missing_events_bypass", checked)
|
||||
|
||||
def analysis_clearing_bypass_func(self, checked):
|
||||
self.analysis_clearing_bypass = checked
|
||||
self._update_config_setting("Preferences", "analysis_clearing_bypass", checked)
|
||||
|
||||
def folding_bypass_func(self, checked):
|
||||
self.folding_bypass = checked
|
||||
self._update_config_setting("Preferences", "folding_bypass", checked)
|
||||
|
||||
def advanced_parameters_func(self, checked):
|
||||
self.advanced_parameters = checked
|
||||
self._update_config_setting("Preferences", "advanced_parameters", checked)
|
||||
def _set_preference(self, config_key: str, checked: bool) -> None:
|
||||
"""Generic handler for updating boolean preferences and config settings."""
|
||||
setattr(self, config_key, checked)
|
||||
self._update_config_setting("Preferences", config_key, checked)
|
||||
if config_key == "ui_advanced_parameters":
|
||||
self.update_sections(0)
|
||||
|
||||
def about_window(self):
|
||||
@@ -1316,9 +1468,14 @@ class MainApplication(QMainWindow):
|
||||
QMessageBox.Ok
|
||||
)
|
||||
|
||||
def plugins_gui(self):
|
||||
if self.plugins is None or not self.plugins.isVisible():
|
||||
self.plugins = PluginsWindow(self, self.plugin_manager)
|
||||
self.plugins.show()
|
||||
|
||||
def terminal_gui(self):
|
||||
if self.terminal is None or not self.terminal.isVisible():
|
||||
self.terminal = TerminalWindow(self)
|
||||
self.terminal = TerminalWindow(self, self.plugin_manager)
|
||||
self.terminal.show()
|
||||
|
||||
def update_optode_positions(self):
|
||||
@@ -1386,32 +1543,65 @@ class MainApplication(QMainWindow):
|
||||
|
||||
|
||||
def sync_app_with_config(self):
|
||||
"""Reads values from file_cfg and updates both internal variables and UI checkmarks."""
|
||||
# 1. Sync internal application state variables
|
||||
self.is_2d_bypass = file_cfg.getboolean("Preferences", "2d_data_bypass", fallback=False)
|
||||
self.incompatible_save_bypass = file_cfg.getboolean("Preferences", "incompatible_save_bypass", fallback=False)
|
||||
self.missing_events_bypass = file_cfg.getboolean("Preferences", "missing_events_bypass", fallback=False)
|
||||
self.analysis_clearing_bypass = file_cfg.getboolean("Preferences", "analysis_clearing_bypass", fallback=False)
|
||||
self.folding_bypass = file_cfg.getboolean("Preferences", "folding_bypass", fallback=False)
|
||||
self.advanced_parameters = file_cfg.getboolean("Preferences", "advanced_parameters", fallback=False)
|
||||
"""Reads values from file_cfg and updates both internal variables and UI checkmarks,
|
||||
using fallbacks parsed directly from DEFAULT_CONFIG."""
|
||||
|
||||
self.show_status_bar = file_cfg.getboolean("View", "status_bar", fallback=False)
|
||||
self.main_h_ratio = file_cfg.getfloat("View", "main_h_ratio", fallback=26 / 45)
|
||||
self.left_v_ratio = file_cfg.getfloat("View", "left_v_ratio", fallback=0.30)
|
||||
# Parse DEFAULT_CONFIG to use as the single source of truth for fallbacks
|
||||
default_parser = configparser.ConfigParser()
|
||||
default_parser.read_string(DEFAULT_CONFIG)
|
||||
|
||||
# 2. Sync the UI Menu checkmarks visually
|
||||
if hasattr(self, 'pref_actions'):
|
||||
self.pref_actions["2d_data_bypass"].setChecked(self.is_2d_bypass)
|
||||
self.pref_actions["incompatible_save_bypass"].setChecked(self.incompatible_save_bypass)
|
||||
self.pref_actions["missing_events_bypass"].setChecked(self.missing_events_bypass)
|
||||
self.pref_actions["analysis_clearing_bypass"].setChecked(self.analysis_clearing_bypass)
|
||||
self.pref_actions["folding_bypass"].setChecked(self.folding_bypass)
|
||||
self.pref_actions["advanced_parameters"].setChecked(self.advanced_parameters)
|
||||
# Define mapping of sections, keys, and their expected type
|
||||
config_mappings = {
|
||||
"View": [
|
||||
("status_bar", "boolean"),
|
||||
("main_h_ratio", "float"),
|
||||
("left_v_ratio", "float")
|
||||
],
|
||||
"Preferences": [
|
||||
("theme_mode", "string"),
|
||||
("processing_2d_data_bypass", "boolean"),
|
||||
("processing_incompatible_save_bypass", "boolean"),
|
||||
("processing_missing_events_bypass", "boolean"),
|
||||
("processing_analysis_clearing_bypass", "boolean"),
|
||||
("processing_folding_bypass", "boolean"),
|
||||
("processing_separate_events", "boolean"),
|
||||
("ui_advanced_parameters", "boolean"),
|
||||
("updates_check_plugins_automatic", "boolean"),
|
||||
("updates_check_app_automatic", "boolean"),
|
||||
]
|
||||
}
|
||||
|
||||
if hasattr(self, 'view_actions'):
|
||||
self.view_actions["statusbar"].setChecked(self.show_status_bar)
|
||||
# 1. Sync internal state variables and UI checkmarks dynamically
|
||||
for section, items in config_mappings.items():
|
||||
for key, val_type in items:
|
||||
# Retrieve the exact fallback specified in DEFAULT_CONFIG
|
||||
if val_type == "boolean":
|
||||
fallback = default_parser.getboolean(section, key)
|
||||
val = file_cfg.getboolean(section, key, fallback=fallback)
|
||||
elif val_type == "float":
|
||||
fallback = default_parser.getfloat(section, key)
|
||||
val = file_cfg.getfloat(section, key, fallback=fallback)
|
||||
else:
|
||||
fallback = default_parser.get(section, key)
|
||||
val = file_cfg.get(section, key, fallback=fallback)
|
||||
|
||||
if self.advanced_parameters:
|
||||
# Set attribute on self
|
||||
setattr(self, key, val)
|
||||
|
||||
# Sync UI Menu checkmarks visually
|
||||
if key in self.pref_actions:
|
||||
self.pref_actions[key].setChecked(val)
|
||||
elif key == "status_bar" and "statusbar" in self.view_actions:
|
||||
self.view_actions["statusbar"].setChecked(val)
|
||||
|
||||
# 2. Sync Theme Radio Action Group based on theme_mode string (e.g., "auto", "light", "dark")
|
||||
theme_mode = getattr(self, "theme_mode", "auto")
|
||||
theme_action_key = f"theme_{theme_mode}"
|
||||
if theme_action_key in self.pref_actions:
|
||||
self.pref_actions[theme_action_key].setChecked(True)
|
||||
|
||||
# 3. Trigger dependent UI updates and handlers
|
||||
if getattr(self, "ui_advanced_parameters", False):
|
||||
self.update_sections(0)
|
||||
|
||||
if hasattr(self, 'recent_files_menu'):
|
||||
@@ -1421,6 +1611,7 @@ class MainApplication(QMainWindow):
|
||||
self.project_manager.update_recent_projects_menu()
|
||||
|
||||
QTimer.singleShot(0, self.apply_splitter_ratios)
|
||||
self.statusbar.showMessage("Ready")
|
||||
|
||||
|
||||
def save_splitter_ratios(self):
|
||||
@@ -1642,7 +1833,7 @@ class MainApplication(QMainWindow):
|
||||
bubble.update_progress(step, active=False)
|
||||
|
||||
# Add to layout
|
||||
self.bubble_layout.addWidget(bubble, index, 1)
|
||||
self.bubble_layout.addWidget(bubble, index, 0)
|
||||
|
||||
# 4. Status Bar
|
||||
msg = f"Project loaded: {len(file_list)} files."
|
||||
@@ -1746,6 +1937,7 @@ class MainApplication(QMainWindow):
|
||||
menu = QMenu(self)
|
||||
action1 = menu.addAction(QIcon(resource_path("icons/folder_eye_24dp_1F1F1F.svg")), "Reveal")
|
||||
action2 = menu.addAction(QIcon(resource_path("icons/remove_24dp_1F1F1F.svg")), "Remove")
|
||||
action3 = menu.addAction(QIcon(resource_path("icons/remove_24dp_1F1F1F.svg")), "View Logs")
|
||||
|
||||
action = menu.exec(global_pos)
|
||||
if action == action1:
|
||||
@@ -1831,6 +2023,36 @@ class MainApplication(QMainWindow):
|
||||
|
||||
self.check_files_dirty_state()
|
||||
|
||||
elif action == action3:
|
||||
self.open_log_window(bubble)
|
||||
|
||||
|
||||
def open_log_window(self, bubble):
|
||||
target_path = bubble.file_path
|
||||
log_content = getattr(self, "participant_logs", {}).get(
|
||||
target_path, "No logs generated yet for this participant."
|
||||
)
|
||||
|
||||
window_title = f"Logs — {os.path.basename(target_path)}"
|
||||
|
||||
if not hasattr(self, "active_log_windows"):
|
||||
self.active_log_windows = []
|
||||
self.active_log_windows = [
|
||||
w for w in self.active_log_windows if w.isVisible()
|
||||
]
|
||||
|
||||
# If a log window for this specific file is already open, focus it instead of opening a new one
|
||||
for win in self.active_log_windows:
|
||||
if getattr(win, "file_path", None) == target_path:
|
||||
win.raise_()
|
||||
win.activateWindow()
|
||||
return
|
||||
|
||||
log_win = LogWindow(window_title, target_path, log_content, self)
|
||||
log_win.show()
|
||||
self.active_log_windows.append(log_win)
|
||||
|
||||
|
||||
def check_files_dirty_state(self):
|
||||
"""Compares currently loaded files against the saved baseline list."""
|
||||
if not hasattr(self, "saved_selected_paths"):
|
||||
@@ -1942,7 +2164,7 @@ class MainApplication(QMainWindow):
|
||||
def on_run_task(self):
|
||||
|
||||
#do the check
|
||||
if not self.analysis_clearing_bypass:
|
||||
if not self.processing_analysis_clearing_bypass:
|
||||
if self.button3.isVisible():
|
||||
msg = QMessageBox(self)
|
||||
msg.setWindowTitle("Confirm - FLARES")
|
||||
@@ -2052,9 +2274,12 @@ class MainApplication(QMainWindow):
|
||||
for section_widget in self.param_sections:
|
||||
section_params = section_widget.get_param_values()
|
||||
all_params.update(section_params)
|
||||
if self.folding_bypass:
|
||||
if self.processing_folding_bypass:
|
||||
all_params['FOLDING_BYP'] = True
|
||||
|
||||
if self.processing_separate_events:
|
||||
all_params['SEPARATE_EVENTS'] = True
|
||||
|
||||
collected_data = {
|
||||
"SNIRF_FILES": snirf_files,
|
||||
"PARAMS": all_params, # add this line
|
||||
@@ -2148,8 +2373,15 @@ class MainApplication(QMainWindow):
|
||||
elapsed_str = self._format_elapsed(msg.get("elapsed", getattr(self, "_last_elapsed", 0)))
|
||||
speedup = msg.get("speedup")
|
||||
speedup_str = f" | Speedup: {speedup:.1f}x" if speedup else ""
|
||||
self.statusbar.showMessage(
|
||||
f"Complete: {success_count} succeeded, {fail_count} failed. | Total time: {elapsed_str}{speedup_str}"
|
||||
status_text = f"Complete: {success_count} succeeded, {fail_count} failed. | Total time: {elapsed_str}{speedup_str}"
|
||||
self.statusbar.showMessage(status_text)
|
||||
|
||||
if hasattr(self, "tray_icon") and self.tray_icon.isSystemTrayAvailable():
|
||||
self.tray_icon.showMessage(
|
||||
"Process Completed",
|
||||
status_text,
|
||||
QSystemTrayIcon.MessageIcon.Information,
|
||||
4000 # Duration in milliseconds
|
||||
)
|
||||
|
||||
if success_count > 0:
|
||||
@@ -2190,6 +2422,36 @@ class MainApplication(QMainWindow):
|
||||
_, file_path, step_index = msg
|
||||
self.progress_update_signal.emit(file_path, step_index)
|
||||
|
||||
elif isinstance(msg, tuple) and len(msg) == 3 and msg[0] == "log":
|
||||
_, file_path, text = msg
|
||||
if not hasattr(self, 'participant_logs'):
|
||||
self.participant_logs = {}
|
||||
if file_path not in self.participant_logs:
|
||||
self.participant_logs[file_path] = ""
|
||||
self.participant_logs[file_path] += text
|
||||
|
||||
# Push live update to any open log window for this file
|
||||
if hasattr(self, 'active_log_windows'):
|
||||
for win in self.active_log_windows:
|
||||
if getattr(win, 'file_path', None) == file_path:
|
||||
win.append_log(text)
|
||||
|
||||
elif isinstance(msg, dict) and msg.get("type") == "log":
|
||||
file_path = msg.get("file")
|
||||
text = msg.get("message")
|
||||
if file_path:
|
||||
if not hasattr(self, 'participant_logs'):
|
||||
self.participant_logs = {}
|
||||
if file_path not in self.participant_logs:
|
||||
self.participant_logs[file_path] = ""
|
||||
self.participant_logs[file_path] += text
|
||||
|
||||
# Push live update to any open log window for this file
|
||||
if hasattr(self, 'active_log_windows'):
|
||||
for win in self.active_log_windows:
|
||||
if getattr(win, 'file_path', None) == file_path:
|
||||
win.append_log(text)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error in timer loop: {e}")
|
||||
@@ -2746,12 +3008,13 @@ if __name__ == "__main__":
|
||||
log_path = os.path.join(os.path.dirname(sys.executable), f"../../../{APP_NAME}.log")
|
||||
cfg_path = os.path.join(os.path.dirname(sys.executable), f"../../../{APP_NAME}.cfg")
|
||||
else:
|
||||
log_path = os.path.join(os.getcwd(), f"{APP_NAME}.log")
|
||||
cfg_path = os.path.join(os.getcwd(), f"{APP_NAME}.cfg")
|
||||
log_path = os.path.join(get_app_dir(), f"{APP_NAME}.log")
|
||||
cfg_path = os.path.join(get_app_dir(), f"{APP_NAME}.cfg")
|
||||
try:
|
||||
if os.path.exists(log_path):
|
||||
os.remove(log_path)
|
||||
except:
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not remove old log file: {e}")
|
||||
|
||||
sys.stdout = open(log_path, "a", buffering=1)
|
||||
sys.stderr = sys.stdout
|
||||
@@ -2778,4 +3041,4 @@ if __name__ == "__main__":
|
||||
window.show()
|
||||
sys.exit(app.exec())
|
||||
|
||||
# Not 2600 lines yay!
|
||||
# Not 3100 lines yay!
|
||||
@@ -0,0 +1,49 @@
|
||||
Package Version Latest Type
|
||||
------------------------- -------- ------------ -----
|
||||
certifi 2026.1.4 2026.7.22 wheel
|
||||
charset-normalizer 3.4.4 3.5.1 wheel
|
||||
decorator 5.2.1 5.3.1 wheel
|
||||
fonttools 4.61.1 4.64.0 wheel
|
||||
h5py 3.15.1 3.16.0 wheel
|
||||
idna 3.11 3.19 wheel
|
||||
joblib 1.5.3 1.6.0 wheel
|
||||
kiwisolver 1.4.9 1.5.1 wheel
|
||||
lazy_loader 0.4 0.5 wheel
|
||||
lxml 6.0.2 6.1.2 wheel
|
||||
matplotlib 3.10.8 3.11.1 wheel
|
||||
mne 1.11.0 1.12.1 wheel
|
||||
mne-connectivity 0.7.0 0.9.0 wheel
|
||||
neurokit2 0.2.12 0.2.13 wheel
|
||||
nibabel 5.3.3 5.4.2 wheel
|
||||
nilearn 0.13.0 0.14.0 wheel
|
||||
numpy 2.4.1 2.5.2 wheel
|
||||
packaging 25.0 26.3 wheel
|
||||
pandas 2.3.3 3.0.5 wheel
|
||||
patsy 1.0.2 1.0.3 wheel
|
||||
pillow 12.1.0 12.3.0 wheel
|
||||
pip 25.3 26.2.1 wheel
|
||||
platformdirs 4.5.1 4.11.7 wheel
|
||||
pooch 1.8.2 1.9.0 wheel
|
||||
psutil 7.2.1 7.2.2 wheel
|
||||
pyinstaller 6.19.0 6.22.2 wheel
|
||||
pyinstaller-hooks-contrib 2026.3 2026.7 wheel
|
||||
pyparsing 3.3.1 3.3.2 wheel
|
||||
PySide6 6.10.1 6.11.2 wheel
|
||||
PySide6_Addons 6.10.1 6.11.2 wheel
|
||||
PySide6_Essentials 6.10.1 6.11.2 wheel
|
||||
pytz 2025.2 2026.3.post1 wheel
|
||||
pyvista 0.46.5 0.48.4 wheel
|
||||
pyvistaqt 0.11.3 0.12.0 wheel
|
||||
requests 2.32.5 2.34.2 wheel
|
||||
scikit-learn 1.8.0 1.9.0 wheel
|
||||
scipy 1.17.0 1.18.1 wheel
|
||||
scooby 0.11.0 0.11.2 wheel
|
||||
setuptools 82.0.1 84.0.0 wheel
|
||||
shiboken6 6.10.1 6.11.2 wheel
|
||||
statsmodels 0.14.6 0.15.0 wheel
|
||||
tqdm 4.67.1 4.70.0 wheel
|
||||
typing_extensions 4.15.0 4.16.0 wheel
|
||||
tzdata 2025.3 2026.3 wheel
|
||||
urllib3 2.6.3 2.7.0 wheel
|
||||
vtk 9.5.2 9.7.0 wheel
|
||||
xarray 2026.1.0 2026.7.0 wheel
|
||||
@@ -0,0 +1,426 @@
|
||||
"""
|
||||
Filename: plugin_manager.py
|
||||
Description: Manager file for anything plugin related
|
||||
Note: Compliant with pylance strict type checking
|
||||
|
||||
Author: Tyler de Zeeuw
|
||||
License: GPL-3.0
|
||||
"""
|
||||
|
||||
# Built-in imports
|
||||
import io
|
||||
import ssl
|
||||
import sys
|
||||
import json
|
||||
import shutil
|
||||
import zipfile
|
||||
import platform
|
||||
import urllib.request
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
# External library imports
|
||||
import certifi
|
||||
from PySide6.QtCore import QObject, QThread, QUrl, Signal
|
||||
from PySide6.QtGui import QDesktopServices
|
||||
from PySide6.QtWidgets import QMainWindow, QMenu, QMessageBox
|
||||
|
||||
from src.shared.shareddata import APP_NAME, CURRENT_VERSION, PLATFORM_NAME
|
||||
|
||||
|
||||
def get_ssl_context() -> ssl.SSLContext:
|
||||
"""Returns a cross-platform SSL context configured with certifi's CA bundle."""
|
||||
return ssl.create_default_context(cafile=certifi.where())
|
||||
|
||||
|
||||
def get_current_platform_id() -> str:
|
||||
"""Returns standardized platform identifier (win_x64, darwin_arm64, etc.)."""
|
||||
sys_name = sys.platform
|
||||
arch = platform.machine().lower()
|
||||
|
||||
if sys_name == "win32":
|
||||
return "win_x64" if "64" in arch or "amd64" in arch else "win_x86"
|
||||
elif sys_name == "darwin":
|
||||
return "darwin_arm64" if "arm" in arch or "aarch64" in arch else "darwin_x64"
|
||||
elif sys_name.startswith("linux"):
|
||||
return "linux_x64" if "64" in arch else "linux_x86"
|
||||
return sys_name
|
||||
|
||||
|
||||
def parse_version(v_str: str) -> tuple[int, ...]:
|
||||
"""Parses a version string into a comparable integer tuple."""
|
||||
try:
|
||||
return tuple(int(x) for x in v_str.strip().lstrip("v").split("."))
|
||||
except ValueError:
|
||||
return (0, 0, 0)
|
||||
|
||||
|
||||
class PluginManager(QObject):
|
||||
"""
|
||||
Handles plugin discovery, dynamic loading, menu building,
|
||||
and plugin lifecycle (install, toggle, uninstall).
|
||||
"""
|
||||
|
||||
plugins_changed = Signal()
|
||||
|
||||
def __init__(self, main_window: QMainWindow) -> None:
|
||||
super().__init__()
|
||||
self.main_window: QMainWindow = main_window
|
||||
self.plugins_dir: Path = self._resolve_plugins_dir()
|
||||
self.loaded_plugins: list[Any] = []
|
||||
self.current_platform: str = get_current_platform_id()
|
||||
self.current_app_version: tuple[int, ...] = parse_version(str(CURRENT_VERSION))
|
||||
|
||||
def _resolve_plugins_dir(self) -> Path:
|
||||
"""Determines the local plugins directory based on execution context."""
|
||||
if PLATFORM_NAME == "darwin":
|
||||
base_dir = Path(sys.executable).parent / "../../.."
|
||||
else:
|
||||
base_dir = Path.cwd()
|
||||
|
||||
plugins_path = (base_dir / "plugins").resolve()
|
||||
plugins_path.mkdir(parents=True, exist_ok=True)
|
||||
return plugins_path
|
||||
|
||||
|
||||
def reload_plugins(self) -> list[Any]:
|
||||
"""Scans directory, imports enabled plugins, and updates the main application menu."""
|
||||
self.unload_plugins()
|
||||
|
||||
plugins_dir_str = str(self.plugins_dir)
|
||||
if plugins_dir_str not in sys.path:
|
||||
sys.path.insert(0, plugins_dir_str)
|
||||
|
||||
failed_plugins: list[tuple[str, str]] = []
|
||||
|
||||
for entry in sorted(self.plugins_dir.iterdir(), key=lambda p: p.name.lower()):
|
||||
if entry.name.startswith((".", "__")) or entry.name.endswith(".disabled"):
|
||||
continue
|
||||
|
||||
module_name = ""
|
||||
init_file: Path | None = None
|
||||
|
||||
if entry.is_dir():
|
||||
init_file = entry / "__init__.py"
|
||||
module_name = entry.name
|
||||
elif entry.is_file() and entry.suffix == ".py":
|
||||
init_file = entry
|
||||
module_name = entry.stem
|
||||
|
||||
if not init_file or not init_file.exists():
|
||||
continue
|
||||
|
||||
try:
|
||||
instance = self._import_and_instantiate(module_name, init_file)
|
||||
if instance is not None:
|
||||
self.loaded_plugins.append(instance)
|
||||
except Exception as e:
|
||||
failed_plugins.append((module_name, str(e)))
|
||||
|
||||
# Report failures after all plugins have attempted to load
|
||||
if failed_plugins:
|
||||
error_details = "\n".join(f"• {name}: {err}" for name, err in failed_plugins)
|
||||
summary_msg = f"One or more plugins failed to load:\n\n{error_details}"
|
||||
|
||||
print(f"[PluginManager] {summary_msg}")
|
||||
|
||||
parent_widget = getattr(self, "main_window", None)
|
||||
QMessageBox.warning(
|
||||
parent_widget,
|
||||
"Plugin Load Failures",
|
||||
summary_msg
|
||||
)
|
||||
|
||||
self.build_plugins_menu()
|
||||
self.plugins_changed.emit()
|
||||
return self.loaded_plugins
|
||||
|
||||
def _import_and_instantiate(self, module_name: str, file_path: Path) -> Any | None:
|
||||
"""Dynamically imports a plugin module, attaches its metadata, and returns the Plugin instance."""
|
||||
spec = importlib.util.spec_from_file_location(module_name, file_path)
|
||||
if spec is None or spec.loader is None:
|
||||
raise ImportError(f"Invalid or missing spec for file: {file_path}")
|
||||
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[module_name] = module
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
if hasattr(module, "Plugin") and isinstance(module.Plugin, type):
|
||||
instance = module.Plugin(self.main_window)
|
||||
|
||||
# Attach manifest metadata directly to the plugin instance if plugin.json exists
|
||||
manifest_path = file_path.parent / "plugin.json"
|
||||
if manifest_path.exists():
|
||||
try:
|
||||
with open(manifest_path, "r", encoding="utf-8") as f:
|
||||
instance.metadata = json.load(f)
|
||||
# Override class name property if specified in manifest
|
||||
if "name" in instance.metadata:
|
||||
instance.name = instance.metadata["name"]
|
||||
except Exception as e:
|
||||
print(f"[PluginManager] Could not attach metadata to '{module_name}': {e}")
|
||||
|
||||
return instance
|
||||
|
||||
return None
|
||||
|
||||
def build_plugins_menu(self) -> None:
|
||||
"""Constructs or refreshes submenus under the top-level 'Plugins' menu item."""
|
||||
menubar = self.main_window.menuBar()
|
||||
|
||||
# Dynamically locate the existing 'Plugins' menu on the menubar
|
||||
plugins_menu: QMenu | None = None
|
||||
for action in menubar.actions():
|
||||
clean_text = action.text().replace("&", "").strip().lower()
|
||||
if clean_text == "plugins":
|
||||
menu = action.menu()
|
||||
if isinstance(menu, QMenu):
|
||||
plugins_menu = menu
|
||||
break
|
||||
|
||||
# If it doesn't exist yet, create it
|
||||
if plugins_menu is None:
|
||||
plugins_menu = menubar.addMenu("&Plugins")
|
||||
|
||||
plugins_menu.clear()
|
||||
|
||||
# 1. Populate loaded plugin submenus
|
||||
if not self.loaded_plugins:
|
||||
disabled_action = plugins_menu.addAction("No active plugins")
|
||||
disabled_action.setEnabled(False)
|
||||
else:
|
||||
for plugin in self.loaded_plugins:
|
||||
plugin_name = getattr(plugin, "name", "Unnamed Plugin")
|
||||
plugin_submenu = plugins_menu.addMenu(plugin_name)
|
||||
|
||||
if hasattr(plugin, "register_menu") and callable(plugin.register_menu):
|
||||
plugin.register_menu(plugin_submenu)
|
||||
|
||||
# 2. Append entry to open Plugin Manager GUI
|
||||
plugins_menu.addSeparator()
|
||||
manager_action = plugins_menu.addAction("Manage Plugins...")
|
||||
|
||||
plugins_gui_func = getattr(self.main_window, "plugins_gui", None)
|
||||
if callable(plugins_gui_func):
|
||||
manager_action.triggered.connect(plugins_gui_func)
|
||||
|
||||
def unload_plugins(self) -> None:
|
||||
"""Clears current active plugin instances."""
|
||||
self.loaded_plugins.clear()
|
||||
|
||||
def get_installed_plugins_info(self) -> list[dict[str, Any]]:
|
||||
"""Returns metadata for all local plugins in the plugins folder by reading their plugin.json."""
|
||||
results: list[dict[str, Any]] = []
|
||||
|
||||
for entry in sorted(self.plugins_dir.iterdir(), key=lambda p: p.name.lower()):
|
||||
if entry.name.startswith((".", "__")):
|
||||
continue
|
||||
|
||||
is_disabled = entry.name.endswith(".disabled")
|
||||
clean_name = entry.name[:-9] if is_disabled else entry.name
|
||||
|
||||
if entry.is_dir() or (entry.is_file() and (entry.suffix == ".py" or entry.name.endswith(".py.disabled"))):
|
||||
if entry.is_file() and entry.stem in ("__init__", "__init__.py"):
|
||||
continue
|
||||
|
||||
# Load local plugin.json if present
|
||||
manifest: dict[str, Any] = {}
|
||||
manifest_path = (entry / "plugin.json") if entry.is_dir() else None
|
||||
|
||||
if manifest_path and manifest_path.exists():
|
||||
try:
|
||||
with open(manifest_path, "r", encoding="utf-8") as f:
|
||||
manifest = json.load(f)
|
||||
except Exception as e:
|
||||
print(f"[PluginManager] Warning: Failed to read manifest for '{entry.name}': {e}")
|
||||
|
||||
display_name = manifest.get("name", clean_name.removesuffix(".py"))
|
||||
version = manifest.get("version", "1.0.0")
|
||||
description = manifest.get("description", "")
|
||||
author = manifest.get("author", "Unknown")
|
||||
|
||||
results.append({
|
||||
"id": manifest.get("id", clean_name),
|
||||
"name": display_name,
|
||||
"version": version,
|
||||
"description": description,
|
||||
"author": author,
|
||||
"path": entry,
|
||||
"is_disabled": is_disabled,
|
||||
"manifest": manifest,
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
def toggle_plugin_state(self, plugin_path: Path) -> None:
|
||||
"""Swaps a plugin between enabled and disabled by renaming with/without '.disabled'."""
|
||||
if not plugin_path.exists():
|
||||
return
|
||||
|
||||
if plugin_path.name.endswith(".disabled"):
|
||||
new_path = plugin_path.with_name(plugin_path.name.removesuffix(".disabled"))
|
||||
else:
|
||||
new_path = plugin_path.with_name(f"{plugin_path.name}.disabled")
|
||||
|
||||
plugin_path.rename(new_path)
|
||||
self.reload_plugins()
|
||||
|
||||
def uninstall_plugin(self, plugin_path: Path) -> None:
|
||||
"""Deletes a plugin directory or file from disk."""
|
||||
if not plugin_path.exists():
|
||||
return
|
||||
|
||||
if plugin_path.is_dir():
|
||||
shutil.rmtree(plugin_path)
|
||||
else:
|
||||
plugin_path.unlink()
|
||||
|
||||
self.reload_plugins()
|
||||
|
||||
def fetch_remote_repositories(self, repo_urls: list[str]) -> tuple[list[dict[str, Any]], bool]:
|
||||
"""Fetches remote plugin metadata from repository URLs."""
|
||||
aggregated: list[dict[str, Any]] = []
|
||||
failed_count = 0
|
||||
|
||||
for url in repo_urls:
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
headers={"User-Agent": f"{APP_NAME}-PluginManager"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=5, context=get_ssl_context()) as response:
|
||||
if response.status == 200:
|
||||
raw_data = response.read().decode("utf-8")
|
||||
data = json.loads(raw_data)
|
||||
if isinstance(data, list):
|
||||
aggregated.extend(cast(list[Any], data))
|
||||
else:
|
||||
failed_count += 1
|
||||
except Exception:
|
||||
failed_count += 1
|
||||
|
||||
all_failed = failed_count == len(repo_urls) and len(repo_urls) > 0
|
||||
return aggregated, all_failed
|
||||
|
||||
def install_plugin_from_url(self, download_url: str, plugin_id: str = "") -> Path:
|
||||
"""
|
||||
Downloads a single-folder ZIP archive and extracts it directly into plugins/.
|
||||
"""
|
||||
req = urllib.request.Request(
|
||||
download_url,
|
||||
headers={"User-Agent": f"{APP_NAME}-PluginManager"},
|
||||
)
|
||||
|
||||
with urllib.request.urlopen(req, timeout=15, context=get_ssl_context()) as response:
|
||||
if response.status != 200:
|
||||
raise RuntimeError(f"Download failed with HTTP status code {response.status}")
|
||||
|
||||
zip_bytes = response.read()
|
||||
|
||||
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zip_ref:
|
||||
zip_ref.extractall(self.plugins_dir)
|
||||
|
||||
self.reload_plugins()
|
||||
return self.plugins_dir
|
||||
|
||||
def open_plugins_directory(self) -> bool:
|
||||
"""Opens the local plugins folder in the operating system's file manager."""
|
||||
if self.plugins_dir.exists():
|
||||
return QDesktopServices.openUrl(QUrl.fromLocalFile(str(self.plugins_dir)))
|
||||
return False
|
||||
|
||||
def check_for_updates(self, repo_urls: list[str]) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Fetches remote repositories and compares server plugin versions
|
||||
against installed plugins, returning the installed plugins list
|
||||
enriched with update metadata.
|
||||
"""
|
||||
installed_plugins = self.get_installed_plugins_info()
|
||||
remote_data, _ = self.fetch_remote_repositories(repo_urls)
|
||||
|
||||
# Index remote plugins by their ID (fallback to name if id is missing)
|
||||
remote_map: dict[str, dict[str, Any]] = {}
|
||||
for remote_plugin in remote_data:
|
||||
plugin_id = remote_plugin.get("id") or remote_plugin.get("name")
|
||||
if plugin_id:
|
||||
remote_map[str(plugin_id)] = remote_plugin
|
||||
|
||||
# Compare versions and annotate each installed plugin dictionary
|
||||
for plugin in installed_plugins:
|
||||
plugin_id = str(plugin.get("id", ""))
|
||||
local_version_str = str(plugin.get("version", "0.0.0"))
|
||||
local_tuple = parse_version(local_version_str)
|
||||
|
||||
remote_plugin = remote_map.get(plugin_id)
|
||||
if remote_plugin:
|
||||
remote_version_str = str(remote_plugin.get("version", "0.0.0"))
|
||||
remote_tuple = parse_version(remote_version_str)
|
||||
|
||||
if remote_tuple > local_tuple:
|
||||
plugin["update_available"] = True
|
||||
plugin["remote_version"] = remote_version_str
|
||||
plugin["download_url"] = str(remote_plugin.get("download_url", ""))
|
||||
else:
|
||||
plugin["update_available"] = False
|
||||
plugin["remote_version"] = remote_version_str
|
||||
plugin["download_url"] = ""
|
||||
else:
|
||||
plugin["update_available"] = False
|
||||
plugin["remote_version"] = None
|
||||
plugin["download_url"] = ""
|
||||
|
||||
return installed_plugins
|
||||
|
||||
|
||||
class StartupUpdateWorker(QThread):
|
||||
updates_checked = Signal(list)
|
||||
|
||||
def __init__(self, manager: Any, repo_urls: list[str]) -> None:
|
||||
super().__init__()
|
||||
self.manager = manager
|
||||
self.repo_urls = repo_urls
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
print("[StartupWorker] Starting background update check...")
|
||||
installed_plugins = self.manager.get_installed_plugins_info()
|
||||
print(f"[StartupWorker] Found {len(installed_plugins)} installed local plugins.")
|
||||
|
||||
remote_data, all_failed = self.manager.fetch_remote_repositories(self.repo_urls)
|
||||
print(f"[StartupWorker] Fetch remote complete. Failed: {all_failed}, Items fetched: {len(remote_data)}")
|
||||
|
||||
if all_failed or not remote_data:
|
||||
print("[StartupWorker] Aborting: Remote fetch failed or returned empty data.")
|
||||
return
|
||||
|
||||
remote_map: dict[str, dict[str, Any]] = {}
|
||||
for raw_plugin in remote_data:
|
||||
if isinstance(raw_plugin, dict):
|
||||
remote_plugin = cast(dict[str, Any], raw_plugin)
|
||||
|
||||
plugin_id = remote_plugin.get("id") or remote_plugin.get("name")
|
||||
if isinstance(plugin_id, str):
|
||||
remote_map[plugin_id] = remote_plugin
|
||||
|
||||
outdated_plugins: list[dict[str, Any]] = []
|
||||
for plugin in installed_plugins:
|
||||
plugin_id = str(plugin.get("id", ""))
|
||||
local_version_str = str(plugin.get("version", "0.0.0"))
|
||||
local_tuple = parse_version(local_version_str)
|
||||
|
||||
remote_plugin = remote_map.get(plugin_id)
|
||||
if remote_plugin:
|
||||
remote_version_str = str(remote_plugin.get("version", "0.0.0"))
|
||||
remote_tuple = parse_version(remote_version_str)
|
||||
print(f"[StartupWorker] Comparing '{plugin.get('name')}': Local v{local_version_str} vs Remote v{remote_version_str}")
|
||||
|
||||
if remote_tuple > local_tuple:
|
||||
plugin["remote_version"] = remote_version_str
|
||||
plugin["download_url"] = str(remote_plugin.get("download_url", ""))
|
||||
outdated_plugins.append(plugin)
|
||||
|
||||
print(f"[StartupWorker] Outdated plugins found: {len(outdated_plugins)}")
|
||||
self.updates_checked.emit(outdated_plugins)
|
||||
except Exception as e:
|
||||
print(f"[StartupWorker] Error during startup update check: {e}")
|
||||
+175
-18
@@ -15,6 +15,7 @@ import copy
|
||||
import pickle
|
||||
import concurrent
|
||||
import configparser
|
||||
import concurrent.futures
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import TYPE_CHECKING, Any, List, Optional, Union
|
||||
|
||||
@@ -37,6 +38,23 @@ if TYPE_CHECKING:
|
||||
from main import MainApplication
|
||||
|
||||
|
||||
class LoadProjectThread(QThread):
|
||||
finished_signal = Signal(object) # Emits the loaded project data dictionary
|
||||
error_signal = Signal(str) # Emits the error message string
|
||||
|
||||
def __init__(self, filename: str) -> None:
|
||||
super().__init__()
|
||||
self.filename = filename
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
with open(self.filename, "rb") as f:
|
||||
project_data = pickle.load(f)
|
||||
self.finished_signal.emit(project_data)
|
||||
except Exception as e:
|
||||
self.error_signal.emit(str(e))
|
||||
|
||||
|
||||
class SaveProjectThread(QThread):
|
||||
finished_signal = Signal(str)
|
||||
error_signal = Signal(str)
|
||||
@@ -55,6 +73,25 @@ class SaveProjectThread(QThread):
|
||||
self.error_signal.emit(str(e))
|
||||
|
||||
|
||||
class LoadingOverlay(QDialog):
|
||||
def __init__(self, parent: Optional[QWidget] = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.FramelessWindowHint)
|
||||
self.setModal(True)
|
||||
self.setWindowModality(Qt.WindowModality.ApplicationModal)
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
||||
|
||||
layout = QVBoxLayout()
|
||||
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
label = QLabel("Loading Project…")
|
||||
label.setStyleSheet(
|
||||
"font-size: 18px; color: white; background-color: rgba(0,0,0,150);"
|
||||
" padding: 20px; border-radius: 10px;"
|
||||
)
|
||||
layout.addWidget(label)
|
||||
self.setLayout(layout)
|
||||
|
||||
|
||||
class SavingOverlay(QDialog):
|
||||
def __init__(self, parent: Optional[QWidget] = None) -> None:
|
||||
@@ -107,21 +144,118 @@ class ProjectManager:
|
||||
# =========================================================================
|
||||
# File & Folder Opening Dialogs
|
||||
# =========================================================================
|
||||
|
||||
def is_valid_snirf(self, path: str) -> bool:
|
||||
"""Fast header check to verify HDF5/SNIRF signature and ignore corrupt/shadow files."""
|
||||
try:
|
||||
if os.path.getsize(path) < 8:
|
||||
return False
|
||||
with open(path, "rb") as f:
|
||||
return f.read(8) == b"\x89HDF\r\n\x1a\n"
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def open_file_dialog(self) -> None:
|
||||
"""Opens dialog to pick a single .snirf file."""
|
||||
file_path, _ = QFileDialog.getOpenFileName(
|
||||
self.app, "Open File", "", "SNIRF Files (*.snirf);;All Files (*)"
|
||||
)
|
||||
if file_path:
|
||||
self._load_files_into_pipeline([os.path.normpath(file_path)])
|
||||
if not file_path:
|
||||
return
|
||||
|
||||
norm_path = os.path.normpath(file_path)
|
||||
filename = os.path.basename(norm_path)
|
||||
|
||||
# 1. Check specifically for macOS shadow files
|
||||
if filename.startswith("._"):
|
||||
QMessageBox.warning(
|
||||
self.app,
|
||||
"Invalid SNIRF File",
|
||||
f"'{filename}' is a macOS system shadow file (Apple Double resource fork), not a actual data file."
|
||||
)
|
||||
return
|
||||
|
||||
# 2. Check for general header validity / corruption
|
||||
if not self.is_valid_snirf(norm_path):
|
||||
QMessageBox.warning(
|
||||
self.app,
|
||||
"Invalid SNIRF File",
|
||||
f"'{filename}' is not a valid SNIRF file or has a corrupted header."
|
||||
)
|
||||
return
|
||||
|
||||
self._load_files_into_pipeline([norm_path])
|
||||
|
||||
def open_folder_dialog(self)-> None:
|
||||
"""Recursively finds all .snirf files in a selected directory."""
|
||||
folder_path = QFileDialog.getExistingDirectory(self.app, "Select Folder", "")
|
||||
if folder_path:
|
||||
snirf_files = [os.path.normpath(str(f)) for f in Path(folder_path).rglob("*.snirf")]
|
||||
if not folder_path:
|
||||
return
|
||||
|
||||
# Automatically filter out shadow files and invalid headers in batch mode
|
||||
snirf_files = [
|
||||
os.path.normpath(str(f))
|
||||
for f in Path(folder_path).rglob("*.snirf")
|
||||
if self.is_valid_snirf(str(f))
|
||||
]
|
||||
|
||||
if not snirf_files:
|
||||
QMessageBox.information(
|
||||
self.app,
|
||||
"No Valid Files",
|
||||
"No valid .snirf files were found in the selected directory."
|
||||
)
|
||||
return
|
||||
|
||||
self._load_files_into_pipeline(snirf_files)
|
||||
|
||||
def load_dropped_files(self, file_paths: List[str]) -> None:
|
||||
"""Public entry point for handling files or folders dropped onto the UI."""
|
||||
if not file_paths:
|
||||
return
|
||||
|
||||
# 1. Single file drop: Give explicit feedback like open_file_dialog
|
||||
if len(file_paths) == 1:
|
||||
norm_path = os.path.normpath(file_paths[0])
|
||||
filename = os.path.basename(norm_path)
|
||||
|
||||
if filename.startswith("._"):
|
||||
QMessageBox.warning(
|
||||
self.app,
|
||||
"Invalid SNIRF File",
|
||||
f"'{filename}' is a macOS system shadow file (Apple Double resource fork), not an actual data file."
|
||||
)
|
||||
return
|
||||
|
||||
if not self.is_valid_snirf(norm_path):
|
||||
QMessageBox.warning(
|
||||
self.app,
|
||||
"Invalid SNIRF File",
|
||||
f"'{filename}' is not a valid SNIRF file or has a corrupted header."
|
||||
)
|
||||
return
|
||||
|
||||
valid_files = [norm_path]
|
||||
|
||||
# 2. Batch drop: Silently filter out shadow/corrupt files like open_folder_dialog
|
||||
else:
|
||||
valid_files = [
|
||||
os.path.normpath(p)
|
||||
for p in file_paths
|
||||
if self.is_valid_snirf(os.path.normpath(p))
|
||||
]
|
||||
|
||||
if not valid_files:
|
||||
QMessageBox.information(
|
||||
self.app,
|
||||
"No Valid Files",
|
||||
"None of the dropped items were valid .snirf files."
|
||||
)
|
||||
return
|
||||
|
||||
# Delegate to internal pipeline
|
||||
self._load_files_into_pipeline(valid_files)
|
||||
|
||||
def _load_files_into_pipeline(self, file_paths: List[str]) -> None:
|
||||
"""Loads .snirf files into UI using chunked batches and background workers."""
|
||||
app = self.app
|
||||
@@ -135,7 +269,6 @@ class ProjectManager:
|
||||
# 2. Track this session to prevent ghost updates
|
||||
if not hasattr(app, "loading_session_id"):
|
||||
app.loading_session_id = 0
|
||||
app.loading_session_id += 1
|
||||
current_session = app.loading_session_id
|
||||
|
||||
# 3. Setup internal tracking if not exists
|
||||
@@ -146,8 +279,11 @@ class ProjectManager:
|
||||
if not hasattr(app, "metadata_cache"):
|
||||
app.metadata_cache = {}
|
||||
|
||||
# Filter out files already in the UI to avoid duplicates
|
||||
new_files = [p for p in file_paths if p not in app.selected_paths]
|
||||
# Filter out duplicates AND non-SNIRF files (including macOS ._ shadow files)
|
||||
new_files = [
|
||||
p for p in file_paths
|
||||
if p not in app.selected_paths and self.is_valid_snirf(p)
|
||||
]
|
||||
if not new_files:
|
||||
return
|
||||
|
||||
@@ -241,12 +377,31 @@ class ProjectManager:
|
||||
self.load_project(filename)
|
||||
|
||||
def load_project(self, filename: str) -> None:
|
||||
"""Loads a .flare project file into the application."""
|
||||
"""Triggers asynchronous loading of a .flare project file."""
|
||||
app = self.app
|
||||
|
||||
# 1. Show the custom loading overlay matching your saver design
|
||||
self.loading_overlay = LoadingOverlay(app)
|
||||
self.loading_overlay.resize(app.size())
|
||||
self.loading_overlay.move(app.pos())
|
||||
self.loading_overlay.show()
|
||||
|
||||
# 2. Fire up the background thread for pickle loading
|
||||
self.loader_thread = LoadProjectThread(filename)
|
||||
self.loader_thread.finished_signal.connect(
|
||||
lambda data: self._handle_loaded_data(filename, data)
|
||||
)
|
||||
self.loader_thread.error_signal.connect(self._handle_load_error)
|
||||
self.loader_thread.start()
|
||||
|
||||
def _handle_loaded_data(self, filename: str, data: dict) -> None:
|
||||
# Close overlay
|
||||
if hasattr(self, "loading_overlay"):
|
||||
self.loading_overlay.close()
|
||||
self.loading_overlay.deleteLater()
|
||||
|
||||
app = self.app
|
||||
try:
|
||||
with open(filename, "rb") as f:
|
||||
data = pickle.load(f)
|
||||
|
||||
checks = [
|
||||
("version", "<=1.1.7"),
|
||||
("file_metadata", "<=1.2.2"),
|
||||
@@ -260,7 +415,7 @@ class ProjectManager:
|
||||
f"This project was saved in an earlier version of {APP_NAME.upper()} ({ver_str}) "
|
||||
"and is potentially not compatible with this version. "
|
||||
)
|
||||
if getattr(app, "incompatible_save_bypass", False):
|
||||
if getattr(app, "processing_incompatible_save_bypass", False):
|
||||
QMessageBox.warning(
|
||||
app, f"Warning - {APP_NAME.upper()}", msg + "Attempting load."
|
||||
)
|
||||
@@ -340,16 +495,19 @@ class ProjectManager:
|
||||
if hasattr(app, "button3"):
|
||||
app.button3.setVisible(has_data)
|
||||
|
||||
|
||||
self.add_to_recent_projects(os.path.normpath(filename))
|
||||
|
||||
# Reset baselines cleanly
|
||||
self.reset_all_dirty_states()
|
||||
|
||||
QMessageBox.information(app, "Loaded", f"Project loaded from:\n{filename}")
|
||||
|
||||
except Exception as e:
|
||||
QMessageBox.critical(app, "Error", f"Failed to load project:\n{e}")
|
||||
QMessageBox.critical(app, "Error", f"Failed to process loaded project data:\n{e}")
|
||||
|
||||
def _handle_load_error(self, error_msg: str) -> None:
|
||||
if hasattr(self, "loading_overlay"):
|
||||
self.loading_overlay.close()
|
||||
self.loading_overlay.deleteLater()
|
||||
QMessageBox.critical(self.app, "Error", f"Failed to read project file:\n{error_msg}")
|
||||
|
||||
# =========================================================================
|
||||
# Project Saving (Save / Save As)
|
||||
@@ -719,7 +877,7 @@ def _get_bids_demographics(snirf_path: str) -> dict[str, str]:
|
||||
return {}
|
||||
|
||||
def _row_to_dict(row: pd.Series) -> dict[str, str]:
|
||||
result = {}
|
||||
result: dict[str, str] = {}
|
||||
for field in fields:
|
||||
if field not in row:
|
||||
continue
|
||||
@@ -765,7 +923,6 @@ def _get_bids_demographics(snirf_path: str) -> dict[str, str]:
|
||||
return {}
|
||||
|
||||
|
||||
|
||||
def extract_metadata_worker(file_name: str) -> dict[str, Any]:
|
||||
"""Runs in the separate worker process. Returns a clean dict."""
|
||||
|
||||
|
||||
+6
-7
@@ -1,7 +1,6 @@
|
||||
src\analysis\participantfoldchannels.py 158
|
||||
src\shared\flaresbasewidget.py 1001+
|
||||
src\window\updateevents.py 83
|
||||
flares.py 1001+
|
||||
main_unit_tests.py 153
|
||||
main.py 691
|
||||
project_manager.py 113
|
||||
src\analysis\participantfoldchannels.py 84
|
||||
src\shared\flaresbasewidget.py 1155
|
||||
flares.py 2900
|
||||
main_unit_tests.py 152
|
||||
main.py 732
|
||||
project_manager.py 109
|
||||
Binary file not shown.
+58
-56
@@ -12,6 +12,7 @@ import os
|
||||
from typing import Any
|
||||
|
||||
# External library imports
|
||||
from mne import Epochs
|
||||
from pandas import DataFrame
|
||||
|
||||
from mne.io.base import BaseRaw
|
||||
@@ -27,6 +28,7 @@ class ExportToCSVWidget(CSVUIMixin, FlaresBaseWidget):
|
||||
def __init__(
|
||||
self,
|
||||
haemo_dict: dict[str, BaseRaw],
|
||||
epochs_dict: dict[str, Epochs],
|
||||
cha_dict: dict[str, DataFrame],
|
||||
df_ind_dict: dict[str, DataFrame],
|
||||
design_matrix_dict: dict[str, DataFrame],
|
||||
@@ -40,6 +42,7 @@ class ExportToCSVWidget(CSVUIMixin, FlaresBaseWidget):
|
||||
super().__init__("ExportToCSV")
|
||||
self.setWindowTitle(f"Export To CSV Viewer - {APP_NAME.upper()}")
|
||||
self.haemo_dict = haemo_dict
|
||||
self.epochs_dict = epochs_dict
|
||||
self.cha_dict = cha_dict
|
||||
self.df_ind_dict = df_ind_dict
|
||||
self.design_matrix = design_matrix_dict
|
||||
@@ -50,8 +53,7 @@ class ExportToCSVWidget(CSVUIMixin, FlaresBaseWidget):
|
||||
self.qc_dict = qc_dict
|
||||
|
||||
self.setup_csv_ui(["0 (Export Data to CSV)", "1 (CSV for SPARKS)", "2 (Export Configuration to CSV)", "3 (Paragraph of Configuration)", "4 (Export FIR Waveform Features to CSV)",
|
||||
"5 (Export Quality Control Metrics to CSV)",
|
||||
"6 (Export Master Consolidated Matrix [All Participants into 1 CSV])"
|
||||
"5 (Export Quality Control Metrics to CSV)", "6 (Export Master Consolidated Matrix [All Participants into 1 CSV])"
|
||||
])
|
||||
|
||||
|
||||
@@ -120,67 +122,67 @@ class ExportToCSVWidget(CSVUIMixin, FlaresBaseWidget):
|
||||
magic_string = self.gen_magic_str(first_params)
|
||||
self.placeholder_label.setText(magic_string)
|
||||
|
||||
# elif idx == 4:
|
||||
# # FIR Waveform Features Export
|
||||
# fir_data = self.fir_feature_dict.get(file_path)
|
||||
# if fir_data and isinstance(fir_data, dict):
|
||||
# names = fir_data.get("feature_names", [])
|
||||
# vals = fir_data.get("features", [])
|
||||
# chans = fir_data.get("feature_channels", [])
|
||||
elif idx == 4:
|
||||
# FIR Waveform Features Export
|
||||
fir_data = self.fir_feature_dict.get(file_path)
|
||||
if fir_data:
|
||||
names = fir_data.get("feature_names", [])
|
||||
vals = fir_data.get("features", [])
|
||||
chans = fir_data.get("feature_channels", [])
|
||||
|
||||
# fir_df = DataFrame({
|
||||
# "Feature_Name": names,
|
||||
# "Channel": chans if len(chans) == len(names) else ["N/A"] * len(names),
|
||||
# "Value": vals
|
||||
# })
|
||||
# save_path = os.path.join(output_dir, f"{base_filename}_fir_features.csv")
|
||||
# fir_df.to_csv(save_path, index=False)
|
||||
# success_count += 1
|
||||
fir_df = DataFrame({
|
||||
"Feature_Name": names,
|
||||
"Channel": chans if len(chans) == len(names) else ["N/A"] * len(names),
|
||||
"Value": vals
|
||||
})
|
||||
save_path = os.path.join(output_dir, f"{base_filename}_fir_features.csv")
|
||||
fir_df.to_csv(save_path, index=False)
|
||||
success_count += 1
|
||||
|
||||
# elif idx == 5:
|
||||
# # Quality Control (QC) Metrics Export
|
||||
# qc_data = self.qc_dict.get(file_path)
|
||||
# if qc_data and isinstance(qc_data, dict):
|
||||
# qc_df = DataFrame(list(qc_data.items()), columns=["Metric", "Value"])
|
||||
# save_path = os.path.join(output_dir, f"{base_filename}_qc_metrics.csv")
|
||||
# qc_df.to_csv(save_path, index=False)
|
||||
# success_count += 1
|
||||
elif idx == 5:
|
||||
# Quality Control (QC) Metrics Export
|
||||
qc_data = self.qc_dict.get(file_path)
|
||||
if qc_data and isinstance(qc_data, dict):
|
||||
qc_df = DataFrame(list(qc_data.items()), columns=["Metric", "Value"])
|
||||
save_path = os.path.join(output_dir, f"{base_filename}_qc_metrics.csv")
|
||||
qc_df.to_csv(save_path, index=False)
|
||||
success_count += 1
|
||||
|
||||
# elif idx == 6:
|
||||
# # Master Consolidated Matrix (1 single CSV combining all selected participants)
|
||||
# save_path = os.path.join(output_dir, f"{APP_NAME}_master_consolidated.csv")
|
||||
# if not os.path.exists(save_path):
|
||||
# master_rows: list[dict[str, Any]] = []
|
||||
# for fp in selected_file_paths:
|
||||
# abs_path = os.path.abspath(fp)
|
||||
# grp = self.group_dict.get(fp, "Unknown")
|
||||
# row: dict[str, Any] = {"Participant": abs_path, "Group": grp}
|
||||
elif idx == 6:
|
||||
# Master Consolidated Matrix (1 single CSV combining all selected participants)
|
||||
save_path = os.path.join(output_dir, f"{APP_NAME}_master_consolidated.csv")
|
||||
if not os.path.exists(save_path):
|
||||
master_rows: list[dict[str, Any]] = []
|
||||
for fp in selected_file_paths:
|
||||
abs_path = os.path.abspath(fp)
|
||||
grp = self.group_dict.get(fp, "Unknown")
|
||||
row: dict[str, Any] = {"Participant": abs_path, "Group": grp}
|
||||
|
||||
# # QC Metrics
|
||||
# qc_info = self.qc_dict.get(fp, {})
|
||||
# if isinstance(qc_info, dict):
|
||||
# for mk, mv in qc_info.items():
|
||||
# row[f"QC_{mk}"] = mv
|
||||
# QC Metrics
|
||||
qc_info = self.qc_dict.get(fp, {})
|
||||
if isinstance(qc_info, dict):
|
||||
for mk, mv in qc_info.items():
|
||||
row[f"QC_{mk}"] = mv
|
||||
|
||||
# # FIR Features
|
||||
# fir_info = self.fir_feature_dict.get(fp, {})
|
||||
# print("1")
|
||||
# if isinstance(fir_info, dict):
|
||||
# print("2")
|
||||
# f_names = fir_info.get("feature_names", [])
|
||||
# f_vals = fir_info.get("features", [])
|
||||
# print("3")
|
||||
# if len(f_names) == len(f_vals):
|
||||
# print("4")
|
||||
# for fn, fv in zip(f_names, f_vals):
|
||||
# row[f"FIR_{fn}"] = fv
|
||||
# FIR Features
|
||||
fir_info = self.fir_feature_dict.get(fp, {})
|
||||
print("1")
|
||||
if isinstance(fir_info, dict):
|
||||
print("2")
|
||||
f_names = fir_info.get("feature_names", [])
|
||||
f_vals = fir_info.get("features", [])
|
||||
print("3")
|
||||
if len(f_names) == len(f_vals):
|
||||
print("4")
|
||||
for fn, fv in zip(f_names, f_vals):
|
||||
row[f"FIR_{fn}"] = fv
|
||||
|
||||
# master_rows.append(row)
|
||||
master_rows.append(row)
|
||||
|
||||
# if master_rows:
|
||||
# df_master = DataFrame(master_rows)
|
||||
# df_master.to_csv(save_path, index=False)
|
||||
# success_count += 1
|
||||
if master_rows:
|
||||
df_master = DataFrame(master_rows)
|
||||
df_master.to_csv(save_path, index=False)
|
||||
success_count += 1
|
||||
|
||||
else:
|
||||
print(f"No method defined for index {idx}")
|
||||
|
||||
@@ -211,7 +211,6 @@ class IntraGroupStatsWidget(IntraGroupUIMixin, FlaresBaseWidget):
|
||||
continue
|
||||
|
||||
|
||||
|
||||
cha_df = self.cha_dict.get(file_path)
|
||||
if cha_df is not None:
|
||||
all_cha = pd.concat([all_cha, cha_df], ignore_index=True)
|
||||
|
||||
@@ -8,29 +8,31 @@ License: GPL-3.0
|
||||
|
||||
# Built-in Imports
|
||||
import os
|
||||
from pathlib import Path
|
||||
import time
|
||||
import traceback
|
||||
|
||||
from pathlib import Path
|
||||
from multiprocessing import Process, current_process, Manager
|
||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
|
||||
|
||||
# External library imports
|
||||
from matplotlib.backend_bases import Event
|
||||
import numpy as np
|
||||
from pandas import DataFrame
|
||||
|
||||
import matplotlib.pyplot as plt
|
||||
import matplotlib.image as mpimg
|
||||
from matplotlib.figure import Figure
|
||||
from matplotlib.backend_bases import Event
|
||||
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
|
||||
|
||||
from PySide6.QtWidgets import QFrame, QGridLayout, QHBoxLayout, QLabel, QLayout, QProgressBar, QPushButton, QScrollArea, QSizePolicy, QWidget, QDialog, QVBoxLayout
|
||||
from PySide6.QtCore import QThread, Qt, QSize, QTimer, QObject, Signal
|
||||
from PySide6.QtGui import QCloseEvent, QMouseEvent, QPixmap, QImage
|
||||
from pandas import DataFrame
|
||||
|
||||
from mne.io.base import BaseRaw
|
||||
from src.shared.flaresbasewidget import FlaresBaseWidget
|
||||
from src.shared.shareddata import APP_NAME, resource_path
|
||||
from flares import fold_channels
|
||||
|
||||
|
||||
class MultiProgressDialog(QDialog):
|
||||
@@ -82,7 +84,6 @@ def single_participant_worker(
|
||||
""" Runs inside its own dedicated process """
|
||||
p_name = os.path.basename(file_path)
|
||||
try:
|
||||
from flares import fold_channels
|
||||
# Perform the heavy fold_channels logic
|
||||
channel_results = fold_channels(raw=raw_data, p_name=p_name, progress_queue=progress_queue)
|
||||
|
||||
@@ -282,9 +283,8 @@ class StaticChannelCanvas(FigureCanvas):
|
||||
self.draw_idle()
|
||||
|
||||
|
||||
|
||||
class StandaloneLegendDialog(QWidget):
|
||||
def __init__(self, canvas_engine, title_prefix, parent=None):
|
||||
def __init__(self, canvas_engine: Any, title_prefix: str, parent: QWidget | None = None) -> None:
|
||||
super().__init__(None)
|
||||
self.setWindowTitle("Full View - Brodmann Legend")
|
||||
self.setMinimumSize(500, 600)
|
||||
@@ -302,14 +302,21 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
|
||||
"""The Big Grid Canvas.
|
||||
Dynamically scales row and column configurations to maintain a crisp 16:9 layout orientation.
|
||||
"""
|
||||
def __init__(self, channels_data, color_map, is_fullscreen_copy=False, parent=None):
|
||||
def __init__(
|
||||
self,
|
||||
channels_data: dict[str, list[dict[str, Any]]],
|
||||
color_map: dict[str, str],
|
||||
is_fullscreen_copy: bool = False,
|
||||
parent: QWidget | None = None
|
||||
) -> None:
|
||||
|
||||
self.channels_data = channels_data
|
||||
self.color_map = color_map
|
||||
self.is_fullscreen_copy = is_fullscreen_copy
|
||||
|
||||
num_channels = len(channels_data)
|
||||
|
||||
# --- FIX: DYNAMICALLY CALCULATE OPTIMAL 16:9 COLUMNS ---
|
||||
# Dynamically calculate best ratio for 16:9
|
||||
target_ratio = 16 / 9
|
||||
best_cols = 4
|
||||
min_ratio_error = float('inf')
|
||||
@@ -494,7 +501,7 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
|
||||
self._fullscreen_refs.append(fullscreen_window)
|
||||
|
||||
|
||||
def _calculate_total_brodmann_profile(self, channels_data: Dict[str, Any]):
|
||||
def _calculate_total_brodmann_profile(self, channels_data: dict[str, list[dict[str, Any]]]) -> list[dict[str, Any]]:
|
||||
"""Sums and normalizes the specificity profile across all channels."""
|
||||
totals = {}
|
||||
num_channels = len(channels_data)
|
||||
@@ -503,7 +510,7 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
|
||||
return []
|
||||
|
||||
# Sum up specificities across all channels
|
||||
for channel_name, data_list in channels_data.items():
|
||||
for _, data_list in channels_data.items():
|
||||
for entry in data_list:
|
||||
landmark = entry['Landmark']
|
||||
specificity = entry['Specificity']
|
||||
@@ -532,7 +539,7 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
|
||||
|
||||
return normalized_data_list
|
||||
|
||||
def _open_expanded_view(self, channel_name, data_list):
|
||||
def _open_expanded_view(self, channel_name: str, data_list: list[dict[str, Any]]) -> None:
|
||||
# 1. Create a plain QWidget with NO parent (None)
|
||||
# This instantly makes it a top-level desktop window
|
||||
popup = QWidget(None)
|
||||
@@ -670,7 +677,6 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
|
||||
scroll_layout.setSpacing(6)
|
||||
scroll_layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
|
||||
true_color_map = get_landmark_color_map()
|
||||
|
||||
# Iterate over the sorted keys directly from your method
|
||||
@@ -746,11 +752,12 @@ class ProcessOrchestrator(QObject):
|
||||
setup_finished = Signal(object, object, object, list)
|
||||
setup_failed = Signal(str)
|
||||
|
||||
def __init__(self,
|
||||
selected_files,
|
||||
haemo_dict: dict[str, BaseRaw],
|
||||
worker_func
|
||||
):
|
||||
def __init__(
|
||||
self,
|
||||
selected_files: list[str],
|
||||
haemo_dict: dict[str, Any],
|
||||
worker_func: Callable[..., Any]
|
||||
) -> None:
|
||||
|
||||
super().__init__()
|
||||
self.selected_files = selected_files
|
||||
@@ -810,9 +817,6 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
|
||||
|
||||
self.index_texts = [
|
||||
"0 (Fold Channels)",
|
||||
# "1 (second image)",
|
||||
# "2 (third image)",
|
||||
# "3 (fourth image)",
|
||||
]
|
||||
|
||||
self.image_index_dropdown = self._create_multiselect_dropdown(self.index_texts)
|
||||
@@ -888,13 +892,8 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
|
||||
|
||||
self.multi_progress.add_participant(os.path.basename(file_path), total_channels)
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
print(f"Before: {datetime.now()}")
|
||||
self.multi_progress.show()
|
||||
|
||||
print(f"After 1: {datetime.now()}")
|
||||
|
||||
if current_process().name == 'MainProcess':
|
||||
# Create a clean background thread worker execution channel
|
||||
self.orchestrator_thread = QThread()
|
||||
@@ -914,7 +913,6 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
|
||||
self.orchestrator.setup_failed.connect(self.orchestrator.deleteLater)
|
||||
|
||||
self.orchestrator_thread.start()
|
||||
print(f"After 4: {datetime.now()}")
|
||||
|
||||
def on_orchestration_success(
|
||||
self,
|
||||
@@ -1102,7 +1100,7 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
|
||||
vbox = QVBoxLayout(container)
|
||||
|
||||
title = QLabel("<b>Brodmann Area Legend</b>")
|
||||
title.setAlignment(Qt.AlignCenter)
|
||||
title.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
vbox.addWidget(title)
|
||||
|
||||
pixmap = self._bytes_to_pixmap(legend_bytes)
|
||||
@@ -1110,10 +1108,10 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
|
||||
# Legends are usually tall, so we scale it differently or keep it smaller
|
||||
legend_label.setPixmap(pixmap.scaled(
|
||||
self.thumb_size,
|
||||
Qt.KeepAspectRatio,
|
||||
Qt.SmoothTransformation
|
||||
Qt.AspectRatioMode.KeepAspectRatio,
|
||||
Qt.TransformationMode.SmoothTransformation
|
||||
))
|
||||
legend_label.setAlignment(Qt.AlignCenter)
|
||||
legend_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
legend_label.mousePressEvent = lambda e, p=pixmap: self._open_full_size(p, "Brodmann Legend")
|
||||
|
||||
vbox.addWidget(legend_label)
|
||||
|
||||
@@ -14,11 +14,12 @@ import pandas as pd
|
||||
from pandas import DataFrame
|
||||
|
||||
from PySide6.QtWidgets import QApplication, QComboBox, QDialog, QGridLayout, QHBoxLayout, QLabel, QLineEdit, QListView, QMessageBox, QPushButton, QScrollArea, QSizePolicy, QVBoxLayout, QWidget, QFrame, QSpinBox, QFileDialog
|
||||
from PySide6.QtGui import QPalette, QStandardItemModel, QStandardItem, QPixmap, QIntValidator, QDoubleValidator
|
||||
from PySide6.QtGui import QStandardItemModel, QStandardItem, QPixmap, QIntValidator, QDoubleValidator
|
||||
from PySide6.QtCore import QEvent, QPoint, QSize, QTimer, Qt, Signal
|
||||
|
||||
from src.shared.shareddata import APP_NAME, PIPELINE_STAGES
|
||||
|
||||
|
||||
class FullClickComboBox(QComboBox):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
@@ -1290,7 +1291,6 @@ class FlaresBaseWidget(QWidget):
|
||||
model.itemChanged.connect(on_item_changed)
|
||||
|
||||
|
||||
|
||||
def update_participant_list_for_group(self, group_name=None, combo=None):
|
||||
|
||||
target_combo = combo if combo is not None else getattr(self, "participant_dropdown", None)
|
||||
@@ -1492,8 +1492,6 @@ class InterGroupUIMixin:
|
||||
dropdown.blockSignals(False)
|
||||
|
||||
|
||||
|
||||
|
||||
def _get_file_paths_from_labels(self, labels, group_name):
|
||||
file_paths = []
|
||||
|
||||
@@ -1868,7 +1866,6 @@ class IntraGroupUIMixin:
|
||||
param_info["options"] = dynamic_contrasts
|
||||
param_info["default"] = dynamic_contrasts[0] if dynamic_contrasts else ""
|
||||
|
||||
|
||||
param_values = {}
|
||||
if indexes_needing_params:
|
||||
dialog = ParameterInputDialog(indexes_needing_params, parent=self)
|
||||
@@ -1886,6 +1883,7 @@ class IntraGroupUIMixin:
|
||||
param_values,
|
||||
)
|
||||
|
||||
|
||||
class ParticipantUIMixin:
|
||||
def setup_participant_ui(
|
||||
self,
|
||||
@@ -1913,7 +1911,6 @@ class ParticipantUIMixin:
|
||||
self.event_dropdown = QComboBox()
|
||||
self.event_dropdown.addItem("<None Selected>")
|
||||
|
||||
|
||||
self.index_texts = index_texts
|
||||
|
||||
self.image_index_dropdown = self._create_multiselect_dropdown(self.index_texts)
|
||||
@@ -1968,8 +1965,6 @@ class ParticipantUIMixin:
|
||||
]
|
||||
|
||||
|
||||
|
||||
|
||||
# Inject full_text from index_texts
|
||||
for idx, params_list in parameterized_indexes.items():
|
||||
full_text = self.index_texts[idx] if idx < len(self.index_texts) else f"{idx} (No label found)"
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
"""
|
||||
Filename: inspiration.py
|
||||
Description: Contains inspiration quotes to be shown at random
|
||||
Note: Compliant with pylance strict type checking
|
||||
|
||||
Author: Tyler de Zeeuw
|
||||
License: GPL-3.0
|
||||
"""
|
||||
|
||||
# Built-in imports
|
||||
import random
|
||||
|
||||
FNIRS_QUOTES = [
|
||||
"Understanding the brain through light and data.",
|
||||
"Every signal offers another perspective on brain function.",
|
||||
"Exploring the relationship between brain activity and behavior.",
|
||||
"Using light to investigate the complexity of the human brain.",
|
||||
"Turning neural measurements into meaningful insights.",
|
||||
"A deeper understanding of the brain begins with better questions.",
|
||||
"Studying the brain means looking beyond what we can see.",
|
||||
"Connecting brain activity to the way we think, feel, and act.",
|
||||
"Even small changes in brain signals can reveal meaningful patterns.",
|
||||
"Advancing neuroscience through new ways of measuring brain activity.",
|
||||
"The brain is complex, and every measurement adds another piece.",
|
||||
"Using fNIRS to explore the connection between the brain and the world around us.",
|
||||
"Good science begins with curiosity and grows through careful observation.",
|
||||
"There is still so much to learn about how the brain works.",
|
||||
"Measuring brain activity is only the beginning; understanding it is the goal.",
|
||||
"Exploring what brain activity can tell us about the human experience.",
|
||||
"Every experiment brings a new opportunity to understand the brain.",
|
||||
"The more we measure, the more questions we learn to ask.",
|
||||
"Neuroscience advances through careful measurements and thoughtful interpretation.",
|
||||
"Understanding brain function requires looking at both signals and context.",
|
||||
"Each dataset offers a different view of the brain in action.",
|
||||
"Exploring the neural processes behind everyday human experiences.",
|
||||
"Better measurements can lead to better questions about the brain.",
|
||||
"The challenge is not just collecting data, but understanding what it means.",
|
||||
"Brain activity reflects a complex interaction of processes and experiences.",
|
||||
"Scientific progress begins with observing what we do not yet understand.",
|
||||
"Each discovery builds on the questions that came before it.",
|
||||
"There is more to brain activity than a single signal can reveal.",
|
||||
"New tools create new opportunities to investigate old questions.",
|
||||
"The goal is to connect what we measure with what we want to understand.",
|
||||
"Neural signals provide clues to the processes underlying human behavior.",
|
||||
"A better understanding of brain function starts with better evidence.",
|
||||
"The value of a measurement lies in what it helps us understand.",
|
||||
"Every experiment adds another perspective to a complex picture.",
|
||||
"Scientific discovery often begins with noticing something unexpected.",
|
||||
"Understanding the brain is an ongoing process of questioning and refinement.",
|
||||
"The best insights emerge when data meets thoughtful interpretation.",
|
||||
"Research moves forward when curiosity is matched with rigorous methods.",
|
||||
"There is always another question worth exploring.",
|
||||
"Investigating the brain means embracing complexity rather than avoiding it.",
|
||||
"Progress comes from asking precise questions about complicated systems.",
|
||||
"The brain does not work in isolation from the person or environment.",
|
||||
"Understanding behavior requires more than observing behavior alone.",
|
||||
"The smallest details can sometimes change how we interpret the bigger picture.",
|
||||
"Every method offers a different window into brain function.",
|
||||
"Different perspectives help build a more complete understanding of the brain.",
|
||||
"Good research turns uncertainty into questions that can be investigated.",
|
||||
"The process of discovery is as important as the result.",
|
||||
"Meaningful findings begin with reliable data and careful reasoning.",
|
||||
"The brain is not simple, and our methods should reflect that complexity.",
|
||||
"Scientific understanding grows when evidence challenges our assumptions.",
|
||||
"Research is a process of learning what the data can—and cannot—tell us.",
|
||||
"The most useful findings are those that move understanding forward.",
|
||||
"Each new method expands the questions we can ask about the brain.",
|
||||
"The path to understanding is built from observations, questions, and evidence.",
|
||||
"Neuroscience is about understanding the processes behind the patterns.",
|
||||
"The aim is not simply to measure brain activity, but to interpret it meaningfully.",
|
||||
"Better tools help us investigate the questions that matter.",
|
||||
"Progress in neuroscience depends on both technical precision and human curiosity.",
|
||||
"The brain is complex, but every careful observation helps clarify a part of it.",
|
||||
"Understanding brain function means connecting measurements to meaningful outcomes.",
|
||||
"Research turns curiosity into evidence and evidence into understanding.",
|
||||
"There is a story behind every pattern, but evidence determines what we can conclude.",
|
||||
"New insights emerge when we approach familiar questions from different angles.",
|
||||
"The strongest conclusions begin with careful measurements.",
|
||||
"Understanding the brain takes more than data; it takes context.",
|
||||
"The questions we ask shape what we learn from the brain.",
|
||||
"Good research is not about finding answers quickly, but finding them carefully.",
|
||||
"Every result contributes to a larger conversation about how the brain works.",
|
||||
"The human brain remains an evolving question for science.",
|
||||
"Measuring brain activity gives us another way to investigate human cognition.",
|
||||
"The connection between brain function and behavior is rarely straightforward.",
|
||||
"Scientific progress comes from combining new methods with careful interpretation.",
|
||||
"The more we learn about the brain, the better our questions become.",
|
||||
"Research is the practice of making uncertainty more understandable.",
|
||||
"Understanding the brain means being willing to revise what we think we know.",
|
||||
"Each experiment helps define what we know and what remains uncertain.",
|
||||
"The most interesting findings often begin with questions that have no easy answers.",
|
||||
"The purpose of research is to move beyond assumptions toward evidence.",
|
||||
"A measurement becomes useful when it helps answer a meaningful question.",
|
||||
"Neuroscience brings together technology, observation, and the study of human experience.",
|
||||
"fNIRS offers another way to investigate changes in brain hemodynamics.",
|
||||
"Optical measurements provide a noninvasive approach to studying brain function.",
|
||||
"Studying brain function outside the lab can reveal aspects of everyday cognition.",
|
||||
"The flexibility of fNIRS opens new possibilities for human neuroscience research.",
|
||||
"Brain measurements become more meaningful when considered alongside behavior.",
|
||||
"fNIRS provides a useful perspective on the relationship between physiology and cognition.",
|
||||
"Understanding the strengths and limitations of a method is part of good science.",
|
||||
"The future of neuroscience depends on asking better questions, not just collecting more data.",
|
||||
"Technology expands what we can measure, while critical thinking guides what we conclude.",
|
||||
"The goal of neuroscience is not just to observe the brain, but to understand its function.",
|
||||
"Every research method brings its own perspective, possibilities, and limitations.",
|
||||
"The most meaningful discoveries connect technical advances to real-world questions.",
|
||||
"Better ways to study the brain can lead to a better understanding of human experience.",
|
||||
"Science progresses when we remain curious about what the evidence cannot yet explain.",
|
||||
"Understanding the brain is a long-term effort built on many small discoveries.",
|
||||
"Behind every useful finding is a question worth investigating.",
|
||||
"The value of research lies in what we learn, not just what we measure.",
|
||||
"Good science makes room for complexity, uncertainty, and new perspectives.",
|
||||
"The next insight may come from looking at a familiar problem in a different way.",
|
||||
]
|
||||
|
||||
def get_quote() -> None:
|
||||
return f"'{random.choice(FNIRS_QUOTES)}'"
|
||||
@@ -8,12 +8,18 @@ License: GPL-3.0
|
||||
"""
|
||||
|
||||
# Built-in imports
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import platform
|
||||
|
||||
try:
|
||||
import winreg
|
||||
except ImportError:
|
||||
winreg = None
|
||||
|
||||
CURRENT_VERSION = "1.6.1"
|
||||
CURRENT_VERSION = "1.8.0"
|
||||
APP_NAME = "flares"
|
||||
APP_NAME_EXPANDED = "fNIRS Lightweight Analysis, Research, & Evaluation Suite"
|
||||
API_URL = f"https://git.research.dezeeuw.ca/api/v1/repos/tyler/{APP_NAME}/releases"
|
||||
@@ -21,6 +27,7 @@ API_URL_SECONDARY = f"https://git.research2.dezeeuw.ca/api/v1/repos/tyler/{APP_N
|
||||
PLATFORM_NAME = platform.system().lower()
|
||||
CHANGELOG_URL = f"https://git.research.dezeeuw.ca/tyler/{APP_NAME}/raw/branch/main/changelog_major.md"
|
||||
WIKI_URL = f"https://git.research.dezeeuw.ca/tyler/{APP_NAME}/wiki"
|
||||
PLUGINS_URL = f"https://git.research.dezeeuw.ca/tyler/{APP_NAME}-plugins/raw/branch/main/plugins.json"
|
||||
|
||||
|
||||
PIPELINE_STAGES = [
|
||||
@@ -71,10 +78,71 @@ DATA_SCHEMA = [
|
||||
]
|
||||
|
||||
|
||||
def resource_path(relative_path: str) -> str:
|
||||
"""
|
||||
Get absolute path to resource regardless of running directly or packaged using PyInstaller
|
||||
"""
|
||||
METRIC_REGISTRY = {
|
||||
"Peak_Amp": "Peak_Amp",
|
||||
"TTP": "Time_to_Peak",
|
||||
"AUC": "AUC",
|
||||
"Rising_Slope": "Rising_Slope",
|
||||
"Recovery_Slope": "Recovery_Slope",
|
||||
"FWHM": "FWHM",
|
||||
"Onset_Latency": "Onset_Latency",
|
||||
"P2P_Amp": "Peak_to_Peak_Amp",
|
||||
"Signal_Std": "Signal_Std",
|
||||
"RMS": "RMS",
|
||||
}
|
||||
|
||||
|
||||
def get_cpu_model_name():
|
||||
"""Detect detailed processor info, collapsing Windows registry whitespace padding."""
|
||||
try:
|
||||
system = platform.system()
|
||||
|
||||
if system == "Windows" and winreg is not None:
|
||||
with winreg.OpenKey(
|
||||
winreg.HKEY_LOCAL_MACHINE,
|
||||
r"HARDWARE\DESCRIPTION\System\CentralProcessor\0",
|
||||
) as key:
|
||||
raw_cpu_name, _ = winreg.QueryValueEx(key, "ProcessorNameString")
|
||||
cpu_name = " ".join(raw_cpu_name.split())
|
||||
|
||||
command = (
|
||||
"Get-CimInstance Win32_Processor | "
|
||||
"Select-Object NumberOfCores, NumberOfLogicalProcessors, MaxClockSpeed | "
|
||||
"ConvertTo-Json -Compress"
|
||||
)
|
||||
cpu = json.loads(subprocess.check_output(
|
||||
["powershell", "-NoProfile", "-Command", command],
|
||||
text=True,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
))
|
||||
base_ghz = cpu["MaxClockSpeed"] / 1000
|
||||
return (f"{cpu_name} @ {base_ghz:.1f}GHz "
|
||||
f"{cpu['NumberOfCores']}c/{cpu['NumberOfLogicalProcessors']}t")
|
||||
|
||||
if system == "Darwin":
|
||||
return subprocess.check_output(
|
||||
["sysctl", "-n", "machdep.cpu.brand_string"], text=True
|
||||
).strip()
|
||||
|
||||
if system == "Linux":
|
||||
with open("/proc/cpuinfo") as f:
|
||||
for line in f:
|
||||
if "model name" in line:
|
||||
return line.split(":")[1].strip()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return platform.processor() or "Multi-Core CPU"
|
||||
|
||||
|
||||
def resource_path(relative_path: str) -> str:
|
||||
"""Get absolute path to resource regardless of running directly or packaged using PyInstaller"""
|
||||
base_path = getattr(sys, "_MEIPASS", os.path.abspath("."))
|
||||
return os.path.join(base_path, relative_path)
|
||||
|
||||
|
||||
def get_app_dir() -> str:
|
||||
"""Returns the root directory of the application executable or working directory."""
|
||||
if getattr(sys, 'frozen', False):
|
||||
return os.path.dirname(sys.executable)
|
||||
return os.getcwd()
|
||||
+11
-1
@@ -12,6 +12,7 @@ from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
from src.shared.shareddata import APP_NAME, APP_NAME_EXPANDED, CURRENT_VERSION
|
||||
from src.shared.inspiration import get_quote
|
||||
|
||||
|
||||
class AboutWindow(QWidget):
|
||||
@@ -30,12 +31,21 @@ class AboutWindow(QWidget):
|
||||
layout = QVBoxLayout()
|
||||
label = QLabel(f"About {APP_NAME.upper()}", self)
|
||||
label2 = QLabel(f"{APP_NAME_EXPANDED}", self)
|
||||
label3 = QLabel(f"{APP_NAME.upper()} is licensed under the GPL-3.0 licence. For more information, visit https://www.gnu.org/licenses/gpl-3.0.en.html", self)
|
||||
label3 = QLabel(
|
||||
f'{APP_NAME.upper()} is licensed under the GPL-3.0 licence. '
|
||||
'For more information, visit '
|
||||
'<a href="https://www.gnu.org/licenses/gpl-3.0.en.html">'
|
||||
'GNU GPL-3.0</a>',
|
||||
self,
|
||||
)
|
||||
label3.setOpenExternalLinks(True)
|
||||
label4 = QLabel(f"Version v{CURRENT_VERSION}")
|
||||
label5 = QLabel(f"{get_quote()}", self)
|
||||
|
||||
layout.addWidget(label)
|
||||
layout.addWidget(label2)
|
||||
layout.addWidget(label3)
|
||||
layout.addWidget(label4)
|
||||
layout.addWidget(label5)
|
||||
|
||||
self.setLayout(layout)
|
||||
@@ -0,0 +1,658 @@
|
||||
"""
|
||||
Filename: plugins.py
|
||||
Description: Plugins window
|
||||
Note: Compliant with pylance strict type checking
|
||||
|
||||
Author: Tyler de Zeeuw
|
||||
License: GPL-3.0
|
||||
"""
|
||||
|
||||
# Built-in imports
|
||||
import io
|
||||
import shutil
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
# External library imports
|
||||
from PySide6.QtCore import Qt, QThread, Signal
|
||||
from PySide6.QtGui import QColor
|
||||
from PySide6.QtWidgets import (
|
||||
QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem, QMessageBox,
|
||||
QPushButton, QTabWidget, QTextBrowser, QVBoxLayout, QWidget, QProgressDialog
|
||||
)
|
||||
|
||||
from plugin_manager import PluginManager, get_ssl_context, parse_version
|
||||
from src.shared.shareddata import APP_NAME, CURRENT_VERSION, PLUGINS_URL
|
||||
|
||||
|
||||
class RemoteFetchWorker(QThread):
|
||||
"""Background thread to fetch remote repository data without lagging the main UI."""
|
||||
fetched = Signal(list, bool, str) # (plugins_data, all_failed, fetch_type)
|
||||
|
||||
def __init__(self, manager: PluginManager, urls: list[str], fetch_type: str = "browser") -> None:
|
||||
super().__init__()
|
||||
self.manager = manager
|
||||
self.urls = urls
|
||||
self.fetch_type = fetch_type
|
||||
|
||||
def run(self) -> None:
|
||||
plugins, all_failed = self.manager.fetch_remote_repositories(self.urls)
|
||||
self.fetched.emit(plugins, all_failed, self.fetch_type)
|
||||
|
||||
|
||||
class PluginInstallWorker(QThread):
|
||||
"""Background worker to download, unpack, and install/update plugins with progress reporting."""
|
||||
progress = Signal(int, int) # (downloaded_bytes, total_bytes)
|
||||
status_message = Signal(str)
|
||||
finished = Signal(bool, str) # (success, message_or_path)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
manager: PluginManager,
|
||||
download_url: str,
|
||||
plugin_id: str,
|
||||
is_update: bool = False,
|
||||
plugin_path_to_remove: Path | None = None
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.manager = manager
|
||||
self.download_url = download_url
|
||||
self.plugin_id = plugin_id
|
||||
self.is_update = is_update
|
||||
self.plugin_path_to_remove = plugin_path_to_remove
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
self.status_message.emit("Connecting to server...")
|
||||
req = urllib.request.Request(
|
||||
self.download_url,
|
||||
headers={"User-Agent": f"{APP_NAME}-PluginManager"},
|
||||
)
|
||||
|
||||
with urllib.request.urlopen(req, timeout=30, context=get_ssl_context()) as response:
|
||||
if response.status != 200:
|
||||
self.finished.emit(False, f"Download failed with HTTP status code {response.status}")
|
||||
return
|
||||
|
||||
total_size_str = response.headers.get("Content-Length")
|
||||
total_size = int(total_size_str) if total_size_str and total_size_str.isdigit() else 0
|
||||
|
||||
self.status_message.emit("Downloading plugin archive...")
|
||||
chunk_size = 8192
|
||||
downloaded = 0
|
||||
buffer = io.BytesIO()
|
||||
|
||||
while True:
|
||||
chunk = response.read(chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
buffer.write(chunk)
|
||||
downloaded += len(chunk)
|
||||
self.progress.emit(downloaded, total_size)
|
||||
|
||||
zip_bytes = buffer.getvalue()
|
||||
|
||||
if self.is_update and self.plugin_path_to_remove and self.plugin_path_to_remove.exists():
|
||||
self.status_message.emit("Removing old version...")
|
||||
if self.plugin_path_to_remove.is_dir():
|
||||
shutil.rmtree(self.plugin_path_to_remove)
|
||||
else:
|
||||
self.plugin_path_to_remove.unlink()
|
||||
|
||||
self.status_message.emit("Unpacking and extracting files...")
|
||||
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zip_ref:
|
||||
zip_ref.extractall(self.manager.plugins_dir)
|
||||
|
||||
self.finished.emit(True, str(self.manager.plugins_dir))
|
||||
except Exception as e:
|
||||
self.finished.emit(False, str(e))
|
||||
|
||||
|
||||
class PluginsWindow(QWidget):
|
||||
|
||||
def __init__(self, parent: QWidget | None, plugin_manager: PluginManager) -> None:
|
||||
super().__init__(None, Qt.WindowType.Window)
|
||||
|
||||
self.main_app_window = parent
|
||||
self.manager: PluginManager = plugin_manager
|
||||
|
||||
self.setWindowTitle(f"{APP_NAME.upper()} - Plugins")
|
||||
self.resize(750, 500)
|
||||
|
||||
self.repository_urls: list[str] = [PLUGINS_URL]
|
||||
self.remote_plugins_data: list[dict[str, Any]] = []
|
||||
self._has_fetched_remote: bool = False
|
||||
self._fetch_worker: RemoteFetchWorker | None = None
|
||||
self._install_worker: PluginInstallWorker | None = None
|
||||
self._progress_dialog: QProgressDialog | None = None
|
||||
|
||||
# Refresh UI automatically if manager updates state
|
||||
self.manager.plugins_changed.connect(self.refresh_installed_plugins)
|
||||
|
||||
main_layout = QVBoxLayout(self)
|
||||
self.tab_widget = QTabWidget(self)
|
||||
|
||||
self.installed_tab = self._create_installed_tab()
|
||||
self.browser_tab = self._create_browser_tab()
|
||||
|
||||
self.tab_widget.addTab(self.installed_tab, "Installed Plugins")
|
||||
self.tab_widget.addTab(self.browser_tab, "Plugin Browser")
|
||||
self.tab_widget.currentChanged.connect(self._on_tab_changed)
|
||||
|
||||
main_layout.addWidget(self.tab_widget)
|
||||
self.setLayout(main_layout)
|
||||
|
||||
self.refresh_installed_plugins()
|
||||
self.check_installed_updates()
|
||||
|
||||
def check_installed_updates(self) -> None:
|
||||
"""Asynchronously checks remote repositories for installed plugin updates."""
|
||||
if self._fetch_worker is not None and self._fetch_worker.isRunning():
|
||||
return
|
||||
|
||||
self._fetch_worker = RemoteFetchWorker(self.manager, self.repository_urls, fetch_type="updates")
|
||||
self._fetch_worker.fetched.connect(self._on_remote_fetched)
|
||||
self._fetch_worker.start()
|
||||
|
||||
def refresh_installed_plugins(self) -> None:
|
||||
"""Refreshes installed list using metadata from PluginManager and attaches update info."""
|
||||
self.installed_list.clear()
|
||||
plugins_info = self.manager.get_installed_plugins_info()
|
||||
|
||||
remote_map: dict[str, dict[str, Any]] = {}
|
||||
if self._has_fetched_remote and self.remote_plugins_data:
|
||||
for p in self.remote_plugins_data:
|
||||
p_id = p.get("id") or p.get("name")
|
||||
if p_id:
|
||||
remote_map[str(p_id)] = p
|
||||
|
||||
for info in plugins_info:
|
||||
p_id = str(info.get("id", ""))
|
||||
local_tuple = parse_version(str(info.get("version", "0.0.0")))
|
||||
|
||||
remote_plugin = remote_map.get(p_id)
|
||||
if remote_plugin:
|
||||
remote_ver = str(remote_plugin.get("version", "0.0.0"))
|
||||
if parse_version(remote_ver) > local_tuple:
|
||||
info["update_available"] = True
|
||||
info["remote_version"] = remote_ver
|
||||
info["download_url"] = str(remote_plugin.get("download_url", ""))
|
||||
|
||||
display_text = f"{info['name']} (v{info.get('version', '1.0.0')})"
|
||||
if info.get("update_available"):
|
||||
display_text += f" [Update Available: v{info.get('remote_version')}]"
|
||||
if info["is_disabled"]:
|
||||
display_text += " [Disabled]"
|
||||
|
||||
item = QListWidgetItem(display_text, self.installed_list)
|
||||
item.setData(Qt.ItemDataRole.UserRole, info)
|
||||
|
||||
if info.get("update_available"):
|
||||
item.setForeground(QColor("#1976d2"))
|
||||
elif info["is_disabled"]:
|
||||
item.setForeground(QColor("#757575"))
|
||||
|
||||
if self.installed_list.count() == 0:
|
||||
self.installed_list.addItem("No plugins installed.")
|
||||
self._clear_details_panel()
|
||||
|
||||
if not self._has_fetched_remote:
|
||||
self.check_installed_updates()
|
||||
elif self.remote_plugins_data:
|
||||
self._populate_browser_list()
|
||||
|
||||
def _on_installed_item_changed(self, current: QListWidgetItem | None, _: Any) -> None:
|
||||
"""Updates the right-hand details panel when a plugin is selected."""
|
||||
if not current:
|
||||
self._clear_details_panel()
|
||||
self.btn_update_plugin.setEnabled(False)
|
||||
return
|
||||
|
||||
info = current.data(Qt.ItemDataRole.UserRole)
|
||||
if not isinstance(info, dict):
|
||||
self._clear_details_panel()
|
||||
self.btn_update_plugin.setEnabled(False)
|
||||
return
|
||||
|
||||
plugin_info = cast(dict[str, Any], info)
|
||||
|
||||
name = str(plugin_info.get("name", "Unknown"))
|
||||
version = str(plugin_info.get("version", "1.0.0"))
|
||||
author = str(plugin_info.get("author", "Unknown"))
|
||||
desc = str(plugin_info.get("description", "No description provided."))
|
||||
is_disabled = bool(plugin_info.get("is_disabled", False))
|
||||
path = str(plugin_info.get("path", ""))
|
||||
update_available = bool(plugin_info.get("update_available", False))
|
||||
|
||||
self.lbl_plugin_title.setText(name)
|
||||
self.lbl_plugin_meta.setText(f"<b>Version:</b> {version} | <b>Author:</b> {author}")
|
||||
|
||||
if update_available:
|
||||
rem_ver = plugin_info.get("remote_version", "")
|
||||
self.lbl_plugin_status.setText(f"<font color='#1976d2'><b>Status: Update v{rem_ver} Available!</b></font>")
|
||||
elif is_disabled:
|
||||
self.lbl_plugin_status.setText("<font color='#d32f2f'><b>Status: Disabled</b></font>")
|
||||
else:
|
||||
self.lbl_plugin_status.setText("<font color='#2e7d32'><b>Status: Active</b></font>")
|
||||
|
||||
self.txt_plugin_desc.setHtml(desc)
|
||||
self.lbl_plugin_path.setText(f"<b>Path:</b> <code>{path}</code>")
|
||||
self.btn_update_plugin.setEnabled(update_available)
|
||||
|
||||
def execute_plugin_installation(self, download_url: str, plugin_id: str, plugin_name: str, is_update: bool, plugin_path: Path | None) -> None:
|
||||
"""Sets up the progress dialog and starts the background installation/update worker."""
|
||||
if self._install_worker is not None and self._install_worker.isRunning():
|
||||
return
|
||||
|
||||
self._installation_finished_flag = False
|
||||
action_label = "Updating" if is_update else "Installing"
|
||||
|
||||
self._progress_dialog = QProgressDialog(f"{action_label} '{plugin_name}'...", "Cancel", 0, 100, self)
|
||||
self._progress_dialog.setMinimumDuration(0) # Show the progress dialog instantly
|
||||
self._progress_dialog.setWindowTitle(f"{action_label} Plugin")
|
||||
self._progress_dialog.setWindowModality(Qt.WindowModality.WindowModal)
|
||||
self._progress_dialog.setAutoClose(False)
|
||||
self._progress_dialog.setAutoReset(False)
|
||||
self._progress_dialog.canceled.connect(self._cancel_installation)
|
||||
self._progress_dialog.show()
|
||||
|
||||
self._install_worker = PluginInstallWorker(self.manager, download_url, plugin_id, is_update, plugin_path)
|
||||
self._install_worker.progress.connect(self._on_install_progress)
|
||||
self._install_worker.status_message.connect(self._on_install_status)
|
||||
self._install_worker.finished.connect(lambda success, msg: self._on_install_finished(success, msg, plugin_name, is_update))
|
||||
self._install_worker.start()
|
||||
|
||||
|
||||
def _cancel_installation(self) -> None:
|
||||
"""Handles manual user cancellation of the download/install progress."""
|
||||
if getattr(self, "_installation_finished_flag", False):
|
||||
return # Ignore if triggered by closing the dialog programmatically on success
|
||||
|
||||
if self._install_worker and self._install_worker.isRunning():
|
||||
self._install_worker.terminate()
|
||||
self._install_worker.wait()
|
||||
QMessageBox.warning(self, "Cancelled", "Plugin installation was cancelled.")
|
||||
|
||||
def _on_install_finished(self, success: bool, message: str, plugin_name: str, is_update: bool) -> None:
|
||||
self._installation_finished_flag = True # Prevent false cancellation triggers
|
||||
|
||||
if self._progress_dialog:
|
||||
self._progress_dialog.close()
|
||||
self._progress_dialog.deleteLater()
|
||||
self._progress_dialog = None
|
||||
|
||||
action_word = "updated" if is_update else "installed"
|
||||
action_verb = "update" if is_update else "install"
|
||||
|
||||
if success:
|
||||
self.manager.reload_plugins()
|
||||
QMessageBox.information(self, "Success", f"Successfully {action_word} '{plugin_name}'!")
|
||||
self.refresh_installed_plugins()
|
||||
if self._has_fetched_remote:
|
||||
self._populate_browser_list()
|
||||
else:
|
||||
QMessageBox.critical(self, "Installation Failed", f"Could not {action_verb} plugin:\n{message}")
|
||||
|
||||
def _on_install_progress(self, downloaded: int, total: int) -> None:
|
||||
if self._progress_dialog:
|
||||
if total > 0:
|
||||
self._progress_dialog.setMaximum(total)
|
||||
self._progress_dialog.setValue(downloaded)
|
||||
mb_down = downloaded / (1024 * 1024)
|
||||
mb_total = total / (1024 * 1024)
|
||||
self._progress_dialog.setLabelText(f"Downloading... ({mb_down:.2f} MB / {mb_total:.2f} MB)")
|
||||
else:
|
||||
self._progress_dialog.setMaximum(0) # Infinite marquee progress if size is unknown
|
||||
mb_down = downloaded / (1024 * 1024)
|
||||
self._progress_dialog.setLabelText(f"Downloading... ({mb_down:.2f} MB downloaded)")
|
||||
|
||||
def _on_install_status(self, message: str) -> None:
|
||||
if self._progress_dialog:
|
||||
self._progress_dialog.setLabelText(message)
|
||||
|
||||
|
||||
def update_selected_plugin(self) -> None:
|
||||
"""Triggers the background update process for the selected installed plugin."""
|
||||
selected = self.installed_list.currentItem()
|
||||
if not selected:
|
||||
return
|
||||
raw_info = selected.data(Qt.ItemDataRole.UserRole)
|
||||
if not isinstance(raw_info, dict):
|
||||
return
|
||||
|
||||
info = cast(dict[str, Any], raw_info)
|
||||
download_url = info.get("download_url", "")
|
||||
plugin_id = info.get("id", "unnamed")
|
||||
plugin_name = info.get("name", "Plugin")
|
||||
plugin_path = info.get("path")
|
||||
|
||||
if not download_url:
|
||||
QMessageBox.warning(self, "Update Failed", "No download URL provided for this update.")
|
||||
return
|
||||
|
||||
path_obj = Path(plugin_path) if isinstance(plugin_path, str) and plugin_path else (plugin_path if isinstance(plugin_path, Path) else None)
|
||||
self.execute_plugin_installation(download_url, plugin_id, plugin_name, is_update=True, plugin_path=path_obj)
|
||||
|
||||
def install_selected_plugin(self) -> None:
|
||||
"""Triggers the background installation/update process from the browser tab."""
|
||||
selected = self.browser_list.currentItem()
|
||||
if not selected:
|
||||
return
|
||||
data = selected.data(Qt.ItemDataRole.UserRole)
|
||||
if not isinstance(data, dict):
|
||||
return
|
||||
|
||||
plugin_data = cast(dict[str, Any], data)
|
||||
|
||||
update_available = bool(selected.data(Qt.ItemDataRole.UserRole + 3))
|
||||
plugin_id = str(plugin_data.get("id", "unnamed"))
|
||||
plugin_name = str(plugin_data.get("name", "Plugin"))
|
||||
download_url = str(plugin_data.get("download_url", ""))
|
||||
|
||||
if not download_url:
|
||||
QMessageBox.warning(self, "Error", "No download URL provided for this plugin.")
|
||||
return
|
||||
|
||||
# Find existing local path if updating an installed plugin
|
||||
existing_path: Path | None = None
|
||||
installed_info = self.manager.get_installed_plugins_info()
|
||||
for info in installed_info:
|
||||
if info.get("id") == plugin_id or info.get("name") == plugin_name:
|
||||
p = info.get("path")
|
||||
if isinstance(p, Path):
|
||||
existing_path = p
|
||||
elif isinstance(p, str):
|
||||
existing_path = Path(p)
|
||||
break
|
||||
|
||||
self.execute_plugin_installation(download_url, plugin_id, plugin_name, is_update=update_available, plugin_path=existing_path)
|
||||
|
||||
def _on_remote_fetched(self, plugins: list[dict[str, Any]], all_failed: bool, fetch_type: str) -> None:
|
||||
"""Callback executed on the main UI thread when remote fetching finishes."""
|
||||
self.remote_plugins_data = plugins
|
||||
self._has_fetched_remote = True
|
||||
self.btn_fetch.setEnabled(True)
|
||||
|
||||
if fetch_type == "updates":
|
||||
self.refresh_installed_plugins()
|
||||
return
|
||||
|
||||
if plugins:
|
||||
self._populate_browser_list()
|
||||
elif all_failed:
|
||||
self.browser_list.clear()
|
||||
self.browser_list.addItem("Unable to load plugins from configured repositories.")
|
||||
else:
|
||||
self.browser_list.clear()
|
||||
self.browser_list.addItem("No plugins found across configured repositories.")
|
||||
|
||||
def fetch_remote_plugins(self) -> None:
|
||||
"""Asynchronously fetches remote plugins on a background thread for the browser."""
|
||||
if self._fetch_worker is not None and self._fetch_worker.isRunning():
|
||||
return
|
||||
|
||||
self.browser_list.clear()
|
||||
self.browser_list.addItem("Fetching remote repositories...")
|
||||
self.btn_install.setEnabled(False)
|
||||
self.btn_fetch.setEnabled(False)
|
||||
|
||||
self._fetch_worker = RemoteFetchWorker(self.manager, self.repository_urls, fetch_type="browser")
|
||||
self._fetch_worker.fetched.connect(self._on_remote_fetched)
|
||||
self._fetch_worker.start()
|
||||
|
||||
def _populate_browser_list(self) -> None:
|
||||
"""Populates the browser tab list and evaluates compatibility, installation, and updates."""
|
||||
self.browser_list.clear()
|
||||
|
||||
installed_info = self.manager.get_installed_plugins_info()
|
||||
installed_map: dict[str, dict[str, Any]] = {}
|
||||
for p in installed_info:
|
||||
p_id = p.get("id")
|
||||
p_name = p.get("name")
|
||||
if p_id:
|
||||
installed_map[str(p_id)] = p
|
||||
if p_name:
|
||||
installed_map[str(p_name)] = p
|
||||
|
||||
for plugin in self.remote_plugins_data:
|
||||
name = plugin.get("name", "Unknown")
|
||||
p_id = plugin.get("id", "")
|
||||
version = plugin.get("version", "v0.0")
|
||||
desc = plugin.get("description", "")
|
||||
platforms = str(plugin.get("platforms", []))
|
||||
min_v_str = plugin.get("min_app_version", "0.0.0")
|
||||
|
||||
is_platform_ok = not platforms or self.manager.current_platform in platforms
|
||||
is_version_ok = self.manager.current_app_version >= parse_version(min_v_str)
|
||||
is_compatible = is_platform_ok and is_version_ok
|
||||
|
||||
incompat_reasons: list[str] = []
|
||||
if not is_platform_ok:
|
||||
plat_str = ", ".join(platforms) if isinstance(platforms, list) else str(platforms)
|
||||
incompat_reasons.append(f"Requires platform: {plat_str}")
|
||||
if not is_version_ok:
|
||||
incompat_reasons.append(f"Requires App v{min_v_str}+")
|
||||
|
||||
installed_match = installed_map.get(str(p_id)) or installed_map.get(str(name))
|
||||
is_installed = installed_match is not None
|
||||
|
||||
update_available = False
|
||||
remote_ver_str = str(plugin.get("version", "0.0.0"))
|
||||
if is_installed:
|
||||
local_ver_str = str(installed_match.get("version", "0.0.0"))
|
||||
if parse_version(remote_ver_str) > parse_version(local_ver_str):
|
||||
update_available = True
|
||||
|
||||
display_text = f"{name} (v{version}) - {desc}"
|
||||
if update_available:
|
||||
display_text += f" [Update Available: v{remote_ver_str}]"
|
||||
elif is_installed:
|
||||
display_text += " [Installed]"
|
||||
elif incompat_reasons:
|
||||
reason_str = "; ".join(incompat_reasons)
|
||||
display_text += f" [Incompatible: {reason_str}]"
|
||||
|
||||
item = QListWidgetItem(display_text, self.browser_list)
|
||||
item.setData(Qt.ItemDataRole.UserRole, plugin)
|
||||
item.setData(Qt.ItemDataRole.UserRole + 1, is_compatible)
|
||||
item.setData(Qt.ItemDataRole.UserRole + 2, is_installed)
|
||||
item.setData(Qt.ItemDataRole.UserRole + 3, update_available)
|
||||
|
||||
if update_available:
|
||||
item.setForeground(QColor("#1976d2"))
|
||||
elif is_installed:
|
||||
item.setForeground(QColor("#2e7d32"))
|
||||
elif not is_compatible:
|
||||
item.setForeground(QColor("#d32f2f"))
|
||||
|
||||
def _on_tab_changed(self, index: int) -> None:
|
||||
if index == 1 and not self._has_fetched_remote:
|
||||
self.fetch_remote_plugins()
|
||||
|
||||
def _on_browser_item_changed(self, current: QListWidgetItem | None, _: Any) -> None:
|
||||
if current:
|
||||
is_compatible = bool(current.data(Qt.ItemDataRole.UserRole + 1))
|
||||
is_installed = bool(current.data(Qt.ItemDataRole.UserRole + 2))
|
||||
update_available = bool(current.data(Qt.ItemDataRole.UserRole + 3))
|
||||
|
||||
if update_available:
|
||||
self.btn_install.setText("Update Plugin")
|
||||
self.btn_install.setEnabled(is_compatible)
|
||||
elif is_installed:
|
||||
self.btn_install.setText("Install Plugin")
|
||||
self.btn_install.setEnabled(False)
|
||||
else:
|
||||
self.btn_install.setText("Install Plugin")
|
||||
self.btn_install.setEnabled(is_compatible)
|
||||
else:
|
||||
self.btn_install.setText("Install Plugin")
|
||||
self.btn_install.setEnabled(False)
|
||||
|
||||
def _on_add_repo(self) -> None:
|
||||
"""Adds a custom repository URL and re-fetches plugins."""
|
||||
url = self.repo_input.text().strip()
|
||||
if url and url not in self.repository_urls:
|
||||
self.repository_urls.append(url)
|
||||
self.repo_input.clear()
|
||||
self.fetch_remote_plugins()
|
||||
|
||||
def _create_installed_tab(self) -> QWidget:
|
||||
tab = QWidget()
|
||||
layout = QVBoxLayout(tab)
|
||||
|
||||
path_layout = QHBoxLayout()
|
||||
path_label = QLabel(f"<b>Plugins Directory:</b> <code>{self.manager.plugins_dir}</code>", tab)
|
||||
btn_open_folder = QPushButton("Open Folder", tab)
|
||||
btn_open_folder.setToolTip("Open this directory in Explorer / Finder")
|
||||
btn_open_folder.clicked.connect(self.manager.open_plugins_directory)
|
||||
|
||||
path_layout.addWidget(path_label)
|
||||
path_layout.addStretch()
|
||||
path_layout.addWidget(btn_open_folder)
|
||||
|
||||
content_layout = QHBoxLayout()
|
||||
|
||||
self.installed_list = QListWidget(tab)
|
||||
self.installed_list.currentItemChanged.connect(self._on_installed_item_changed)
|
||||
|
||||
self.details_panel = QWidget(tab)
|
||||
details_layout = QVBoxLayout(self.details_panel)
|
||||
details_layout.setContentsMargins(10, 0, 0, 0)
|
||||
|
||||
self.lbl_plugin_title = QLabel("Select a plugin", self.details_panel)
|
||||
self.lbl_plugin_title.setStyleSheet("font-size: 13pt; font-weight: bold;")
|
||||
|
||||
self.lbl_plugin_meta = QLabel("", self.details_panel)
|
||||
self.lbl_plugin_status = QLabel("", self.details_panel)
|
||||
|
||||
self.txt_plugin_desc = QTextBrowser(self.details_panel)
|
||||
self.txt_plugin_desc.setPlaceholderText("Select an installed plugin to view details...")
|
||||
|
||||
self.lbl_plugin_path = QLabel("", self.details_panel)
|
||||
self.lbl_plugin_path.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||||
self.lbl_plugin_path.setWordWrap(True)
|
||||
|
||||
details_layout.addWidget(self.lbl_plugin_title)
|
||||
details_layout.addWidget(self.lbl_plugin_meta)
|
||||
details_layout.addWidget(self.lbl_plugin_status)
|
||||
details_layout.addWidget(QLabel("<b>Description:</b>", self.details_panel))
|
||||
details_layout.addWidget(self.txt_plugin_desc)
|
||||
details_layout.addWidget(self.lbl_plugin_path)
|
||||
|
||||
content_layout.addWidget(self.installed_list, stretch=1)
|
||||
content_layout.addWidget(self.details_panel, stretch=1)
|
||||
|
||||
btn_layout = QHBoxLayout()
|
||||
btn_refresh = QPushButton("Refresh List", tab)
|
||||
self.btn_update_plugin = QPushButton("Update Plugin", tab)
|
||||
self.btn_update_plugin.setEnabled(False)
|
||||
self.btn_update_plugin.setStyleSheet("font-weight: bold; color: #1976d2;")
|
||||
btn_enable = QPushButton("Enable / Disable", tab)
|
||||
btn_uninstall = QPushButton("Uninstall", tab)
|
||||
|
||||
btn_refresh.clicked.connect(self.refresh_installed_plugins)
|
||||
self.btn_update_plugin.clicked.connect(self.update_selected_plugin)
|
||||
btn_enable.clicked.connect(self.toggle_enable_disable)
|
||||
btn_uninstall.clicked.connect(self.uninstall_plugin)
|
||||
|
||||
btn_layout.addWidget(btn_refresh)
|
||||
btn_layout.addWidget(self.btn_update_plugin)
|
||||
btn_layout.addStretch()
|
||||
btn_layout.addWidget(btn_enable)
|
||||
btn_layout.addWidget(btn_uninstall)
|
||||
|
||||
layout.addLayout(path_layout)
|
||||
layout.addLayout(content_layout)
|
||||
layout.addLayout(btn_layout)
|
||||
return tab
|
||||
|
||||
def _create_browser_tab(self) -> QWidget:
|
||||
tab = QWidget()
|
||||
layout = QVBoxLayout(tab)
|
||||
|
||||
info = QLabel(
|
||||
f"<b>Platform:</b> <code>{self.manager.current_platform}</code> | "
|
||||
f"<b>App Version:</b> <code>v{CURRENT_VERSION}</code>", tab
|
||||
)
|
||||
|
||||
repo_layout = QHBoxLayout()
|
||||
self.repo_input = QLineEdit(tab)
|
||||
self.repo_input.setPlaceholderText("Enter custom plugins.json URL...")
|
||||
btn_add = QPushButton("Add Repo", tab)
|
||||
btn_add.clicked.connect(self._on_add_repo)
|
||||
|
||||
repo_layout.addWidget(self.repo_input)
|
||||
repo_layout.addWidget(btn_add)
|
||||
|
||||
self.browser_list = QListWidget(tab)
|
||||
self.browser_list.currentItemChanged.connect(self._on_browser_item_changed)
|
||||
|
||||
btn_layout = QHBoxLayout()
|
||||
self.btn_fetch = QPushButton("Fetch Remote Lists", tab)
|
||||
self.btn_install = QPushButton("Install Plugin", tab)
|
||||
self.btn_install.setEnabled(False)
|
||||
|
||||
self.btn_fetch.clicked.connect(self.fetch_remote_plugins)
|
||||
self.btn_install.clicked.connect(self.install_selected_plugin)
|
||||
|
||||
btn_layout.addWidget(self.btn_fetch)
|
||||
btn_layout.addStretch()
|
||||
btn_layout.addWidget(self.btn_install)
|
||||
|
||||
layout.addWidget(info)
|
||||
layout.addLayout(repo_layout)
|
||||
layout.addWidget(self.browser_list)
|
||||
layout.addLayout(btn_layout)
|
||||
return tab
|
||||
|
||||
def toggle_enable_disable(self) -> None:
|
||||
selected = self.installed_list.currentItem()
|
||||
if not selected:
|
||||
return
|
||||
raw_info = selected.data(Qt.ItemDataRole.UserRole)
|
||||
if isinstance(raw_info, dict):
|
||||
info = cast(dict[str, Any], raw_info)
|
||||
plugin_path = info.get("path")
|
||||
if isinstance(plugin_path, str) and plugin_path:
|
||||
self.manager.toggle_plugin_state(Path(plugin_path))
|
||||
elif isinstance(plugin_path, Path):
|
||||
self.manager.toggle_plugin_state(plugin_path)
|
||||
|
||||
def uninstall_plugin(self) -> None:
|
||||
selected = self.installed_list.currentItem()
|
||||
if not selected:
|
||||
return
|
||||
|
||||
raw_info = selected.data(Qt.ItemDataRole.UserRole)
|
||||
if not isinstance(raw_info, dict):
|
||||
return
|
||||
|
||||
info = cast(dict[str, Any], raw_info)
|
||||
plugin_path = info.get("path")
|
||||
plugin_name = info.get("name", "this plugin")
|
||||
|
||||
if not plugin_path:
|
||||
return
|
||||
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"Confirm Uninstall",
|
||||
f"Are you sure you want to delete '{plugin_name}'?",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
)
|
||||
if reply == QMessageBox.StandardButton.Yes:
|
||||
if isinstance(plugin_path, str):
|
||||
self.manager.uninstall_plugin(Path(plugin_path))
|
||||
elif isinstance(plugin_path, Path):
|
||||
self.manager.uninstall_plugin(plugin_path)
|
||||
|
||||
|
||||
def _clear_details_panel(self) -> None:
|
||||
"""Clears details display when nothing is selected."""
|
||||
self.lbl_plugin_title.setText("Select a plugin")
|
||||
self.lbl_plugin_meta.setText("")
|
||||
self.lbl_plugin_status.setText("")
|
||||
self.txt_plugin_desc.clear()
|
||||
self.lbl_plugin_path.setText("")
|
||||
@@ -17,8 +17,10 @@ from PySide6.QtWidgets import QWidget, QVBoxLayout, QTextEdit, QLineEdit, QMainW
|
||||
from PySide6.QtCore import QProcess, Qt, QThread, Signal
|
||||
|
||||
from file_ext_registration import register_file_association, is_windows_admin
|
||||
from plugin_manager import PluginManager
|
||||
from src.shared.shareddata import API_URL, API_URL_SECONDARY, APP_NAME, CURRENT_VERSION, PLATFORM_NAME
|
||||
from src.window.about import AboutWindow
|
||||
from src.window.plugins import PluginsWindow
|
||||
from updater import UpdateManager
|
||||
|
||||
|
||||
@@ -42,7 +44,7 @@ class _AssocWorker(QThread):
|
||||
|
||||
|
||||
class TerminalWindow(QWidget):
|
||||
def __init__(self, parent: QWidget | None = None) -> None:
|
||||
def __init__(self, parent: QWidget | None, plugin_manager: PluginManager) -> None:
|
||||
super().__init__(parent, Qt.WindowType.Window)
|
||||
self.setWindowTitle(f"Terminal - {APP_NAME.upper()}")
|
||||
self.resize(320, 180)
|
||||
@@ -58,6 +60,7 @@ class TerminalWindow(QWidget):
|
||||
self.setLayout(layout)
|
||||
|
||||
self._process: QProcess | None = None
|
||||
self.plugin_manager = plugin_manager
|
||||
|
||||
self.commands: dict[str, Callable[..., Any]] = {
|
||||
"hello": self.cmd_hello,
|
||||
@@ -66,6 +69,7 @@ class TerminalWindow(QWidget):
|
||||
"about": self.cmd_about,
|
||||
"assoc": self.cmd_assoc,
|
||||
"update": self.cmd_update,
|
||||
"plugins": self.cmd_plugins,
|
||||
"utest": self.cmd_utest,
|
||||
}
|
||||
|
||||
@@ -122,6 +126,10 @@ class TerminalWindow(QWidget):
|
||||
self.about = AboutWindow(self)
|
||||
self.about.show()
|
||||
|
||||
def cmd_plugins(self, *args: Any) -> None:
|
||||
self.about = PluginsWindow(self, self.plugin_manager)
|
||||
self.about.show()
|
||||
|
||||
def cmd_update(self, *args: Any) -> str:
|
||||
main_win = self.parent()
|
||||
if not isinstance(main_win, QMainWindow):
|
||||
|
||||
+198
-167
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Filename: updateevents.py
|
||||
Description: Methods to update snirf events for FLARES
|
||||
Description: Methods to update snirf events
|
||||
Note: Compliant with pylance strict type checking
|
||||
|
||||
Author: Tyler de Zeeuw
|
||||
License: GPL-3.0
|
||||
@@ -9,8 +10,9 @@ License: GPL-3.0
|
||||
# Built-in imports
|
||||
import os
|
||||
import json
|
||||
import concurrent.futures
|
||||
from enum import Enum, auto
|
||||
from typing import Any, List, Optional, cast
|
||||
from typing import Any, List, Optional, Sequence, Union, cast
|
||||
|
||||
# External library imports
|
||||
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QLineEdit, QPushButton, QComboBox, QHBoxLayout, QMessageBox, QFileDialog
|
||||
@@ -20,16 +22,172 @@ from mne import Annotations
|
||||
from mne.io import read_raw_snirf #type: ignore
|
||||
from mne_nirs.io import write_raw_snirf #type: ignore
|
||||
|
||||
from mne.io.base import BaseRaw
|
||||
from src.shared.shareddata import APP_NAME
|
||||
|
||||
|
||||
def _load_annotations_worker(file_path: str) -> Annotations:
|
||||
"""Isolated worker process to load SNIRF annotations without leaving HDF5 memory leaks in main process."""
|
||||
raw: BaseRaw = read_raw_snirf(file_path, preload=False, verbose=False)
|
||||
return cast(Annotations, getattr(raw, "annotations"))
|
||||
|
||||
|
||||
def load_snirf_annotations_isolated(file_path: str) -> Annotations:
|
||||
"""Spawns a short-lived process to extract annotations and cleanly releases C-memory allocations."""
|
||||
with concurrent.futures.ProcessPoolExecutor(max_workers=1) as executor:
|
||||
future = executor.submit(_load_annotations_worker, file_path)
|
||||
return future.result()
|
||||
|
||||
|
||||
def _write_snirf_worker(
|
||||
file_path: str,
|
||||
save_path: str,
|
||||
onsets: list[float],
|
||||
durations: list[float],
|
||||
descriptions: list[str]
|
||||
) -> None:
|
||||
"""Isolated process worker to read, annotate, write, and immediately free memory/file handles."""
|
||||
raw = read_raw_snirf(file_path, preload=True, verbose=False)
|
||||
new_annotations = Annotations(onset=onsets, duration=durations, description=descriptions)
|
||||
raw.set_annotations(new_annotations) #type: ignore
|
||||
write_raw_snirf(raw, save_path)
|
||||
|
||||
|
||||
def write_snirf_with_annotations_isolated(
|
||||
file_path: str,
|
||||
save_path: str,
|
||||
onsets: list[float],
|
||||
durations: list[float],
|
||||
descriptions: list[str]
|
||||
) -> None:
|
||||
"""Executes SNIRF writing in a dedicated worker process to ensure 100% memory/handle cleanup."""
|
||||
with concurrent.futures.ProcessPoolExecutor(max_workers=1) as executor:
|
||||
future = executor.submit(
|
||||
_write_snirf_worker,
|
||||
file_path,
|
||||
save_path,
|
||||
onsets,
|
||||
durations,
|
||||
descriptions
|
||||
)
|
||||
future.result()
|
||||
|
||||
|
||||
def _align_boris_and_write_snirf_worker(
|
||||
file_path: str,
|
||||
save_path: str,
|
||||
boris_events: list[Any],
|
||||
time_shift: float
|
||||
) -> int:
|
||||
"""
|
||||
Isolated process worker to read SNIRF, align BORIS events, write updated file,
|
||||
and cleanly exit releasing all HDF5 C-memory allocations and file locks.
|
||||
"""
|
||||
raw = read_raw_snirf(file_path, preload=True, verbose=False)
|
||||
|
||||
# Type-safe extraction of sampling frequency from raw.info
|
||||
info_dict = cast(dict[str, Any], raw.info)
|
||||
sfreq = float(info_dict.get("sfreq", 10.0))
|
||||
min_shift = 1.0 / sfreq if sfreq > 0 else 0.1
|
||||
max_attempts = 10
|
||||
|
||||
onsets: list[float] = []
|
||||
durations: list[float] = []
|
||||
descriptions: list[str] = []
|
||||
|
||||
open_events: dict[str, list[float]] = {}
|
||||
label_counts: dict[str, int] = {}
|
||||
used_times: set[float] = set()
|
||||
|
||||
for raw_event in boris_events:
|
||||
if not isinstance(raw_event, (list, tuple)):
|
||||
continue
|
||||
|
||||
event = cast(Sequence[Any], raw_event)
|
||||
|
||||
if len(event) < 3:
|
||||
continue
|
||||
|
||||
event_time = float(event[0])
|
||||
label = str(event[2])
|
||||
|
||||
count = label_counts.get(label, 0) + 1
|
||||
label_counts[label] = count
|
||||
|
||||
if label not in open_events:
|
||||
open_events[label] = []
|
||||
|
||||
if count % 2 == 1:
|
||||
open_events[label].append(event_time)
|
||||
else:
|
||||
if open_events[label]:
|
||||
start_time = open_events[label].pop(0)
|
||||
duration = event_time - start_time
|
||||
if duration <= 0:
|
||||
continue
|
||||
|
||||
adjusted_time = start_time + time_shift
|
||||
attempts = 0
|
||||
while round(adjusted_time, 6) in used_times and attempts < max_attempts:
|
||||
adjusted_time += min_shift
|
||||
attempts += 1
|
||||
if attempts == max_attempts:
|
||||
continue
|
||||
|
||||
adjusted_time = round(adjusted_time, 6)
|
||||
used_times.add(adjusted_time)
|
||||
onsets.append(adjusted_time)
|
||||
durations.append(round(duration, 6))
|
||||
descriptions.append(label)
|
||||
|
||||
# Handle unmatched start markers
|
||||
for label, starts in open_events.items():
|
||||
for start_time in starts:
|
||||
adjusted_time = start_time + time_shift
|
||||
attempts = 0
|
||||
while round(adjusted_time, 6) in used_times and attempts < max_attempts:
|
||||
adjusted_time += min_shift
|
||||
attempts += 1
|
||||
if attempts == max_attempts:
|
||||
continue
|
||||
adjusted_time = round(adjusted_time, 6)
|
||||
used_times.add(adjusted_time)
|
||||
onsets.append(adjusted_time)
|
||||
durations.append(0.0)
|
||||
descriptions.append(label)
|
||||
|
||||
new_annotations = Annotations(onset=onsets, duration=durations, description=descriptions)
|
||||
raw.set_annotations(new_annotations) #type: ignore
|
||||
write_raw_snirf(raw, save_path)
|
||||
|
||||
return len(onsets)
|
||||
|
||||
|
||||
def align_boris_and_write_snirf_isolated(
|
||||
file_path: str,
|
||||
save_path: str,
|
||||
boris_events: list[Any],
|
||||
time_shift: float
|
||||
) -> int:
|
||||
"""Executes BORIS alignment and SNIRF writing in a dedicated worker process."""
|
||||
with concurrent.futures.ProcessPoolExecutor(max_workers=1) as executor:
|
||||
future = executor.submit(
|
||||
_align_boris_and_write_snirf_worker,
|
||||
file_path,
|
||||
save_path,
|
||||
boris_events,
|
||||
time_shift
|
||||
)
|
||||
return future.result()
|
||||
|
||||
|
||||
class EventUpdateMode(Enum):
|
||||
WRITE_SNIRF = auto() # destructive
|
||||
WRITE_JSON = auto() # non-destructive
|
||||
|
||||
|
||||
class UpdateEventsWindow(QWidget):
|
||||
def __init__(self, parent: Optional[QWidget]=None, mode=EventUpdateMode.WRITE_SNIRF, caller=None):
|
||||
def __init__(self, parent: Optional[QWidget]=None, mode: EventUpdateMode=EventUpdateMode.WRITE_SNIRF, caller: Optional[Union[str, object]] = None,):
|
||||
super().__init__(parent, Qt.WindowType.Window)
|
||||
|
||||
self.mode = mode
|
||||
@@ -210,12 +368,13 @@ class UpdateEventsWindow(QWidget):
|
||||
if file_path:
|
||||
self.line_edit_file_a.setText(file_path)
|
||||
try:
|
||||
# TODO: Bad! read_raw_snirf doesnt release memory properly! Should be spawned in a seperate process and killed once completed
|
||||
raw = read_raw_snirf(file_path, preload=False)
|
||||
annotations = raw.annotations
|
||||
# Memory leak safe: Extracted in a separate process that terminates immediately
|
||||
annotations: Annotations = load_snirf_annotations_isolated(file_path)
|
||||
|
||||
print(f"Loaded {len(annotations)} annotations from {file_path}")
|
||||
|
||||
# Build individual event entries
|
||||
event_entries = []
|
||||
event_entries: list[str] = []
|
||||
for onset, description in zip(annotations.onset, annotations.description):
|
||||
event_str = f"{description} @ {onset:.3f}s"
|
||||
event_entries.append(event_str)
|
||||
@@ -252,6 +411,7 @@ class UpdateEventsWindow(QWidget):
|
||||
except (json.JSONDecodeError, FileNotFoundError, KeyError) as e:
|
||||
QMessageBox.warning(self, "Error", f"Failed to parse BORIS file:\n{e}")
|
||||
|
||||
|
||||
def extract_boris_observation_keys(self, data: dict[str, Any]) -> List[str]:
|
||||
if "observations" not in data:
|
||||
raise KeyError("Missing 'observations' key in BORIS file.")
|
||||
@@ -274,11 +434,16 @@ class UpdateEventsWindow(QWidget):
|
||||
self.combo_events.setEnabled(False)
|
||||
return
|
||||
|
||||
event_entries = []
|
||||
for event in events:
|
||||
if isinstance(event, list) and len(event) >= 3:
|
||||
timestamp = event[0]
|
||||
label = event[2]
|
||||
event_entries: list[str] = []
|
||||
for raw_event in events:
|
||||
if not isinstance(raw_event, (list, tuple)):
|
||||
continue
|
||||
|
||||
event = cast(Sequence[Any], raw_event)
|
||||
|
||||
if len(event) >= 3:
|
||||
timestamp = float(event[0])
|
||||
label = str(event[2])
|
||||
display = f"{label} @ {timestamp:.3f}"
|
||||
event_entries.append(display)
|
||||
|
||||
@@ -363,71 +528,15 @@ class UpdateEventsWindow(QWidget):
|
||||
save_path += ".snirf"
|
||||
|
||||
try:
|
||||
raw = read_raw_snirf(file_a, preload=True)
|
||||
# Memory leak safe: Worker handles alignment calculation, read, write, and process termination
|
||||
event_count = align_boris_and_write_snirf_isolated(
|
||||
file_a,
|
||||
save_path,
|
||||
boris_events,
|
||||
time_shift
|
||||
)
|
||||
|
||||
# --- Align BORIS events to SNIRF ---
|
||||
boris_events = boris_obs.get("events", [])
|
||||
onsets, durations, descriptions = [], [], []
|
||||
open_events = {} # label -> list of start times
|
||||
label_counts = {}
|
||||
used_times = set()
|
||||
sfreq = raw.info['sfreq']
|
||||
min_shift = 1.0 / sfreq
|
||||
max_attempts = 10
|
||||
|
||||
for event in boris_events:
|
||||
if not isinstance(event, list) or len(event) < 3:
|
||||
continue
|
||||
event_time = event[0]
|
||||
label = event[2]
|
||||
count = label_counts.get(label, 0) + 1
|
||||
label_counts[label] = count
|
||||
|
||||
if label not in open_events:
|
||||
open_events[label] = []
|
||||
|
||||
if count % 2 == 1:
|
||||
open_events[label].append(event_time)
|
||||
else:
|
||||
if open_events[label]:
|
||||
start_time = open_events[label].pop(0)
|
||||
duration = event_time - start_time
|
||||
if duration <= 0:
|
||||
continue
|
||||
|
||||
adjusted_time = start_time + time_shift
|
||||
attempts = 0
|
||||
while round(adjusted_time, 6) in used_times and attempts < max_attempts:
|
||||
adjusted_time += min_shift
|
||||
attempts += 1
|
||||
if attempts == max_attempts:
|
||||
continue
|
||||
|
||||
adjusted_time = round(adjusted_time, 6)
|
||||
used_times.add(adjusted_time)
|
||||
onsets.append(adjusted_time)
|
||||
durations.append(duration)
|
||||
descriptions.append(label)
|
||||
# Handle unmatched starts
|
||||
for label, starts in open_events.items():
|
||||
for start_time in starts:
|
||||
adjusted_time = start_time + time_shift
|
||||
attempts = 0
|
||||
while round(adjusted_time, 6) in used_times and attempts < max_attempts:
|
||||
adjusted_time += min_shift
|
||||
attempts += 1
|
||||
if attempts == max_attempts:
|
||||
continue
|
||||
adjusted_time = round(adjusted_time, 6)
|
||||
used_times.add(adjusted_time)
|
||||
onsets.append(adjusted_time)
|
||||
durations.append(0.0)
|
||||
descriptions.append(label)
|
||||
|
||||
new_annotations = Annotations(onset=onsets, duration=durations, description=descriptions)
|
||||
raw.set_annotations(new_annotations)
|
||||
write_raw_snirf(raw, save_path)
|
||||
QMessageBox.information(self, "Success", "SNIRF file updated with aligned BORIS events.")
|
||||
QMessageBox.information(self, "Success", f"SNIRF file updated with {event_count} aligned BORIS events.")
|
||||
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Error", f"Failed to update SNIRF file:\n{e}")
|
||||
@@ -466,79 +575,9 @@ class UpdateEventsWindow(QWidget):
|
||||
QMessageBox.critical(self, "Error", f"Failed to write JSON:\n{e}")
|
||||
|
||||
|
||||
# def update_optode_positions(self, file_a, file_b, save_path):
|
||||
|
||||
# fiducials = {}
|
||||
# ch_positions = {}
|
||||
|
||||
# # Read the lines from the optode file
|
||||
# with open(file_b, 'r') as f:
|
||||
# for line in f:
|
||||
# if line.strip():
|
||||
# # Split by the semicolon and convert to meters
|
||||
# ch_name, coords_str = line.split(":")
|
||||
# coords = np.array(list(map(float, coords_str.strip().split()))) * 0.001
|
||||
|
||||
# # The key we have is a fiducial
|
||||
# if ch_name.lower() in ['lpa', 'nz', 'rpa']:
|
||||
# fiducials[ch_name.lower()] = coords
|
||||
|
||||
# # The key we have is a source or detector
|
||||
# else:
|
||||
# ch_positions[ch_name.upper()] = coords
|
||||
|
||||
# # Create montage with updated coords in head space
|
||||
# initial_montage = make_dig_montage(ch_pos=ch_positions, nasion=fiducials.get('nz'), lpa=fiducials.get('lpa'), rpa=fiducials.get('rpa'), coord_frame='head') # type: ignore
|
||||
|
||||
# # Read the SNIRF file, set the montage, and write it back
|
||||
# # TODO: Bad! read_raw_snirf doesnt release memory properly! Should be spawned in a seperate process and killed once completed
|
||||
# raw = read_raw_snirf(file_a, preload=True)
|
||||
# raw.set_montage(initial_montage)
|
||||
# write_raw_snirf(raw, save_path)
|
||||
|
||||
|
||||
# def _apply_events_to_snirf(self, raw, new_annotations, save_path):
|
||||
# raw.set_annotations(new_annotations)
|
||||
# write_raw_snirf(raw, save_path)
|
||||
|
||||
# def _write_event_mapping_json(
|
||||
# self,
|
||||
# file_a,
|
||||
# file_b,
|
||||
# selected_obs,
|
||||
# snirf_anchor,
|
||||
# boris_anchor,
|
||||
# time_shift,
|
||||
# mapped_events,
|
||||
# save_path
|
||||
# ):
|
||||
|
||||
# payload = {
|
||||
# "source": {
|
||||
# "called_from": self.caller,
|
||||
# "snirf_file": os.path.basename(file_a),
|
||||
# "boris_file": os.path.basename(file_b),
|
||||
# "observation": selected_obs
|
||||
# },
|
||||
# "alignment": {
|
||||
# "snirf_anchor": snirf_anchor,
|
||||
# "boris_anchor": boris_anchor,
|
||||
# "time_shift_seconds": time_shift
|
||||
# },
|
||||
# "events": mapped_events,
|
||||
# "created_at": datetime.utcnow().isoformat() + "Z"
|
||||
# }
|
||||
|
||||
# with open(save_path, "w", encoding="utf-8") as f:
|
||||
# json.dump(payload, f, indent=2)
|
||||
|
||||
# return save_path
|
||||
|
||||
|
||||
|
||||
class UpdateEventsBlazesWindow(QWidget):
|
||||
|
||||
def __init__(self, parent: Optional[QWidget]=None, mode=EventUpdateMode.WRITE_SNIRF, caller=None):
|
||||
def __init__(self, parent: Optional[QWidget]=None, mode: EventUpdateMode=EventUpdateMode.WRITE_SNIRF, caller: Optional[Union[str, object]] = None,):
|
||||
super().__init__(parent, Qt.WindowType.Window)
|
||||
|
||||
self.mode = mode
|
||||
@@ -692,12 +731,13 @@ class UpdateEventsBlazesWindow(QWidget):
|
||||
if file_path:
|
||||
self.line_edit_file_a.setText(file_path)
|
||||
try:
|
||||
# TODO: Bad! read_raw_snirf doesnt release memory properly! Should be spawned in a seperate process and killed once completed
|
||||
raw = read_raw_snirf(file_path, preload=False)
|
||||
annotations = raw.annotations
|
||||
# Memory leak safe: Extracted in a separate process that terminates immediately
|
||||
annotations: Annotations = load_snirf_annotations_isolated(file_path)
|
||||
|
||||
print(f"Loaded {len(annotations)} annotations from {file_path}")
|
||||
|
||||
# Build individual event entries
|
||||
event_entries = []
|
||||
event_entries: list[str] = []
|
||||
for onset, description in zip(annotations.onset, annotations.description):
|
||||
event_str = f"{description} @ {onset:.3f}s"
|
||||
event_entries.append(event_str)
|
||||
@@ -748,12 +788,11 @@ class UpdateEventsBlazesWindow(QWidget):
|
||||
|
||||
event_strings: List[str] = []
|
||||
|
||||
# The new format is a flat list chronologically ordered
|
||||
# Flat list chronologically ordered
|
||||
for event in data["events"]:
|
||||
track_name = event.get("track_name", "Unknown")
|
||||
onset = event.get("start_sec", 0.0)
|
||||
|
||||
# Formatting to match your SNIRF style: "Event Name @ 0.000s"
|
||||
display_str = f"{track_name} @ {onset:.3f}s"
|
||||
event_strings.append(display_str)
|
||||
|
||||
@@ -792,7 +831,9 @@ class UpdateEventsBlazesWindow(QWidget):
|
||||
|
||||
time_shift = snirf_anchor_time - json_anchor_time
|
||||
|
||||
onsets, durations, descriptions = [], [], []
|
||||
onsets: list[float] = []
|
||||
durations: list[float] = []
|
||||
descriptions: list[str] = []
|
||||
skipped_count = 0
|
||||
|
||||
try:
|
||||
@@ -806,7 +847,7 @@ class UpdateEventsBlazesWindow(QWidget):
|
||||
original_end = event.get("end_sec", original_start)
|
||||
duration = original_end - original_start
|
||||
|
||||
# FILTER: Minimum 0.1s duration
|
||||
# Minimum 0.1s duration
|
||||
if duration < 0.1:
|
||||
skipped_count += 1
|
||||
continue
|
||||
@@ -834,19 +875,9 @@ class UpdateEventsBlazesWindow(QWidget):
|
||||
if not save_path.lower().endswith(".snirf"): save_path += ".snirf"
|
||||
|
||||
try:
|
||||
raw = read_raw_snirf(file_a, preload=True)
|
||||
# Memory leak safe: Worker handles read, write, and complete memory reclamation upon exit
|
||||
write_snirf_with_annotations_isolated(file_a, save_path, onsets, durations, descriptions)
|
||||
|
||||
# Create annotations
|
||||
new_annotations = Annotations(
|
||||
onset=onsets,
|
||||
duration=durations,
|
||||
description=descriptions
|
||||
)
|
||||
|
||||
# Replace existing annotations with the new aligned JSON tracks
|
||||
raw.set_annotations(new_annotations)
|
||||
|
||||
write_raw_snirf(raw, save_path)
|
||||
QMessageBox.information(self, "Success",
|
||||
f"Aligned {len(onsets)} events.\n(Filtered out {skipped_count} short events)")
|
||||
except Exception as e:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""
|
||||
Filename: updateoptodes.py
|
||||
Description: Methods to update optode locations for FLARES
|
||||
Description: Methods to update optode locations
|
||||
Note: Compliant with pylance strict type checking
|
||||
|
||||
Author: Tyler de Zeeuw
|
||||
@@ -8,7 +8,6 @@ License: GPL-3.0
|
||||
"""
|
||||
|
||||
# Built-in imports
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional, Union
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ class ViewerLauncherWidget(QWidget):
|
||||
("Inter-Group Stats Viewer", InterGroupStatsWidget, [haemo_dict, cha_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, roi_channel_map_dict, group_dict], True),
|
||||
("Intra-Group Brain and Image Viewer", IntraGroupBrainImageWidget, [haemo_dict, cha_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, group_dict], True),
|
||||
("Inter-Group Brain and Image Viewer", InterGroupBrainImageWidget, [haemo_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, group_dict], True),
|
||||
("Export To CSV Viewer", ExportToCSVWidget, [haemo_dict, cha_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, group_dict, config_dict, fir_feature_dict, qc_dict], True)
|
||||
("Export To CSV Viewer", ExportToCSVWidget, [haemo_dict, epochs_dict, cha_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, group_dict, config_dict, fir_feature_dict, qc_dict], True)
|
||||
]
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
+91
-83
@@ -18,7 +18,8 @@ import zipfile
|
||||
import traceback
|
||||
import subprocess
|
||||
import configparser
|
||||
from typing import List
|
||||
from typing import List, Tuple, Optional, Callable, Any
|
||||
|
||||
|
||||
# External library imports
|
||||
import psutil
|
||||
@@ -26,7 +27,7 @@ import requests
|
||||
|
||||
from PySide6.QtCore import QThread, Signal, QObject
|
||||
from PySide6.QtWidgets import QMainWindow, QMessageBox
|
||||
|
||||
from src.shared.shareddata import get_app_dir
|
||||
|
||||
class UpdateDownloadThread(QThread):
|
||||
"""
|
||||
@@ -54,7 +55,7 @@ class UpdateDownloadThread(QThread):
|
||||
self.platform_name = platform_name
|
||||
self.app_name = app_name
|
||||
|
||||
def run(self):
|
||||
def run(self) -> None:
|
||||
try:
|
||||
local_filename = os.path.basename(self.download_url)
|
||||
|
||||
@@ -63,8 +64,8 @@ class UpdateDownloadThread(QThread):
|
||||
os.makedirs(tmp_dir, exist_ok=True)
|
||||
local_path = os.path.join(tmp_dir, local_filename)
|
||||
else:
|
||||
tmp_dir = os.getcwd()
|
||||
local_path = os.path.join(os.getcwd(), local_filename)
|
||||
tmp_dir = get_app_dir()
|
||||
local_path = os.path.join(tmp_dir, local_filename)
|
||||
|
||||
# Download the file
|
||||
with requests.get(self.download_url, stream=True, timeout=15) as r:
|
||||
@@ -78,10 +79,9 @@ class UpdateDownloadThread(QThread):
|
||||
if self.platform_name == 'darwin':
|
||||
extract_folder = os.path.splitext(local_filename)[0]
|
||||
extract_path = os.path.join(tmp_dir, extract_folder)
|
||||
|
||||
else:
|
||||
extract_folder = os.path.splitext(local_filename)[0]
|
||||
extract_path = os.path.join(os.getcwd(), extract_folder)
|
||||
extract_path = os.path.join(get_app_dir(), extract_folder)
|
||||
|
||||
# Create the folder if not exists
|
||||
os.makedirs(extract_path, exist_ok=True)
|
||||
@@ -132,10 +132,7 @@ class UpdateCheckThread(QThread):
|
||||
self.platform_name = platform_name
|
||||
self.app_name = app_name
|
||||
|
||||
def run(self):
|
||||
# if not getattr(sys, 'frozen', False):
|
||||
# self.error_occurred.emit("Application is not frozen (Development mode).")
|
||||
# return
|
||||
def run(self) -> None:
|
||||
try:
|
||||
latest_version, download_url = self.get_latest_release_for_platform()
|
||||
if not latest_version:
|
||||
@@ -159,11 +156,10 @@ class UpdateCheckThread(QThread):
|
||||
return [int(x) for x in v.split(".")]
|
||||
return (normalize(v1) > normalize(v2)) - (normalize(v1) < normalize(v2))
|
||||
|
||||
def get_latest_release_for_platform(self):
|
||||
def get_latest_release_for_platform(self) -> Tuple[Optional[str], Optional[str]]:
|
||||
urls = [self.api_url, self.api_url_sec]
|
||||
for url in urls:
|
||||
try:
|
||||
|
||||
response = requests.get(url, timeout=5)
|
||||
response.raise_for_status()
|
||||
releases = response.json()
|
||||
@@ -219,11 +215,11 @@ class LocalPendingUpdateCheckThread(QThread):
|
||||
return [int(x) for x in v.split(".")]
|
||||
return (normalize(v1) > normalize(v2)) - (normalize(v1) < normalize(v2))
|
||||
|
||||
def run(self):
|
||||
def run(self) -> None:
|
||||
if self.platform_name == 'darwin':
|
||||
cwd = f'/tmp/{self.app_name}tempupdate'
|
||||
else:
|
||||
cwd = os.getcwd()
|
||||
cwd = get_app_dir()
|
||||
|
||||
pattern = re.compile(r".*-(\d+\.\d+\.\d+)" + re.escape(self.platform_suffix) + r"$")
|
||||
found = False
|
||||
@@ -239,7 +235,7 @@ class LocalPendingUpdateCheckThread(QThread):
|
||||
self.pending_update_found.emit(folder_version, folder_path)
|
||||
found = True
|
||||
break
|
||||
except:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not found:
|
||||
@@ -272,9 +268,8 @@ class UpdateManager(QObject):
|
||||
self.platform_suffix = platform_suffix
|
||||
self.app_name = app_name
|
||||
|
||||
self.pending_update_version = None
|
||||
self.pending_update_path = None
|
||||
|
||||
self.pending_update_version: Optional[str] = None
|
||||
self.pending_update_path: Optional[str] = None
|
||||
|
||||
def manual_check_for_updates(self) -> None:
|
||||
self.local_check_thread = LocalPendingUpdateCheckThread(self.current_version, self.platform_suffix, self.platform_name, self.app_name)
|
||||
@@ -283,13 +278,14 @@ class UpdateManager(QObject):
|
||||
self.local_check_thread.start()
|
||||
|
||||
def on_pending_update_found(self, version: str, folder_path: str) -> None:
|
||||
if self.main_window.statusBar():
|
||||
self.main_window.statusBar().showMessage(f"Pending update found: version {version}")
|
||||
self.pending_update_version = version
|
||||
self.pending_update_path = folder_path
|
||||
self.show_pending_update_popup()
|
||||
|
||||
def on_no_pending_update(self) -> None:
|
||||
# No pending update found locally, start server check directly
|
||||
if self.main_window.statusBar():
|
||||
self.main_window.statusBar().showMessage("No pending local update found. Checking server...")
|
||||
self.start_update_check_thread()
|
||||
|
||||
@@ -306,7 +302,6 @@ class UpdateManager(QObject):
|
||||
else:
|
||||
if self.main_window.statusBar():
|
||||
self.main_window.statusBar().showMessage("Pending update available. Install later.")
|
||||
# After user dismisses, still check the server for new updates
|
||||
self.start_update_check_thread()
|
||||
|
||||
def start_update_check_thread(self) -> None:
|
||||
@@ -327,12 +322,14 @@ class UpdateManager(QObject):
|
||||
if pending_version and pending_path:
|
||||
cmp = self.version_compare(latest_version, pending_version)
|
||||
if cmp > 0:
|
||||
# Server version is newer than pending update
|
||||
if self.main_window.statusBar():
|
||||
self.main_window.statusBar().showMessage(f"Newer version {latest_version} available on server. Removing old pending update...")
|
||||
try:
|
||||
shutil.rmtree(pending_path)
|
||||
if self.main_window.statusBar():
|
||||
self.main_window.statusBar().showMessage(f"Deleted old update folder: {pending_path}")
|
||||
except Exception as e:
|
||||
if self.main_window.statusBar():
|
||||
self.main_window.statusBar().showMessage(f"Failed to delete old update folder: {e}")
|
||||
|
||||
# Clear pending update info so new download proceeds
|
||||
@@ -342,7 +339,7 @@ class UpdateManager(QObject):
|
||||
# Download the new update
|
||||
self.download_update(download_url, latest_version)
|
||||
elif cmp == 0:
|
||||
# Versions equal, no download needed
|
||||
if self.main_window.statusBar():
|
||||
self.main_window.statusBar().showMessage(f"Pending update version {self.pending_update_version} is already latest. No download needed.")
|
||||
else:
|
||||
# Server version older than pending? Unlikely but just keep pending update
|
||||
@@ -374,24 +371,26 @@ class UpdateManager(QObject):
|
||||
if msg_box.clickedButton() == install_now_button:
|
||||
self.install_update(extract_folder)
|
||||
else:
|
||||
if self.main_window.statusBar():
|
||||
self.main_window.statusBar().showMessage("Update ready. Install later.")
|
||||
|
||||
|
||||
def install_update(self, extract_folder: str) -> None:
|
||||
# Path to updater executable
|
||||
base_dir = get_app_dir()
|
||||
|
||||
# Path to updater executable
|
||||
if self.platform_name == 'windows':
|
||||
updater_path = os.path.join(os.getcwd(), f"{self.app_name}_updater.exe")
|
||||
updater_path = os.path.join(base_dir, f"{self.app_name}_updater.exe")
|
||||
elif self.platform_name == 'darwin':
|
||||
if getattr(sys, 'frozen', False):
|
||||
updater_path = os.path.join(os.path.dirname(sys.executable), f"../../../{self.app_name}_updater.app")
|
||||
updater_path = os.path.join(base_dir, f"../../../{self.app_name}_updater.app")
|
||||
else:
|
||||
updater_path = os.path.join(os.getcwd(), f"../{self.app_name}_updater.app")
|
||||
|
||||
updater_path = os.path.join(base_dir, f"../{self.app_name}_updater.app")
|
||||
elif self.platform_name == 'linux':
|
||||
updater_path = os.path.join(os.getcwd(), f"{self.app_name}_updater")
|
||||
updater_path = os.path.join(base_dir, f"{self.app_name}_updater")
|
||||
else:
|
||||
updater_path = os.getcwd()
|
||||
updater_path = base_dir
|
||||
|
||||
updater_path = os.path.abspath(updater_path)
|
||||
|
||||
if not os.path.exists(updater_path):
|
||||
QMessageBox.critical(self.main_window, "Error", f"Updater not found at:\n{updater_path}. The absolute path was {os.path.abspath(updater_path)}")
|
||||
@@ -399,8 +398,7 @@ class UpdateManager(QObject):
|
||||
|
||||
# Launch updater with extracted folder path as argument
|
||||
try:
|
||||
# Pass current app's executable path for updater to relaunch
|
||||
main_app_executable = os.path.abspath(sys.argv[0])
|
||||
main_app_executable = sys.executable if getattr(sys, 'frozen', False) else os.path.abspath(sys.argv[0])
|
||||
|
||||
print(f'Launching updater with: "{updater_path}" "{extract_folder}" "{main_app_executable}"')
|
||||
|
||||
@@ -457,17 +455,30 @@ def wait_for_process_to_exit(process_name: str, timeout: int = 10) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _remove_readonly(func: Callable[[str], Any], path: str, exc: Any) -> None:
|
||||
"""Error handler for shutil.rmtree to clear read-only files on Windows."""
|
||||
import stat
|
||||
os.chmod(path, stat.S_IWRITE)
|
||||
func(path)
|
||||
|
||||
|
||||
def finish_update_if_needed(platform_name: str, app_name: str, cfg_path: str, finish_update: bool) -> None:
|
||||
"""
|
||||
Completes a pending application update if '--finish-update' is present in the command-line arguments.
|
||||
"""
|
||||
|
||||
if finish_update:
|
||||
if not finish_update:
|
||||
return
|
||||
|
||||
print("Finishing update...")
|
||||
|
||||
# 1. Reset welcome dialog config flag
|
||||
update_cfg = configparser.ConfigParser()
|
||||
try:
|
||||
if os.path.exists(cfg_path):
|
||||
update_cfg.read(cfg_path)
|
||||
if not update_cfg.has_section("Options"):
|
||||
update_cfg.add_section("Options")
|
||||
update_cfg.set("Options", "show_welcome_dialog", "true")
|
||||
|
||||
with open(cfg_path, "w") as f:
|
||||
@@ -476,74 +487,65 @@ def finish_update_if_needed(platform_name: str, app_name: str, cfg_path: str, fi
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not update welcome dialog preference flag: {e}")
|
||||
|
||||
if platform_name == 'darwin':
|
||||
app_dir = f'/tmp/{app_name}tempupdate'
|
||||
else:
|
||||
app_dir = os.getcwd()
|
||||
app_dir = f'/tmp/{app_name}tempupdate' if platform_name == 'darwin' else get_app_dir()
|
||||
|
||||
# 1. Find update folder
|
||||
update_folder = None
|
||||
if not os.path.exists(app_dir):
|
||||
print(f"App directory does not exist: {app_dir}. Skipping cleanup.")
|
||||
return
|
||||
|
||||
# 2. Find update folder(s)
|
||||
update_folders: List[str] = []
|
||||
try:
|
||||
for entry in os.listdir(app_dir):
|
||||
entry_path = os.path.join(app_dir, entry)
|
||||
if os.path.isdir(entry_path) and entry.startswith(f"{app_name}-") and entry.endswith("-" + platform_name):
|
||||
update_folder = os.path.join(app_dir, entry)
|
||||
break
|
||||
update_folders.append(entry_path)
|
||||
except Exception as e:
|
||||
print(f"Error scanning app directory for update folders: {e}")
|
||||
|
||||
if update_folder is None:
|
||||
if not update_folders:
|
||||
print("No update folder found. Skipping update steps.")
|
||||
return
|
||||
|
||||
if platform_name == 'darwin':
|
||||
update_folder = os.path.join(update_folder, f"{app_name}-darwin")
|
||||
primary_update_folder = update_folders[0]
|
||||
target_updater_folder = os.path.join(primary_update_folder, f"{app_name}-darwin") if platform_name == 'darwin' else primary_update_folder
|
||||
|
||||
# 2. Wait for updater to exit
|
||||
# 3. Wait for updater process to exit
|
||||
print(f"Waiting for {app_name}_updater to exit...")
|
||||
updater_bin_name = f"{app_name}_updater"
|
||||
for proc in psutil.process_iter(['pid', 'name']):
|
||||
if proc.info['name'] and f"{app_name}_updater" in proc.info['name'].lower():
|
||||
try:
|
||||
if proc.info['name'] and updater_bin_name.lower() in proc.info['name'].lower():
|
||||
proc.wait(timeout=5)
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
pass
|
||||
except psutil.TimeoutExpired:
|
||||
print(f"Force killing lingering {app_name}_updater")
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 3. Replace the updater
|
||||
# 4. Replace the updater executable
|
||||
target_base = get_app_dir()
|
||||
if platform_name == 'windows':
|
||||
new_updater = os.path.join(update_folder, f"{app_name}_updater.exe")
|
||||
dest_updater = os.path.join(app_dir, f"{app_name}_updater.exe")
|
||||
|
||||
new_updater = os.path.join(target_updater_folder, f"{app_name}_updater.exe")
|
||||
dest_updater = os.path.join(target_base, f"{app_name}_updater.exe")
|
||||
elif platform_name == 'darwin':
|
||||
new_updater = os.path.join(update_folder, f"{app_name}_updater.app")
|
||||
new_updater = os.path.join(target_updater_folder, f"{app_name}_updater.app")
|
||||
dest_updater = os.path.abspath(os.path.join(sys.executable, f"../../../../{app_name}_updater.app"))
|
||||
|
||||
elif platform_name == 'linux':
|
||||
new_updater = os.path.join(update_folder, f"{app_name}_updater")
|
||||
dest_updater = os.path.join(app_dir, f"{app_name}_updater")
|
||||
|
||||
new_updater = os.path.join(target_updater_folder, f"{app_name}_updater")
|
||||
dest_updater = os.path.join(target_base, f"{app_name}_updater")
|
||||
else:
|
||||
print("Unknown Platform")
|
||||
new_updater = os.getcwd()
|
||||
dest_updater = os.getcwd()
|
||||
|
||||
print(f"New updater is {new_updater}")
|
||||
print(f"Dest updater is {dest_updater}")
|
||||
|
||||
print("Writable?", os.access(dest_updater, os.W_OK))
|
||||
print("Executable path:", sys.executable)
|
||||
print("Trying to copy:", new_updater, "->", dest_updater)
|
||||
new_updater = target_base
|
||||
dest_updater = target_base
|
||||
|
||||
if os.path.exists(new_updater):
|
||||
try:
|
||||
if os.path.exists(dest_updater):
|
||||
if platform_name == 'darwin':
|
||||
try:
|
||||
if os.path.isdir(dest_updater):
|
||||
shutil.rmtree(dest_updater)
|
||||
print(f"Deleted directory: {dest_updater}")
|
||||
else:
|
||||
os.remove(dest_updater)
|
||||
print(f"Deleted file: {dest_updater}")
|
||||
except Exception as e:
|
||||
print(f"Error deleting {dest_updater}: {e}")
|
||||
if platform_name == 'darwin' and os.path.isdir(dest_updater):
|
||||
shutil.rmtree(dest_updater, onexc=_remove_readonly)
|
||||
else:
|
||||
os.remove(dest_updater)
|
||||
|
||||
@@ -559,19 +561,25 @@ def finish_update_if_needed(platform_name: str, app_name: str, cfg_path: str, fi
|
||||
if platform_name == 'darwin':
|
||||
remove_quarantine(dest_updater, app_name)
|
||||
|
||||
print(f"{app_name}_updater replaced.")
|
||||
print(f"{app_name}_updater replaced successfully.")
|
||||
except Exception as e:
|
||||
print(f"Failed to replace {app_name}_updater: {e}")
|
||||
|
||||
# 4. Delete the update folder
|
||||
# 5. Clean up all temporary update folders (with retries for Windows file locks)
|
||||
for folder in update_folders:
|
||||
for attempt in range(3):
|
||||
try:
|
||||
if platform_name == 'darwin':
|
||||
shutil.rmtree(app_dir)
|
||||
else:
|
||||
shutil.rmtree(update_folder)
|
||||
if os.path.exists(folder):
|
||||
shutil.rmtree(folder, onexc=_remove_readonly)
|
||||
print(f"Successfully deleted update folder: {folder}")
|
||||
break
|
||||
except Exception as e:
|
||||
print(f"Failed to delete update folder: {e}")
|
||||
if attempt < 2:
|
||||
time.sleep(1.0)
|
||||
else:
|
||||
print(f"Failed to delete update folder '{folder}' after 3 attempts: {e}")
|
||||
|
||||
if "--finish-update" in sys.argv:
|
||||
sys.argv.remove("--finish-update")
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user