From a173a20dc832374076878a4c95341fcd1dd9505b Mon Sep 17 00:00:00 2001 From: tyler Date: Tue, 1 Sep 2026 17:24:46 -0700 Subject: [PATCH] new plugin --- packages/roi-builder-pack.zip | Bin 0 -> 15317 bytes plugins.json | 20 +- roi-builder-pack/__init__.py | 608 ++++++++++++++++++++++++++++++++++ roi-builder-pack/plugin.json | 10 + test-plugin/plugin.json | 15 +- 5 files changed, 641 insertions(+), 12 deletions(-) create mode 100644 packages/roi-builder-pack.zip create mode 100644 roi-builder-pack/__init__.py create mode 100644 roi-builder-pack/plugin.json diff --git a/packages/roi-builder-pack.zip b/packages/roi-builder-pack.zip new file mode 100644 index 0000000000000000000000000000000000000000..c8f1844bb30763e73da2790631a077d27d1b8fcc GIT binary patch literal 15317 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 literal 0 HcmV?d00001 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