diff --git a/packages/roi-builder-pack.zip b/packages/roi-builder-pack.zip new file mode 100644 index 0000000..c8f1844 Binary files /dev/null and b/packages/roi-builder-pack.zip differ diff --git a/plugins.json b/plugins.json index db715b0..6af8d34 100644 --- a/plugins.json +++ b/plugins.json @@ -5,9 +5,9 @@ "version": "0.1.0", "author": "Tyler de Zeeuw", "description": "Machine Learning on Processed fNIRS Data.", - "platforms": ["win_x64"], "min_app_version": "1.7.0", - "download_url": "https://git.research.dezeeuw.ca/tyler/flares-plugins/raw/branch/main/packages/machine_learning_pack_win_x64.zip" + "platforms": ["win_x64"], + "download_url": "https://git.research.dezeeuw.ca/tyler/flares-plugins/raw/branch/main/packages/machine-learning-pack-win-x64.zip" }, { "id": "theme-pack", @@ -17,16 +17,26 @@ "description": "Pure Python themes (cross-platform).", "min_app_version": "1.7.0", "platforms": ["darwin_arm64", "darwin_x64", "linux_x64"], - "download_url": "https://git.research.dezeeuw.ca/tyler/flares-plugins/raw/branch/main/packages/theme_pack_multi.zip" + "download_url": "https://git.research.dezeeuw.ca/tyler/flares-plugins/raw/branch/main/packages/theme-pack-multi.zip" }, { "id": "test-plugin", "name": "Test Plugin", "version": "1.0.0", + "description": "A basic test plugin demonstrating menu registration and custom widgets.", "author": "Tyler de Zeeuw", - "description": "Test Plugin.", "min_app_version": "1.7.0", - "platforms": ["win_x64"], + "platforms": ["win_x64", "darwin_arm64", "darwin_x64", "linux_x64"], "download_url": "https://git.research.dezeeuw.ca/tyler/flares-plugins/raw/branch/main/packages/test-plugin.zip" + }, + { + "id": "roi-builder-pack", + "name": "ROI Channel Builder Pack", + "version": "1.0.0", + "description": "Extracts channel labels from SNIRF files and generates custom ROI grouping JSON files.", + "author": "Tyler de Zeeuw", + "min_app_version": "1.7.0", + "platforms": ["win_x64", "darwin_arm64", "darwin_x64", "linux_x64"], + "download_url": "https://git.research.dezeeuw.ca/tyler/flares-plugins/raw/branch/main/packages/roi-builder-pack.zip" } ] \ No newline at end of file diff --git a/roi-builder-pack/__init__.py b/roi-builder-pack/__init__.py new file mode 100644 index 0000000..af358d6 --- /dev/null +++ b/roi-builder-pack/__init__.py @@ -0,0 +1,608 @@ +""" +Filename: plugins.py +Description: roi-builder-pack plugin + +Author: Tyler de Zeeuw +License: GPL-3.0 +""" + +# Built-in imports +import os +import json +from typing import Optional, Any + +# External library imports +import h5py + +from PySide6.QtCore import Qt +from PySide6.QtGui import QAction +from PySide6.QtWidgets import (QAbstractItemView, QComboBox, QFileDialog, QGroupBox, QHBoxLayout, QHeaderView, QLabel, QLineEdit, QListWidget, + QMenu, QMessageBox, QPushButton, QSplitter, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget,) + +from mne.io import read_raw_snirf +from mne.preprocessing.nirs import optical_density, beer_lambert_law +from flares import fold_channels + + +class RoiBuilderWidget(QWidget): + """UI Widget for opening SNIRF files, grouping channels into ROIs, and exporting JSON files.""" + + NEW_GROUP_OPTION = "+ Create New Group" + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setWindowTitle("ROI Channel Builder") + self.resize(850, 620) + + self.extracted_channels: list[str] = [] + self._setup_ui() + + def _setup_ui(self) -> None: + main_layout = QVBoxLayout(self) + + file_box = QGroupBox("SNIRF File Selection", self) + file_layout = QHBoxLayout(file_box) + + self.txt_file_path = QLineEdit(self) + self.txt_file_path.setReadOnly(True) + self.txt_file_path.setPlaceholderText("Select a .snirf file to load channels...") + + btn_browse = QPushButton("Browse...", self) + btn_browse.clicked.connect(self._on_browse_snirf) + + file_layout.addWidget(self.txt_file_path) + file_layout.addWidget(btn_browse) + main_layout.addWidget(file_box) + + splitter = QSplitter(Qt.Orientation.Horizontal, self) + + # Left Column: Extracted Channels + left_widget = QWidget(self) + left_layout = QVBoxLayout(left_widget) + left_layout.setContentsMargins(0, 0, 0, 0) + + left_layout.addWidget(QLabel("Available Channels:", self)) + self.list_channels = QListWidget(self) + self.list_channels.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) + left_layout.addWidget(self.list_channels) + + # Draft Auto-Grouping Button + self.btn_auto_group = QPushButton("Auto ROI Grouping", self) + self.btn_auto_group.setToolTip("Automatically splits available channels via their physical locations.") + self.btn_auto_group.clicked.connect(self._on_auto_group) + left_layout.addWidget(self.btn_auto_group) + + splitter.addWidget(left_widget) + + # Right Column: Defined ROIs & Inputs + right_widget = QWidget(self) + right_layout = QVBoxLayout(right_widget) + right_layout.setContentsMargins(0, 0, 0, 0) + + # Form for selecting or adding ROI + form_box = QGroupBox("Assign Channels to Group", self) + form_layout = QVBoxLayout(form_box) + + self.combo_group_select = QComboBox(self) + self.combo_group_select.addItem(self.NEW_GROUP_OPTION) + self.combo_group_select.currentIndexChanged.connect(self._on_group_selection_changed) + + self.txt_roi_name = QLineEdit(self) + self.txt_roi_name.setPlaceholderText("ROI Name (e.g., Left_PFC)") + + self.txt_roi_desc = QLineEdit(self) + self.txt_roi_desc.setPlaceholderText("Description (e.g., Left prefrontal cortex)") + + btn_add_roi = QPushButton("Assign Selected Channels to Group", self) + btn_add_roi.setStyleSheet("font-weight: bold;") + btn_add_roi.clicked.connect(self._on_assign_channels) + + form_layout.addWidget(QLabel("Target Group:")) + form_layout.addWidget(self.combo_group_select) + form_layout.addWidget(self.txt_roi_name) + form_layout.addWidget(self.txt_roi_desc) + form_layout.addWidget(btn_add_roi) + + right_layout.addWidget(form_box) + + # ROI Tree View + right_layout.addWidget(QLabel("Defined Regions of Interest:", self)) + self.tree_rois = QTreeWidget(self) + self.tree_rois.setHeaderLabels(["ROI / Channel", "Description"]) + self.tree_rois.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) + self.tree_rois.header().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) + right_layout.addWidget(self.tree_rois) + + # Unassign & Remove Buttons + btn_layout = QHBoxLayout() + btn_unassign_channel = QPushButton("Unassign Selected Channel(s)", self) + btn_unassign_channel.clicked.connect(self._on_unassign_channels) + + btn_remove_roi = QPushButton("Remove Selected Group", self) + btn_remove_roi.clicked.connect(self._on_remove_roi) + + btn_layout.addWidget(btn_unassign_channel) + btn_layout.addWidget(btn_remove_roi) + right_layout.addLayout(btn_layout) + + splitter.addWidget(right_widget) + splitter.setSizes([280, 570]) + + main_layout.addWidget(splitter) + + bottom_layout = QHBoxLayout() + self.lbl_status = QLabel("Ready.", self) + + btn_export = QPushButton("Export ROI JSON...", self) + btn_export.setStyleSheet("font-weight: bold;") + btn_export.clicked.connect(self._on_export_json) + + bottom_layout.addWidget(self.lbl_status) + bottom_layout.addStretch() + bottom_layout.addWidget(btn_export) + + main_layout.addLayout(bottom_layout) + + def _add_channels_to_available_list(self, channels: list[str]) -> None: + """Restores channels to the available list and keeps them sorted.""" + existing = [self.list_channels.item(i).text() for i in range(self.list_channels.count())] + combined = set(existing).union(channels) + sorted_channels = sorted(list(combined), key=parse_sd_key) + + self.list_channels.clear() + for ch in sorted_channels: + self.list_channels.addItem(ch) + + def _find_roi_item_by_name(self, name: str) -> QTreeWidgetItem | None: + """Finds an existing top-level tree item matching the group name.""" + for i in range(self.tree_rois.topLevelItemCount()): + item = self.tree_rois.topLevelItem(i) + if item: + data = item.data(0, Qt.ItemDataRole.UserRole) + if data and data.get("name", "").strip().lower() == name.strip().lower(): + return item + return None + + + def _on_group_selection_changed(self, index: int) -> None: + selected_text = self.combo_group_select.currentText() + + if selected_text == self.NEW_GROUP_OPTION: + self.txt_roi_name.clear() + self.txt_roi_desc.clear() + self.txt_roi_name.setReadOnly(False) + else: + roi_item = self._find_roi_item_by_name(selected_text) + if roi_item: + data = roi_item.data(0, Qt.ItemDataRole.UserRole) + self.txt_roi_name.setText(data.get("name", "")) + self.txt_roi_desc.setText(data.get("description", "")) + self.txt_roi_name.setReadOnly(True) + + def _on_browse_snirf(self) -> None: + file_path, _ = QFileDialog.getOpenFileName( + self, "Select SNIRF File", "", "SNIRF Files (*.snirf *.h5);;All Files (*)" + ) + if not file_path: + return + + self.txt_file_path.setText(file_path) + try: + self.extracted_channels = extract_snirf_channels(file_path) + self.tree_rois.clear() + self.list_channels.clear() + + # Reset combo box + self.combo_group_select.blockSignals(True) + self.combo_group_select.clear() + self.combo_group_select.addItem(self.NEW_GROUP_OPTION) + self.combo_group_select.blockSignals(False) + + for ch in self.extracted_channels: + self.list_channels.addItem(ch) + + self.lbl_status.setText(f"Loaded {len(self.extracted_channels)} channels from file.") + except Exception as e: + QMessageBox.critical(self, "Error Reading SNIRF", f"Failed to load channels:\n{e}") + self.lbl_status.setText("Failed to load SNIRF file.") + + def _on_assign_channels(self) -> None: + name = self.txt_roi_name.text().strip() + desc = self.txt_roi_desc.text().strip() + selected_items = self.list_channels.selectedItems() + + if not name: + QMessageBox.warning(self, "Input Required", "Please specify a Group / ROI Name.") + return + + if not selected_items: + QMessageBox.warning(self, "Selection Required", "Please select at least one channel from the left panel.") + return + + new_channels = [item.text() for item in selected_items] + existing_item = self._find_roi_item_by_name(name) + + if existing_item: + # Merge channels into existing group + data = existing_item.data(0, Qt.ItemDataRole.UserRole) + combined_channels = set(data.get("channels", [])).union(new_channels) + sorted_channels = sorted(list(combined_channels), key=parse_sd_key) + + data["description"] = desc + data["channels"] = sorted_channels + + existing_item.setText(1, desc) + existing_item.setData(0, Qt.ItemDataRole.UserRole, data) + + # Rebuild child channel items + existing_item.takeChildren() + for ch in sorted_channels: + child = QTreeWidgetItem(existing_item) + child.setText(0, ch) + + self.lbl_status.setText(f"Updated group '{name}' with {len(new_channels)} additional channels.") + else: + # Create new group tree item + roi_item = QTreeWidgetItem(self.tree_rois) + roi_item.setText(0, name) + roi_item.setText(1, desc) + + sorted_channels = sorted(new_channels, key=parse_sd_key) + roi_data = {"name": name, "description": desc, "channels": sorted_channels} + roi_item.setData(0, Qt.ItemDataRole.UserRole, roi_data) + + for ch in sorted_channels: + child = QTreeWidgetItem(roi_item) + child.setText(0, ch) + + self.tree_rois.expandItem(roi_item) + + self.combo_group_select.addItem(name) + self.combo_group_select.setCurrentText(name) + + self.lbl_status.setText(f"Created group '{name}' with {len(new_channels)} channels.") + + # REMOVE assigned channels from available channels list + for item in selected_items: + self.list_channels.takeItem(self.list_channels.row(item)) + + def _on_unassign_channels(self) -> None: + """Unassigns individually selected channels from tree items and returns them to available list.""" + selected_tree_items = self.tree_rois.selectedItems() + if not selected_tree_items: + QMessageBox.information( + self, "Selection Required", "Please select channel item(s) inside an ROI group to unassign." + ) + return + + channels_to_restore: list[str] = [] + + for item in selected_tree_items: + parent = item.parent() + # Check if this item is a child channel node + if parent is not None: + ch_name = item.text(0) + channels_to_restore.append(ch_name) + + # Remove child from tree + parent.removeChild(item) + + # Update parent's channel dataset + data = parent.data(0, Qt.ItemDataRole.UserRole) + if data and "channels" in data and ch_name in data["channels"]: + data["channels"].remove(ch_name) + parent.setData(0, Qt.ItemDataRole.UserRole, data) + + if channels_to_restore: + self._add_channels_to_available_list(channels_to_restore) + self.lbl_status.setText(f"Unassigned {len(channels_to_restore)} channels back to available list.") + else: + QMessageBox.information( + self, "Select Channels", "Please select specific channel items under a group (not the group header)." + ) + + def _on_remove_roi(self) -> None: + """Removes an entire ROI group and returns all its channels back to available list.""" + selected_item = self.tree_rois.currentItem() + if not selected_item: + return + + root_item = selected_item.parent() if selected_item.parent() else selected_item + index = self.tree_rois.indexOfTopLevelItem(root_item) + + if index != -1: + data = root_item.data(0, Qt.ItemDataRole.UserRole) + name = data.get("name", "") if data else "" + channels = data.get("channels", []) if data else [] + + # Return channels to available list + if channels: + self._add_channels_to_available_list(channels) + + self.tree_rois.takeTopLevelItem(index) + + # Remove from combo box + cb_idx = self.combo_group_select.findText(name) + if cb_idx != -1: + self.combo_group_select.removeItem(cb_idx) + + self.combo_group_select.setCurrentIndex(0) + self.lbl_status.setText(f"Removed group '{name}' and restored {len(channels)} channels.") + + def _on_export_json(self) -> None: + roi_list: list[dict[str, Any]] = [] + + for i in range(self.tree_rois.topLevelItemCount()): + item = self.tree_rois.topLevelItem(i) + if item: + data = item.data(0, Qt.ItemDataRole.UserRole) + if data and data.get("channels"): + roi_list.append(data) + + if not roi_list: + QMessageBox.warning(self, "No ROIs Defined", "Please create at least one non-empty group before exporting.") + return + + save_path, _ = QFileDialog.getSaveFileName( + self, "Save Regions of Interest JSON", "regions_of_interest.json", "JSON Files (*.json)" + ) + if not save_path: + return + + output_data = {"regions_of_interest": roi_list} + + try: + with open(save_path, "w", encoding="utf-8") as f: + json.dump(output_data, f, indent=2) + + QMessageBox.information(self, "Export Successful", f"Saved ROI definitions to:\n{save_path}") + self.lbl_status.setText(f"Successfully exported {len(roi_list)} groups.") + except Exception as e: + QMessageBox.critical(self, "Export Failed", f"Could not save JSON file:\n{e}") + + + def _on_auto_group(self) -> None: + """Automatically parses fOLD results and populates the tree and combo UI components.""" + file_path = self.txt_file_path.text().strip() + if not file_path: + QMessageBox.warning(self, "No File Selected", "Please select a .snirf file first.") + return + + self.btn_auto_group.setEnabled(False) + + try: + # 1. Fetch fold specificity data + channel_results = get_channel_fold_results( + file_path=file_path, + atlas="Brodmann" + ) + + # 2. Determine groups automatically + auto_groups = _auto_group_channels(channel_results) + + if not auto_groups: + QMessageBox.information(self, "Auto Grouping", "No valid ROI groups could be determined.") + return + + # Map available channel strings to QListWidgetItems + available_items = { + self.list_channels.item(i).text(): self.list_channels.item(i) + for i in range(self.list_channels.count()) + } + + assigned_count = 0 + groups_created = 0 + + for group_name, ch_list in auto_groups.items(): + clean_channels: list[str] = [] + items_to_remove: list = [] + + # Clean channel names (e.g., 'S1_D1 hbo' -> 'S1_D1') and match against available list + for raw_ch in ch_list: + ch_id = raw_ch.split()[0] + if ch_id in available_items: + clean_channels.append(ch_id) + items_to_remove.append(available_items[ch_id]) + + if not clean_channels: + continue + + sorted_channels = sorted(list(set(clean_channels)), key=parse_sd_key) + + # Check if group tree item exists or create new + existing_item = self._find_roi_item_by_name(group_name) + if existing_item: + data = existing_item.data(0, Qt.ItemDataRole.UserRole) or {} + combined = set(data.get("channels", [])).union(sorted_channels) + final_channels = sorted(list(combined), key=parse_sd_key) + + data["name"] = group_name + data["description"] = group_name + data["channels"] = final_channels + existing_item.setData(0, Qt.ItemDataRole.UserRole, data) + + existing_item.takeChildren() + for ch in final_channels: + child = QTreeWidgetItem(existing_item) + child.setText(0, ch) + else: + roi_item = QTreeWidgetItem(self.tree_rois) + roi_item.setText(0, group_name) + roi_item.setText(1, group_name) + + roi_data = { + "name": group_name, + "description": group_name, + "channels": sorted_channels + } + roi_item.setData(0, Qt.ItemDataRole.UserRole, roi_data) + + for ch in sorted_channels: + child = QTreeWidgetItem(roi_item) + child.setText(0, ch) + + self.tree_rois.expandItem(roi_item) + + if self.combo_group_select.findText(group_name) == -1: + self.combo_group_select.addItem(group_name) + + groups_created += 1 + + # Remove assigned channels from available QListWidget + for item in items_to_remove: + row = self.list_channels.row(item) + if row != -1: + self.list_channels.takeItem(row) + ch_key = item.text() + if ch_key in available_items: + del available_items[ch_key] + + assigned_count += len(clean_channels) + + self.lbl_status.setText(f"Auto-grouped {assigned_count} channels into {groups_created} ROI regions.") + QMessageBox.information( + self, + "Auto Grouping Complete", + f"Successfully created {groups_created} ROI groups and assigned {assigned_count} channels." + ) + + except Exception as e: + QMessageBox.critical(self, "Fold Error", f"Failed to calculate fold channels:\n{e}") + + finally: + self.btn_auto_group.setEnabled(True) + + +class Plugin: + """Plugin entry point contract loaded by PluginManager.""" + + def __init__(self, main_window: QWidget) -> None: + self.main_window = main_window + self.name = "ROI Channel Builder" + self.widget_instance: RoiBuilderWidget | None = None + + def register_menu(self, plugin_menu: QMenu) -> None: + """Registers plugin options into the application's top menubar.""" + open_action = QAction("Open ROI Builder Tool", self.main_window) + open_action.triggered.connect(self.show_widget) + + about_action = QAction("About ROI Builder", self.main_window) + about_action.triggered.connect(self.show_about) + + plugin_menu.addAction(open_action) + plugin_menu.addAction(about_action) + + def show_widget(self) -> None: + """Instantiates or focuses the ROI Builder window.""" + if self.widget_instance is None or not self.widget_instance.isVisible(): + self.widget_instance = RoiBuilderWidget() + self.widget_instance.show() + else: + self.widget_instance.raise_() + self.widget_instance.activateWindow() + + def show_about(self) -> None: + """Displays plugin information.""" + QMessageBox.about( + self.main_window, + "About ROI Channel Builder", + "This plugin loads SNIRF binary files using h5py, extracts source-detector channel pairs, " + "and exports custom ROI channel group JSON files.", + ) + + +def parse_sd_key(ch: str) -> tuple[int, int]: + """Helper for natural sorting of S{source}_D{detector} strings.""" + parts = ch.split("_") + s_num = int(parts[0].lstrip("S")) if len(parts) > 0 and parts[0].lstrip("S").isdigit() else 0 + d_num = int(parts[1].lstrip("D")) if len(parts) > 1 and parts[1].lstrip("D").isdigit() else 0 + return s_num, d_num + + +def extract_snirf_channels(file_path: str) -> list[str]: + """Reads a .snirf file using h5py and extracts unique channel names (S{source}_D{detector}).""" + + channels: set[str] = set() + + with h5py.File(file_path, "r") as h5_file: + if "nirs" not in h5_file: + raise ValueError("Invalid SNIRF file: missing top-level '/nirs' HDF5 group.") + + nirs_group = h5_file["nirs"] + data_keys = [k for k in nirs_group.keys() if k.startswith("data")] + + for d_key in data_keys: + data_group = nirs_group[d_key] + ml_keys = [k for k in data_group.keys() if k.startswith("measurementList")] + + for ml_key in ml_keys: + ml = data_group[ml_key] + if "sourceIndex" in ml and "detectorIndex" in ml: + src = ml["sourceIndex"][()] + det = ml["detectorIndex"][()] + + if hasattr(src, "item"): + src = src.item() + if hasattr(det, "item"): + det = det.item() + + channels.add(f"S{src}_D{det}") + + return sorted(list(channels), key=parse_sd_key) + + +def get_channel_fold_results( + file_path: str, + atlas: str = 'Brodmann', + progress_queue: Optional[Any] = None +) -> dict[str, list[dict[str, Any]]]: + """ + Directly loads a .snirf file, performs a lightweight HbO conversion, + and runs fold_channels without executing any GLM, QC, or pipeline steps. + """ + p_name = os.path.basename(file_path) + + raw = read_raw_snirf(file_path, preload=True, verbose=False) + raw_od = optical_density(raw) + raw_haemo = beer_lambert_law(raw_od) + + channel_results = fold_channels( + raw=raw_haemo, + p_name=p_name, + atlas=atlas, + progress_queue=progress_queue + ) + + return channel_results + + +def _auto_group_channels(channel_results: dict) -> dict[str, list[str]]: + """Clusters channels by their highest specificity landmark (excluding Brain_Outside).""" + roi_groups: dict[str, list[str]] = {} + + for ch_name, landmarks in channel_results.items(): + if not landmarks: + continue + + # Filter out Brain_Outside unless it's the only option + valid_landmarks = [ + lm for lm in landmarks + if lm.get("Landmark") != "Brain_Outside" + ] + if not valid_landmarks: + valid_landmarks = landmarks + + # Pick highest specificity landmark + top_landmark = max(valid_landmarks, key=lambda x: x.get("Specificity", 0)) + raw_name = top_landmark.get("Landmark", "Unassigned") + + # Format label (e.g. "7 - Somatosensory..." -> "BA 7: Somatosensory Association Cortex") + parts = raw_name.split(" - ", 1) + if len(parts) == 2 and parts[0].strip().isdigit(): + group_name = f"BA {parts[0].strip()}: {parts[1].strip()}" + else: + group_name = raw_name + + roi_groups.setdefault(group_name, []).append(ch_name) + + return roi_groups \ No newline at end of file diff --git a/roi-builder-pack/plugin.json b/roi-builder-pack/plugin.json new file mode 100644 index 0000000..776b385 --- /dev/null +++ b/roi-builder-pack/plugin.json @@ -0,0 +1,10 @@ +{ + "id": "roi-builder-pack", + "name": "ROI Channel Builder Pack", + "version": "1.0.0", + "description": "Extracts channel labels from SNIRF files and generates custom ROI grouping JSON files.", + "author": "Tyler de Zeeuw", + "min_app_version": "1.7.0", + "platforms": ["win_x64", "darwin_arm64", "darwin_x64", "linux_x64"], + "download_url": "https://git.research.dezeeuw.ca/tyler/flares-plugins/raw/branch/main/packages/roi-builder-pack.zip" +} \ No newline at end of file diff --git a/test-plugin/plugin.json b/test-plugin/plugin.json index 0e309e3..89fdd28 100644 --- a/test-plugin/plugin.json +++ b/test-plugin/plugin.json @@ -1,9 +1,10 @@ { - "id": "test_plugin", - "name": "Test Plugin", - "version": "1.0.0", - "description": "A basic test plugin demonstrating menu registration and custom widgets.", - "author": "Your Name", - "min_app_version": "1.0.0", - "platforms": ["win_x64", "darwin_arm64", "darwin_x64", "linux_x64"] + "id": "test_plugin", + "name": "Test Plugin", + "version": "1.0.0", + "description": "A basic test plugin demonstrating menu registration and custom widgets.", + "author": "Tyler de Zeeuw", + "min_app_version": "1.7.0", + "platforms": ["win_x64", "darwin_arm64", "darwin_x64", "linux_x64"], + "download_url": "https://git.research.dezeeuw.ca/tyler/flares-plugins/raw/branch/main/packages/test-plugin.zip" } \ No newline at end of file