diff --git a/.github/badges/code_issues.svg b/.github/badges/code_issues.svg
index a6d761ea..d259430f 100644
--- a/.github/badges/code_issues.svg
+++ b/.github/badges/code_issues.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/.github/badges/tests.svg b/.github/badges/tests.svg
index ed4f6ca1..41b70f07 100644
--- a/.github/badges/tests.svg
+++ b/.github/badges/tests.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/.github/workflows/generate-requirements-txt.yml b/.github/workflows/generate-requirements-txt.yml
new file mode 100644
index 00000000..9eee6818
--- /dev/null
+++ b/.github/workflows/generate-requirements-txt.yml
@@ -0,0 +1,52 @@
+name: Generate requirements.txt from manifest
+
+on:
+ push:
+ paths:
+ - 'code/dependencies.nansen.json'
+ branches:
+ - main
+ - dev
+ workflow_dispatch:
+
+jobs:
+ generate-requirements:
+ name: Regenerate requirements.txt
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@v4
+ with:
+ ssh-key: ${{ secrets.DEPLOY_KEY }}
+
+ - name: Set up MATLAB
+ uses: matlab-actions/setup-matlab@v2
+
+ - name: Add toolbox code to path and generate requirements.txt
+ uses: matlab-actions/run-command@v2
+ with:
+ command: |
+ addpath(genpath('code'));
+ addpath(genpath('tools'));
+ nansentools.generateRequirementsTxt( ...
+ fullfile(pwd, 'dependencies.nansen.json'), ...
+ fullfile(pwd, 'requirements.txt'));
+
+ - name: Check for changes
+ id: check-changes
+ run: |
+ if git diff --quiet requirements.txt; then
+ echo "changed=false" >> "$GITHUB_OUTPUT"
+ else
+ echo "changed=true" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Commit and push updated requirements.txt
+ if: steps.check-changes.outputs.changed == 'true'
+ run: |
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+ git add requirements.txt
+ git commit -m "Auto-generate requirements.txt from manifest [skip ci]"
+ git push
diff --git a/code/+nansen/+app/+setup/SetupWizard.mlapp b/code/+nansen/+app/+setup/SetupWizard.mlapp
index 2e17fdc9..90fcd786 100644
Binary files a/code/+nansen/+app/+setup/SetupWizard.mlapp and b/code/+nansen/+app/+setup/SetupWizard.mlapp differ
diff --git a/code/+nansen/+app/+tutorial/loadProject.m b/code/+nansen/+app/+tutorial/loadProject.m
index 8a14e676..26c24750 100644
--- a/code/+nansen/+app/+tutorial/loadProject.m
+++ b/code/+nansen/+app/+tutorial/loadProject.m
@@ -35,22 +35,26 @@
error('User canceled.')
end
+% We need the addonmanager to ensure all tutorial dependencies are installed
+addonManager = nansen.AddonManager();
+
if startsWith(S(selection), 'Allen Brain Observatory')
- addonManager = nansen.AddonManager();
names = {addonManager.AddonList.Name};
S = addonManager.AddonList(strcmp(names, "Brain Observatory Toolbox"));
if ~S.IsInstalled
fprintf('Downloading %s...', S.Name)
addonManager.downloadAddon(S.Name)
- addonManager.addAddonToMatlabPath(S.Name)
fprintf('Finished.\n')
end
+
elseif startsWith(S(selection), 'Nansen - Two-photon Quickstart')
warnState = warning('off', 'MATLAB:RMDIR:RemovedFromPath');
warnCleanup = onCleanup(@() warning(warnState));
+
disp('Installing two-photon addons...')
- nansen.internal.setup.installAddons()
+ addonManager.installMissingAddons('nansen.module.ophys.twophoton')
+
% Some users had problems where Yaml was not added to java path
nansen.internal.setup.addYamlJarToJavaClassPath
end
diff --git a/code/+nansen/+common/suppressWarning.m b/code/+nansen/+common/suppressWarning.m
new file mode 100644
index 00000000..314533f9
--- /dev/null
+++ b/code/+nansen/+common/suppressWarning.m
@@ -0,0 +1,14 @@
+function warningCleanupObj = suppressWarning(warningId)
+% suppressWarning - Temporarily suppress warning with given warning id
+%
+% Example usage:
+%
+% warningIdentifier = 'MATLAB:callback:error';
+% warningCleanup = nansen.common.suppressWarning(warningIdentifier); %#ok
+
+ arguments
+ warningId (1,:) char
+ end
+ warnState = warning('off', warningId);
+ warningCleanupObj = onCleanup(@() warning(warnState));
+end
diff --git a/code/+nansen/+config/+addons/@AddonManager/AddonManager.m b/code/+nansen/+config/+addons/@AddonManager/AddonManager.m
index c0179a3f..a433b9a9 100644
--- a/code/+nansen/+config/+addons/@AddonManager/AddonManager.m
+++ b/code/+nansen/+config/+addons/@AddonManager/AddonManager.m
@@ -1,633 +1,753 @@
classdef AddonManager < handle
-%AddonManager A simple addon manager for managing a custom list of addons
- % Provides an interface for downloading and adding addons/toolboxes
- % to the matlab path. Addons are specified in a separate function
- % and this class provides several methods for managing these addons.
-
- % TODOS:
- % [X] Save addon list
- % [x] System for adding addons to path on startup
- % [ ] Make another class for addons.
- % [v] Rename to addon manager
- % [ ] Add option for setting a custom installation dir
- % [ ] File with list of addons should be saved based on which software
- % it belongs to. Either use subclassing, or make a way to use access
- % a settings file by a keyword or something similar.
- % [ ] Better warning/resolving when addons are duplicated...
-
- % [v] Use matlab.addons.install(filename) for matlab toolbox files.
- % [ ] Provide table with addons to install as input...
-
- % QUESTIONS:
- % - Use gitmodules??
- % - Implement better git functionality, i.e version tracking
-
- % NOTES:
- % addons = matlab.addons.installedAddons
- % S = matlab.addons.toolbox.installedToolboxes Does not show Mathworks toolboxes
-
- properties % Preferences
- InstallationDir char = '' % Path where addons should be installed
- UseGit logical = false % Whether to use git for downloads and updates
- end
+%AddonManager Manages community toolbox dependencies for NANSEN.
+% Tracks installed addons, delegates download/install to matbox,
+% and manages MATLAB path additions per session.
properties
- AddonList struct = struct() % List of addons (table or struct array)
+ % InstallationFolder - Folder to install Add-Ons (dependencies) for NANSEN
+ InstallationFolder (1,1) string = missing
end
properties (SetAccess = private)
- AddonDefinitionsPath
+ % ManifestFilePath - Filepath for JSON manifest storing info and
+ % installation status about Add-Ons managed by NANSEN
+ ManifestFilePath (1,1) string
+ end
+
+ properties (Dependent)
+ % ManagedAddons - Table listing information about managed Add-Ons
+ ManagedAddons table
+ end
+
+ properties (SetAccess=private, Hidden)
+ AddonList struct = struct() % ManagedAddons
end
properties (Hidden)
IsDirty = false
end
-
+
properties (Constant, Hidden)
-
- % A list of fields that are relevant for each addon entry
- % Todo: make into a separate class...
- addonFields = {...
- 'Name', ... % Name of addon
- 'IsRequired', ... % Whether addon is required or optional
- 'IsInstalled', ...
- 'DateInstalled', ...
- 'FilePath', ...
- 'WebSource', ...
- 'WebUrl', ...
- 'DownloadUrl', ...
- 'HasSetupFile', ...
- 'SetupFileName', ...
- 'FunctionName', ...
- 'AddToPathOnInit', ...
- 'IsDoubleInstalled'}
+ DefaultAddonEntry = struct( ...
+ 'Name', '', ...
+ 'Description', "", ...
+ 'IsRequired', false, ...
+ 'IsInstalled', false, ...
+ 'IsOnPath', false, ...
+ 'DateInstalled', NaT, ...
+ 'FilePath', '', ...
+ 'Source', '', ...
+ 'DocsSource', '', ...
+ 'SetupFunctionName', '', ...
+ 'InstallCheck', '', ...
+ 'InstallationType', '', ...
+ 'ToolboxIdentifier', '', ...
+ 'AddToPathOnInit', false, ...
+ 'HasMultipleInstancesOnPath', false)
end
-
- methods (Access = ?nansen.internal.user.NansenUserSession)
-
- function obj = AddonManager(preferenceDirectory)
- %AddonManager Construct an instance of this class
-
- % Create a addon manager instance. Provide methods for
- % installing addons
- if nargin < 1; preferenceDirectory = ''; end
-
- % Assign the path to the directory where addons are saved
- obj.InstallationDir = obj.getDefaultInstallationDir();
-
- % Get path where list of previously installed addons are saved
- obj.AddonDefinitionsPath = obj.getPathForAddonList(preferenceDirectory);
-
- % Load addon list (list is initialized if it does not exist)
- obj.loadAddonList()
- % Add previously installed addons to path if they are not already there.
- obj.updateSearchPath()
-
- % Check if there are multiple versions of addons on the matlab
- % search path.
+ methods (Access = private) % Constructor (private -> singleton)
+ function obj = AddonManager(installationFolder, manifestFilePath)
+ arguments
+ installationFolder (1,1) string
+ manifestFilePath (1,1) string
+ end
+ obj.InstallationFolder = installationFolder;
+ obj.ManifestFilePath = manifestFilePath;
+ obj.loadAddonManifest()
+ obj.ensureAddonDependenciesOnPath()
obj.checkAddonDuplication()
end
end
-
- methods
-
- function listAddons(obj)
- %listAddons Display a table of addons
-
- T = struct2table(obj.AddonList);
-
- % Add a column with index numbers.
- numberColumn = table((1:size(T,1))', 'VariableNames', {'Num'});
- T = [numberColumn T];
-
- % Display table
- disp(T)
- end
-
- function loadAddonList(obj)
- % loadAddonList Load list (csv or xml) with required/supported addons
-
- % Load list
- if isfile(obj.AddonDefinitionsPath)
- S = load(obj.AddonDefinitionsPath);
- addonList = S.AddonList;
- else
- addonList = obj.initializeAddonList(); % init to empty struct
+
+ methods (Static, Hidden) % Get singleton instance
+ function obj = instance(mode, installationFolder, manifestFilePath)
+ %instance Get the singleton AddonManager instance.
+ %
+ % obj = AddonManager.instance() returns the singleton instance,
+ % creating it with default paths if needed.
+ %
+ % obj = AddonManager.instance("reset") resets the singleton.
+ %
+ % obj = AddonManager.instance("reset", installationFolder, manifestFilePath)
+ % resets the singleton with custom paths (useful for testing).
+ arguments
+ mode (1,1) string {mustBeMember(mode, ["normal", "reset"])} = "normal"
+ installationFolder (1,1) string = ...
+ nansen.config.addons.AddonManager.getDefaultInstallationDir()
+ manifestFilePath (1,1) string = ...
+ nansen.config.addons.AddonManager.getPathForAddonManifest()
end
-
- addonList = obj.updateAddonList(addonList);
-
- % Assign to AddonList property
- obj.AddonList = addonList;
+ persistent singletonInstance
+ if isempty(singletonInstance) || ~isvalid(singletonInstance) || mode == "reset"
+ singletonInstance = nansen.config.addons.AddonManager( ...
+ installationFolder, manifestFilePath);
+ end
+ obj = singletonInstance;
end
-
- function saveAddonList(obj)
- %saveAddonList Sve the list of addons to file.
-
- S = struct;
- S.type = 'Nansen Configuration: List of Installed Addons';
- S.description = 'This file lists all the addons that have been installed through NANSEN';
-
- S.AddonList = obj.AddonList;
- save(obj.AddonDefinitionsPath, '-struct', 'S')
-
- jsonFilePath = strrep(obj.AddonDefinitionsPath, '.mat', '.json');
- utility.filewrite(jsonFilePath, jsonencode(S, 'PrettyPrint', true))
+ end
+
+ methods % Public
+
+ % Open the Add-On Manifest in MATLABs editor
+ function openManifest(obj)
+ edit( obj.ManifestFilePath )
end
-
- function S = updateAddonList(~, S)
- %updateAddonList Compare current with default
- % (in case defaults have been updated)
-
- % %todo: rename
-
- % Get package list
- defaultAddonList = nansen.config.addons.getDefaultAddonList();
-
- %numAddons = numel(defaultAddonList);
-
- defaultAddonNames = {defaultAddonList.Name};
- currentAddonNames = {S.Name};
-
- isNew = ~ismember(defaultAddonNames, currentAddonNames);
-
- newAddons = find(isNew);
- fieldNames = fieldnames(defaultAddonList);
-
- % If some addons are present in default addon list and not in
- % current addon list, add from default to current.
- for iAddon = newAddons
- appendIdx = numel(S) + 1;
-
- for jField = 1:numel(fieldNames)
- thisField = fieldNames{jField};
- S(appendIdx).(thisField) = defaultAddonList(iAddon).(thisField);
+
+ % Change current directory to the Add-On installation folder
+ function cdInstallationFolder(obj)
+ cd(obj.InstallationFolder)
+ end
+
+ function numAddonsInstalled = installMissingAddons(obj, modules)
+ % installMissingAddons - Install add-ons that are not installed.
+ arguments
+ obj (1,1) nansen.config.addons.AddonManager
+ modules (1,:) string = string.empty
+ end
+
+ obj.refreshManagedAddons("SelectedModules", modules);
+ addonEntries = obj.getManagedAddonsForModules(modules);
+
+ numAddonsInstalled = 0;
+ for i = 1:numel(addonEntries)
+ addonEntry = addonEntries(i);
+ if ~addonEntry.IsInstalled
+ obj.downloadAddon(addonEntry.Name)
+ numAddonsInstalled = numAddonsInstalled + 1;
end
-
- % Check if addon is found on matlab's path and update
- % IsInstalled flag
- if ismember(exist(S(appendIdx).FunctionName), [2,8])
- S(appendIdx).IsInstalled = true;
- S(appendIdx).DateInstalled = char(datetime("now")); %todo: format?
- else
- S(appendIdx).IsInstalled = false;
- S(appendIdx).DateInstalled = 'N/A';
+ end
+ obj.saveAddonList()
+
+ if ~nargout
+ clear numAddonsInstalled
+ end
+ end
+
+ function updateAddons(obj, modules)
+ % updateAddons - Update tracked installed addons.
+ arguments
+ obj (1,1) nansen.config.addons.AddonManager
+ modules (1,:) string = string.empty
+ end
+
+ obj.refreshManagedAddons("SelectedModules", modules);
+ addonEntries = obj.getManagedAddonsForModules(modules);
+
+ for i = 1:numel(addonEntries)
+ addonEntry = addonEntries(i);
+ if addonEntry.IsInstalled
+ obj.downloadAddon(addonEntry.Name, true)
end
-
- % Set this flag to false. This should change if an addon is
- % installed, but not saved to the matlab search path.
- S(appendIdx).AddToPathOnInit = false;
end
-
- % Update package and download url links
- for i = 1:numel(S)
- thisName = S(i).Name;
- isMatch = strcmp(thisName, defaultAddonNames);
- if ~any(isMatch); return; end
-
- S(i).DownloadUrl = defaultAddonList(isMatch).DownloadUrl;
- S(i).WebUrl = defaultAddonList(isMatch).WebUrl;
- S(i).SetupFileName = defaultAddonList(isMatch).SetupFileName;
+ obj.saveAddonList()
+ end
+
+ function saveAddonList(obj)
+ %saveAddonList Save the addon list as JSON.
+ savedData = struct();
+ savedData.type = 'Nansen Configuration: List of Installed Addons';
+ savedData.description = 'Installed addons tracked by NANSEN AddonManager';
+ savedData.AddonList = obj.AddonList;
+ jsonText = jsonencode(savedData, 'PrettyPrint', true);
+ nansenDirectory = fileparts(obj.ManifestFilePath);
+ if ~isfolder(nansenDirectory); mkdir(nansenDirectory); end
+ fileIdentifier = fopen(obj.ManifestFilePath, 'w');
+ assert(fileIdentifier ~= -1, ...
+ 'NANSEN:AddonManager:SaveFailed', ...
+ 'Could not open addon manifest for writing: %s', ...
+ obj.ManifestFilePath)
+ closeFile = onCleanup(@() fclose(fileIdentifier));
+ fwrite(fileIdentifier, jsonText, 'char');
+
+ obj.markClean()
+ end
+
+ function refreshManagedAddons(obj, options)
+ %refreshManagedAddons Sync addon list with resolved dependencies.
+ arguments
+ obj
+ options.SelectedModules (1,:) string = string.empty
+ options.ManifestPaths (1,:) string = string.empty
end
+
+ resolveOptions = { ...
+ "DependencyTypes", "community-toolbox", ...
+ "SelectedModules", options.SelectedModules, ...
+ "TrackedAddons", obj.AddonList};
+ if ~isempty(options.ManifestPaths)
+ resolveOptions = [resolveOptions, ...
+ {"ManifestPaths"}, {options.ManifestPaths}];
+ end
+ resolvedRequirements = nansen.internal.dependencies.resolveRequirements( ...
+ resolveOptions{:});
+
+ obj.mergeRequirementsIntoAddonList(resolvedRequirements);
+ obj.checkAddonDuplication()
end
-
- function tf = browseAddonPath(obj, addonName)
-
+
+ % Open UI dialog for user to locate Add-On
+ function tf = locateAddonPath(obj, addonName)
+ %locateAddonPath Manually locate an addon folder on disk.
tf = false;
addonIdx = obj.getAddonIndex(addonName);
-
- % Open path dialog to locate folderpath for addon
pkgInstallationDir = uigetdir();
-
if pkgInstallationDir == 0
return
end
-
obj.AddonList(addonIdx).IsInstalled = true;
- obj.AddonList(addonIdx).DateInstalled = string(datetime("now"));
+ obj.AddonList(addonIdx).DateInstalled = char(datetime("now"));
obj.AddonList(addonIdx).FilePath = pkgInstallationDir;
-
- % Addon is added using this addon manager. Addon should
- % therefore be added to the Matlab search path when this
- % class is initialized. (assume it should not permanently be
- % saved to the search path)
+ obj.AddonList(addonIdx).InstallationType = 'folder';
+ obj.AddonList(addonIdx).ToolboxIdentifier = '';
obj.AddonList(addonIdx).AddToPathOnInit = true;
-
+ obj.addAddonToMatlabPath(addonIdx)
+ obj.AddonList(addonIdx).IsOnPath = true;
+ obj.saveAddonList();
tf = true;
end
-
+
+ % Download (and install) specified Add-On
function downloadAddon(obj, addonIdx, updateFlag, throwErrorIfFails)
- %downloadAddon Download addon to a specified addon folder
-
+ %downloadAddon Install an addon via matbox.
if nargin < 3; updateFlag = false; end
if nargin < 4; throwErrorIfFails = false; end
-
- if isa(updateFlag, 'char') && strcmp(updateFlag, 'update')
+ if ischar(updateFlag) && strcmp(updateFlag, 'update')
updateFlag = true;
end
-
- % Get addon entry from the given addon index
+
addonIdx = obj.getAddonIndex(addonIdx);
addonEntry = obj.AddonList(addonIdx);
-
- % Create a temporary path for storing the downloaded file.
- fileType = obj.getFileTypeFromUrl(addonEntry);
- tempFilepath = [tempname, fileType];
-
- % Download the file containing the addon toolbox
+ sourceUri = string(addonEntry.Source);
+
try
- tempFilepath = websave(tempFilepath, addonEntry.DownloadUrl);
- fileCleanupObj = onCleanup( @(fname) delete(tempFilepath) );
+ installResult = obj.installViaMatbox( ...
+ sourceUri, updateFlag);
catch ME
if throwErrorIfFails
rethrow(ME)
+ else
+ warning('NANSEN:AddonManager:InstallFailed', ...
+ 'Failed to install %s: %s', addonEntry.Name, ME.message)
+ return
end
end
-
- if updateFlag && ~isempty(addonEntry.FilePath)
- pkgInstallationDir = addonEntry.FilePath;
- %rootDir = utility.path.getAncestorDir(pkgInstallationDir);
-
- % Delete current version
- if isfolder(pkgInstallationDir)
- if contains(path, pkgInstallationDir)
- rmpath(genpath(pkgInstallationDir))
- end
- try
- rmdir(pkgInstallationDir, 's')
- catch
- warning('Could not remove old installation... Please report')
- end
- end
- else
-
- switch addonEntry.Type
- case 'General'
- subfolderPath = 'general_toolboxes';
- case 'Neuroscience'
- subfolderPath = 'neuroscience_toolboxes';
- end
-
- % Create a pathstring for the installation directory
- rootDir = fullfile(obj.InstallationDir, subfolderPath);
- pkgInstallationDir = fullfile(rootDir, addonEntry.Name);
- end
-
- switch fileType
- case '.zip'
- unzip(tempFilepath, pkgInstallationDir);
- case '.mltbx'
- obj.installMatlabToolbox(tempFilepath) % Todo: pass updateFlag
- end
-
- % Delete the temp zip file
- clear fileCleanupObj
-
- % Fix github unzipped directory...
- if strcmp(addonEntry.Source, 'Github')
- renamedDir = obj.restructureUnzippedGithubRepo(pkgInstallationDir);
- pkgInstallationDir = renamedDir;
- end
- obj.AddonList(addonIdx).FilePath = pkgInstallationDir;
-
- % Addon is added using this addon manager. Addon should
- % therefore be added to the Matlab search path when this
- % class is initialized. (assume it should not permanently be
- % saved to the search path)
+ obj.AddonList(addonIdx).FilePath = obj.getCharOrEmpty(installResult.FilePath);
+ obj.AddonList(addonIdx).InstallationType = char(installResult.InstallationType);
+ obj.AddonList(addonIdx).ToolboxIdentifier = char(installResult.ToolboxIdentifier);
obj.AddonList(addonIdx).AddToPathOnInit = true;
+ obj.AddonList(addonIdx).IsInstalled = true;
+ obj.AddonList(addonIdx).IsOnPath = true;
+ obj.AddonList(addonIdx).DateInstalled = char(datetime("now"));
+ obj.addAddonToMatlabPath(addonIdx)
obj.markDirty()
- addpath(genpath(pkgInstallationDir))
+ % Run named setup function if specified (matbox only runs setup.m)
try
- % Run setup of package if it has a setup function.
- if ~isempty(obj.AddonList(addonIdx).SetupFileName)
- setupFcn = str2func(obj.AddonList(addonIdx).SetupFileName);
- setupFcn()
+ if ~isempty(addonEntry.SetupFunctionName)
+ feval(addonEntry.SetupFunctionName)
end
catch MECause
- rmpath(genpath(pkgInstallationDir))
- rmdir(pkgInstallationDir, "s")
if throwErrorIfFails
- ME = MException("Nansen:AddonInstallFailed", 'Setup of the toolbox %s failed.', addonEntry.Name);
+ ME = MException("Nansen:AddonInstallFailed", ...
+ 'Setup of the toolbox %s failed.', addonEntry.Name);
ME = ME.addCause(MECause);
- disp(getReport(MECause, 'extended'))
throw(ME)
else
warning('Setup of the toolbox %s failed with the following error:', addonEntry.Name)
disp(getReport(MECause, 'extended'))
end
end
-
- obj.AddonList(addonIdx).IsInstalled = true;
- obj.AddonList(addonIdx).DateInstalled = char(datetime("now"));
end
-
- function updateSearchPath(obj)
- %updateSearchPath Add addons to the search path in the current matlab session
-
- for i = 1:numel(obj.AddonList)
- % Only add those who have filepath assigned (those are added from this interface)
- if obj.AddonList(i).AddToPathOnInit
- obj.addAddonToMatlabPath(i)
- end
+
+ % Check if specified Add-On is installed
+ function tf = isAddonInstalled(obj, addonName)
+ %isAddonInstalled Check if addon is tracked as installed.
+
+ tf = false;
+ if any(strcmp({obj.AddonList.Name}, addonName))
+ addonIndex = obj.getAddonIndex(addonName);
+ tf = obj.AddonList(addonIndex).IsInstalled;
end
end
-
- function addAddonToMatlabPath(obj, addonIdx)
-
- addonIdx = obj.getAddonIndex(addonIdx);
- pathList = genpath(obj.AddonList(addonIdx).FilePath);
-
- % Remove all .git subfolders from this list
- pathListCell = strsplit(pathList, pathsep);
- keep = ~contains(pathListCell, '.git');
- pathListCell = pathListCell(keep);
- pathListNoGit = strjoin(pathListCell, pathsep);
- % Add all remaining folders to path.
- addpath(pathListNoGit);
- end
-
- function addAllToMatlabPath(obj)
-
+ function ensureAddonDependenciesOnPath(obj)
+ %ensureAddonDependenciesOnPath Activate all tracked installed addons.
for i = 1:numel(obj.AddonList)
-
- % Only add those who have filepath assigned (those are added from this interface)
- if ~isempty(obj.AddonList(i).FilePath)
+ if obj.AddonList(i).IsInstalled
obj.addAddonToMatlabPath(i)
+ obj.AddonList(i).IsOnPath = obj.isAddonOnPath(obj.AddonList(i));
end
end
end
-
+
function restoreAddToPathOnInitFlags(obj)
-
+ %restoreAddToPathOnInitFlags Clear session path flags after permanent save.
for i = 1:numel(obj.AddonList)
-
- % Only add those who have filepath assigned (those are added from this interface)
if obj.AddonList(i).IsInstalled
if obj.AddonList(i).AddToPathOnInit
obj.AddonList(i).AddToPathOnInit = false;
end
end
end
-
obj.saveAddonList()
end
-
- function checkAddonDuplication(obj)
- for i = 1:numel(obj.AddonList)
-
- pathStr = which( obj.AddonList(i).FunctionName, '-all');
-
- if isa(pathStr, 'cell') && numel(pathStr) > 1
- obj.AddonList(i).IsDoubleInstalled = true;
- end
- end
- end
-
- % Not implemented yet. Future todo
- function runAddonSetup(obj, addonIdx)
-
- end
-
- function TF = isAddonInstalled(obj, addonName)
- % isAddonInstalled - Check if addon is installed
- TF = any(strcmp({obj.AddonList.Name}, addonName));
-
- % Todo/Question:
- % Should we look for whether name of package is present?
- % Or, look for a function in the package and check if it is
- % on path...?
- end
-
- % Not implemented (Not urgent):
- function TF = isAddonUpToDate(obj)
- %isAddonRecent
- % Check if version is latest...?
- end
-
function markDirty(obj)
obj.IsDirty = true;
end
-
+
function markClean(obj)
obj.IsDirty = false;
end
end
- methods (Access = protected)
-
- function addonIdx = getAddonIndex(obj, addonIdx)
- %getAddonIndex Get index (number) of addon in list given addon name
-
- if isa(addonIdx, 'char')
- addonIdx = strcmpi({obj.AddonList.Name}, addonIdx);
+ methods % Set/get
+ function managedAddons = get.ManagedAddons(obj)
+ T = struct2table(obj.AddonList);
+ numberColumn = table((1:size(T,1))', 'VariableNames', {'Num'});
+ managedAddons = [numberColumn T];
+
+ % Enrich name with link to online documentation if present
+ for i = 1:height(managedAddons)
+ if ~isempty(managedAddons{i, 'DocsSource'}{1})
+ name = nansen.internal.utility.createCommandWindowWebLink(...
+ managedAddons{i, 'DocsSource'}{1}, managedAddons{i, 'Name'}{1});
+ managedAddons{i, 'Name'} = {name};
+ end
end
-
- if isempty(addonIdx)
- error('Something went wrong, addon was not found in list.')
+
+ managedAddons = removevars(managedAddons, ...
+ ["Source", "DocsSource", "SetupFunctionName", "InstallCheck", ...
+ "ToolboxIdentifier","AddToPathOnInit", "HasMultipleInstancesOnPath"]);
+ managedAddons.Name = string(managedAddons.Name);
+ managedAddons.Description = string(managedAddons.Description);
+ managedAddons = movevars(managedAddons, "IsOnPath", "After", "IsInstalled");
+ managedAddons = movevars(managedAddons, "Description", "After", "IsOnPath");
+ end
+
+ function addonEntries = getManagedAddonsForModules(obj, modules)
+ %getManagedAddonsForModules Return managed addons relevant for selected modules.
+ arguments
+ obj (1,1) nansen.config.addons.AddonManager
+ modules (1,:) string = string.empty
end
+
+ resolvedRequirements = nansen.internal.dependencies.resolveRequirements( ...
+ "DependencyTypes", "community-toolbox", ...
+ "SelectedModules", modules, ...
+ "TrackedAddons", obj.AddonList);
+ addonNames = string({resolvedRequirements.Name});
+ isMatch = ismember(string({obj.AddonList.Name}), addonNames);
+ addonEntries = obj.AddonList(isMatch);
end
end
-
- methods (Hidden, Access = protected)
-
- function pathStr = getPathForAddonList(obj, prefDir)
- %getPathForAddonList Get path where local addon list is saved.
-
- if nargin < 2 || isempty(prefDir)
- prefDir = fullfile(nansen.prefdir, 'settings');
+
+ methods (Hidden)
+ function downloadAndInstallMatBox(obj)
+ installResult = obj.createInstallResult("", "folder", "");
+ wasInstalledNow = false;
+
+ if ~exist('+matbox/installRequirements', 'file')
+ sourceFile = 'https://raw.githubusercontent.com/ehennestad/matbox-actions/refs/heads/main/install-matbox/installMatBox.m';
+ filePath = websave('installMatBox.m', sourceFile);
+ [installationFolder, installationMethod] = installMatBox('commit');
+ installResult = obj.createInstallResult(installationFolder, installationMethod, "");
+ wasInstalledNow = true;
+ rehash()
+ delete(filePath);
+ else
+ matboxFunctionPath = which('matbox.VersionNumber');
+ if ~isempty(matboxFunctionPath)
+ matboxRootFolder = fileparts(fileparts(matboxFunctionPath));
+ installResult = obj.createInstallResult(matboxRootFolder, "folder", "");
+ end
+ end
+
+ addonIdx = obj.getAddonIndex('MatBox');
+ addonEntry = obj.AddonList(addonIdx);
+
+ hasChanges = false;
+ filePath = obj.getCharOrEmpty(installResult.FilePath);
+ installationType = obj.getCharOrEmpty(installResult.InstallationType);
+
+ if ~addonEntry.IsInstalled
+ obj.AddonList(addonIdx).IsInstalled = true;
+ hasChanges = true;
+ end
+ if ~addonEntry.IsOnPath
+ obj.AddonList(addonIdx).IsOnPath = true;
+ hasChanges = true;
+ end
+ if ~isempty(filePath) && ~strcmp(addonEntry.FilePath, filePath)
+ obj.AddonList(addonIdx).FilePath = filePath;
+ hasChanges = true;
+ end
+ if ~isempty(installationType) && ~strcmp(addonEntry.InstallationType, installationType)
+ obj.AddonList(addonIdx).InstallationType = installationType;
+ hasChanges = true;
+ end
+ if ~strcmp(addonEntry.ToolboxIdentifier, '')
+ obj.AddonList(addonIdx).ToolboxIdentifier = '';
+ hasChanges = true;
+ end
+ if wasInstalledNow && ~addonEntry.AddToPathOnInit
+ obj.AddonList(addonIdx).AddToPathOnInit = true;
+ hasChanges = true;
+ end
+ if ~addonEntry.IsInstalled || ...
+ (ischar(addonEntry.DateInstalled) && strcmp(addonEntry.DateInstalled, 'N/A'))
+ obj.AddonList(addonIdx).DateInstalled = char(datetime("now"));
+ hasChanges = true;
end
- if ~isfolder(prefDir); mkdir(prefDir); end
- pathStr = fullfile(prefDir, 'installed_addons.mat');
+ if hasChanges
+ obj.markDirty()
+ obj.saveAddonList()
+ end
end
-
- function fileType = getFileTypeFromUrl(obj, addonEntry)
- %getFileTypeFromUrl Get filetype from the url download entry.
- downloadUrl = addonEntry.DownloadUrl;
-
- % Todo: Does this generalize well?
- switch addonEntry.Source
-
- case 'FileExchange'
- [~, fileType, ~] = fileparts(downloadUrl);
- fileType = strcat('.', fileType);
- case 'Github'
- [~, ~, fileType] = fileparts(downloadUrl);
+
+ function viewFullAddonListAsTable(obj)
+ disp( struct2table(obj.AddonList) )
+ end
+ end
+
+ methods (Access = private)
+
+ function loadAddonManifest(obj)
+ %loadAddonManifest - Load addon manifest from file.
+ wasMigrated = false;
+ if isfile(obj.ManifestFilePath)
+ jsonText = fileread(obj.ManifestFilePath);
+ savedData = jsondecode(jsonText);
+ addonList = savedData.AddonList;
+ if iscell(addonList)
+ addonList = [addonList{:}];
+ end
+ else
+ [addonList, wasMigrated] = obj.tryMigrateFromLegacyLocation();
+ end
+ obj.AddonList = addonList;
+
+ if wasMigrated
+ obj.saveAddonList()
end
end
-
- % Following functions are not implemented
- function downloadGithubAddon(obj, addonName)
-
+
+ function mergeRequirementsIntoAddonList(obj, resolvedRequirements)
+ % mergeRequirementsIntoAddonList - Sync addon list with resolved dependencies.
+ if nargin < 2
+ resolvedRequirements = nansen.internal.dependencies.resolveRequirements( ...
+ "DependencyTypes", "community-toolbox");
+ end
+
+ if isempty(resolvedRequirements)
+ return
+ end
+
+ resolvedNames = string({resolvedRequirements.Name});
+ currentAddonNames = string({obj.AddonList.Name});
+
+ for idx = 1:numel(resolvedRequirements)
+ entry = resolvedRequirements(idx);
+ addonIdx = find(currentAddonNames == resolvedNames(idx), 1);
+
+ if isempty(addonIdx)
+ addonIdx = numel(obj.AddonList) + 1;
+ obj.AddonList(addonIdx) = obj.createAddonEntry(entry);
+ currentAddonNames(end+1) = resolvedNames(idx); %#ok
+ end
+
+ obj.AddonList(addonIdx).Name = char(entry.Name);
+ obj.AddonList(addonIdx).IsRequired = entry.RequirementLevel == "required";
+ obj.AddonList(addonIdx).Description = char(entry.Description);
+ obj.AddonList(addonIdx).Source = char(entry.Source);
+ obj.AddonList(addonIdx).DocsSource = char(entry.DocsSource);
+ obj.AddonList(addonIdx).SetupFunctionName = char(entry.SetupHook);
+ obj.AddonList(addonIdx).InstallCheck = char(entry.InstallCheck);
+ obj.AddonList(addonIdx).IsInstalled = entry.IsInstalled;
+ obj.AddonList(addonIdx).IsOnPath = entry.IsOnPath;
+
+ if obj.AddonList(addonIdx).IsInstalled && strcmp(obj.AddonList(addonIdx).DateInstalled, 'N/A')
+ obj.AddonList(addonIdx).DateInstalled = char(datetime("now"));
+ end
+ end
end
-
- function downloadMatlabAddon(obj, addonName)
-
+
+ function checkAddonDuplication(obj)
+ %checkAddonDuplication Detect addons with multiple path locations.
+ for i = 1:numel(obj.AddonList)
+ probeName = obj.AddonList(i).InstallCheck;
+ obj.AddonList(i).HasMultipleInstancesOnPath = false;
+ if isempty(probeName); continue; end
+ pathStr = which(probeName, '-all');
+ if isa(pathStr, 'cell') && numel(pathStr) > 1
+ obj.AddonList(i).HasMultipleInstancesOnPath = true;
+ end
+ end
end
- function installGithubAddon(obj, addonName)
-
+ function addAddonToMatlabPath(obj, addonIdx)
+ %addAddonToMatlabPath Activate an installed addon for this MATLAB session.
+ addonIdx = obj.getAddonIndex(addonIdx);
+ addonEntry = obj.AddonList(addonIdx);
+ installationType = string(addonEntry.InstallationType);
+
+ if installationType == "mltbx"
+ obj.enableToolboxAddon(addonEntry)
+ return
+ end
+
+ addonFilePath = addonEntry.FilePath;
+ if isempty(addonFilePath) || ~isfolder(addonFilePath)
+ return
+ end
+ pathList = genpath(addonFilePath);
+ pathListCell = strsplit(pathList, pathsep);
+ keep = ~contains(pathListCell, '.git');
+ pathListCell = pathListCell(keep);
+ pathListNoGit = strjoin(pathListCell, pathsep);
+ addpath(pathListNoGit);
end
-
- function installMatlabAddon(obj, addonName)
-
+
+ function addonIdx = getAddonIndex(obj, addonIdx)
+ %getAddonIndex Get index of addon by name or pass through numeric index.
+ if ischar(addonIdx) || isstring(addonIdx)
+ addonIdx = find(strcmpi({obj.AddonList.Name}, addonIdx));
+ end
+ if isempty(addonIdx)
+ error('NANSEN:AddonManager:NotFound', ...
+ 'Addon was not found in list.')
+ end
end
-
- function installMatlabToolbox(obj, fileName)
-
- % Will install to the default matlab toolbox/addon directory.
- newAddon = matlab.addons.install(fileName);
-
-% NEWADDON is a table of strings with these fields:
-% Name - Name of the installed add-on
-% Version - Version of the installed add-on
-% Enabled - Whether the add-on is enabled
-% Identifier - Unique identifier of the installed add-on
-
+
+ function installResult = installViaMatbox(obj, sourceUri, doUpdate)
+ %installViaMatbox Delegate installation to matbox based on URI type.
+
+ installResult = matbox.setup.installFromSourceUri( ...
+ sourceUri, ...
+ "InstallationLocation", obj.InstallationFolder, ...
+ "AddToPath", true, ...
+ "Update", doUpdate, ...
+ "Verbose", true, ...
+ "AgreeToLicense", true);
+
+ assert( isfield(installResult, 'FilePath') && ...
+ isfield(installResult, 'InstallationType') && ...
+ isfield(installResult, 'ToolboxIdentifier'))
end
end
-
- methods (Hidden)
-
- function showAddonFiletype(obj)
- %showAddonFiletype Show the filetype of the downloaded addon files
- %
- % Method for testing/verification
-
- for i = 1:numel(obj.AddonList)
- thisAddon = obj.AddonList(i);
- fileType = obj.getFileTypeFromUrl(thisAddon);
-
- fprintf('%s : %s\n', thisAddon.Name, fileType)
+
+ methods (Access = private)
+ function addonEntry = createAddonEntry(obj, entry)
+ addonEntry = obj.getDefaultAddonEntry();
+
+ addonEntry.Name = char(entry.Name);
+ addonEntry.IsRequired = entry.RequirementLevel == "required";
+ addonEntry.IsInstalled = entry.IsInstalled;
+ addonEntry.IsOnPath = entry.IsOnPath;
+ addonEntry.DateInstalled = 'N/A';
+ addonEntry.FilePath = '';
+ addonEntry.Description = char(entry.Description);
+ addonEntry.Source = char(entry.Source);
+ addonEntry.DocsSource = char(entry.DocsSource);
+ addonEntry.SetupFunctionName = char(entry.SetupHook);
+ addonEntry.InstallCheck = char(entry.InstallCheck);
+ addonEntry.InstallationType = '';
+ addonEntry.ToolboxIdentifier = '';
+ addonEntry.AddToPathOnInit = false;
+ addonEntry.HasMultipleInstancesOnPath = false;
+
+ if entry.IsInstalled
+ addonEntry.DateInstalled = char(datetime("now"));
+ end
+ end
+
+ function tf = isAddonOnPath(~, addonEntry)
+ tf = false;
+ if ~isempty(addonEntry.InstallCheck)
+ tf = ~isempty(which(addonEntry.InstallCheck));
+ end
+ end
+
+ function enableToolboxAddon(~, addonEntry)
+ if ~isempty(addonEntry.InstallCheck) && ~isempty(which(addonEntry.InstallCheck))
+ return
+ end
+ if ~isempty(addonEntry.ToolboxIdentifier)
+ try
+ matlab.addons.enableAddon(addonEntry.ToolboxIdentifier)
+ return
+ catch
+ end
+ end
+ if ~isempty(addonEntry.Name)
+ matlab.addons.enableAddon(addonEntry.Name)
end
end
end
- methods (Static)
- function checkIfAddonsAreOnPath()
-
- import nansen.config.addons.AddonManager
+ methods (Static, Hidden)
+ function pathStr = getDefaultInstallationDir()
+ %getDefaultInstallationDir Get default addon installation directory.
+ nansen.config.addons.AddonManager.ensureUserpathAvailable()
+ pathStr = fullfile(userpath, 'Nansen', 'Add-Ons');
+ end
+ function checkIfAddonsAreOnPath() % Todo: Needs improvement, not urgent
+ %checkIfAddonsAreOnPath Prompt user to add missing addon folders to path.
+ import nansen.config.addons.AddonManager
addonDir = AddonManager.getDefaultInstallationDir();
-
- % Get all subfolders two levels down
subfolders = utility.path.listSubDir(addonDir, '', {}, 2);
-
isOnPath = true(size(subfolders));
-
if ~isempty(subfolders)
for i = 1:numel(subfolders)
if ~contains(path, subfolders{i})
- isOnPath(i)=false;
+ isOnPath(i) = false;
end
end
end
-
if any(~isOnPath)
subfoldersNotOnPath = subfolders(~isOnPath);
[~, addonNames] = fileparts(subfoldersNotOnPath);
-
- msg = sprintf("The following add-ons where not present on the MATLAB path: \n\n%s \n\nDo you want to add them now?", strjoin(addonNames, newline));
+ msg = sprintf( ...
+ "The following add-ons were not present on the MATLAB path:\n\n%s\n\nDo you want to add them now?", ...
+ strjoin(addonNames, newline));
answer = questdlg(msg, 'Update MATLAB path?');
-
- switch answer
- case 'Yes'
- for i = 1:numel(subfoldersNotOnPath)
- addpath(genpath(subfoldersNotOnPath{i}))
- end
- savepath()
+ if strcmp(answer, 'Yes')
+ for i = 1:numel(subfoldersNotOnPath)
+ addpath(genpath(subfoldersNotOnPath{i}))
+ end
+ savepath()
end
end
end
end
-
- methods (Static)
+
+ methods (Static, Access = private)
+ function addonEntry = getDefaultAddonEntry()
+ addonEntry = nansen.config.addons.AddonManager.DefaultAddonEntry;
+ end
- function S = initializeAddonList()
+ function addonEntry = initializeAddonList()
%initializeAddonList Create an empty struct with addon fields.
-
- names = nansen.config.addons.AddonManager.addonFields;
- values = repmat({{}}, size(names));
-
- structInit = [names; values];
-
- S = struct(structInit{:});
+ addonEntry = nansen.config.addons.AddonManager.DefaultAddonEntry;
+ addonEntry(1) = [];
end
- function pathStr = getDefaultInstallationDir()
- %getDefaultInstallationDir Get path to default directory for
- % installing addons
-
- % Assign installation directory.
- % QTodo: get "userpath" from preferences?
- pathStr = fullfile(userpath, 'Nansen', 'Add-Ons');
+ function ensureUserpathAvailable()
+ if isempty(userpath)
+ nansen.internal.setup.resolveEmptyUserpath()
+ end
end
- function folderPath = restructureUnzippedGithubRepo(folderPath)
- %restructureUnzippedGithubRepo Move the folder of a github addon.
- %
-
- % Github packages unzips to a new folder within the created
- % folder. Move it up one level. Also, remove the '-master' from
- % foldername.
-
- rootDir = fileparts(folderPath);
-
- % Find the repository folder
- L = dir(folderPath);
- L = L(~strncmp({L.name}, '.', 1));
+ function newAddonList = migrateLegacyAddonList(oldAddonList)
+ %migrateLegacyAddonList Remap legacy addon struct fields to new names.
+ % Handles old addon lists that have DownloadUrl, WebUrl,
+ % SetupFileName, FunctionName fields.
- if numel(L) > 1
- % This is unexpected, there should only be one folder.
- return
- end
+ import nansen.config.addons.AddonManager
- % Move folder up one level
- oldDir = fullfile(folderPath, L.name);
- newDir = fullfile(rootDir, L.name);
- movefile(oldDir, newDir)
- rmdir(folderPath)
+ newAddonList = AddonManager.initializeAddonList();
+ if isempty(oldAddonList); return; end
+
+ if isfield(oldAddonList, 'DownloadUrl') && ~isfield(oldAddonList, 'Source')
- % Remove the master postfix from foldername
- if contains(L.name, '-master')
- newName = strrep(L.name, '-master', '');
- elseif contains(L.name, '-main')
- newName = strrep(L.name, '-main', '');
+ for i = 1:numel(oldAddonList)
+ newAddonList(i) = AddonManager.getDefaultAddonEntry();
+
+ newAddonList(i).Name = oldAddonList(i).Name;
+ newAddonList(i).Description = oldAddonList(i).Description;
+ newAddonList(i).Source = oldAddonList(i).DownloadUrl;
+ newAddonList(i).DocsSource = oldAddonList(i).WebUrl;
+ newAddonList(i).SetupFunctionName = oldAddonList(i).SetupFileName;
+ newAddonList(i).InstallCheck = oldAddonList(i).FunctionName;
+ newAddonList(i).IsInstalled = oldAddonList(i).IsInstalled;
+ newAddonList(i).DateInstalled = oldAddonList(i).DateInstalled;
+ newAddonList(i).FilePath = oldAddonList(i).FilePath;
+
+ if ~isempty(oldAddonList(i).FilePath) && isfolder(oldAddonList(i).FilePath)
+ newAddonList(i).InstallationType = 'folder';
+ end
+ end
else
- folderPath = fullfile(rootDir, L.name);
- return
+ newAddonList = oldAddonList;
end
-
- % Rename folder to remove main/master tag
- renamedDir = fullfile(rootDir, newName);
- if isfolder(renamedDir)
- rmdir(renamedDir, 's')
+ end
+
+ function [addonList, wasMigrated] = tryMigrateFromLegacyLocation()
+ %tryMigrateFromLegacyLocation Migrate addon list from legacy .mat location.
+ wasMigrated = false;
+ legacyMatPath = fullfile(prefdir, 'Nansen', 'default', 'settings', 'installed_addons.mat');
+ if isfile(legacyMatPath)
+ loadedData = load(legacyMatPath);
+ addonList = loadedData.AddonList;
+ addonList = nansen.config.addons.AddonManager.migrateLegacyAddonList(addonList);
+ wasMigrated = true;
+ else
+ addonList = nansen.config.addons.AddonManager.initializeAddonList();
end
- movefile(newDir, renamedDir)
- folderPath = renamedDir;
end
- end
- methods (Static, Access = private)
function pathStr = getDefaultInstallationDirLegacy()
- % Note: This method will be removed in a future version (todo).
+ % Note: This method will be removed in a future version.
pathStr = fullfile(nansen.rootpath, 'external');
end
end
- methods (Access = ?nansen.internal.user.NansenUserSession)
- % Note: This method will be removed in a future version (todo).
+ methods (Static, Access = private)
+
+ function pathStr = getPathForAddonManifest()
+ %getPathForAddonManifest Get path where local addon list is saved.
+ nansen.config.addons.AddonManager.ensureUserpathAvailable()
+ nansenDirectory = fullfile(userpath, 'Nansen');
+ if ~isfolder(nansenDirectory); mkdir(nansenDirectory); end
+ pathStr = fullfile(nansenDirectory, 'installed_addons.json');
+ end
+
+ function installResult = createInstallResult(filePath, installationType, toolboxIdentifier)
+ if nargin < 3
+ toolboxIdentifier = "";
+ end
+
+ filePath = string(filePath);
+ installationType = string(installationType);
+ toolboxIdentifier = string(toolboxIdentifier);
+
+ if isempty(filePath) || any(ismissing(filePath))
+ filePath = "";
+ end
+ if isempty(installationType) || any(ismissing(installationType))
+ installationType = "folder";
+ end
+ if isempty(toolboxIdentifier) || any(ismissing(toolboxIdentifier))
+ toolboxIdentifier = "";
+ end
+
+ installResult = struct( ...
+ 'FilePath', filePath, ...
+ 'InstallationType', installationType, ...
+ 'ToolboxIdentifier', toolboxIdentifier);
+ end
+
+ function value = getCharOrEmpty(stringValue)
+ stringValue = string(stringValue);
+ if isempty(stringValue) || any(ismissing(stringValue))
+ value = '';
+ else
+ value = char(stringValue);
+ end
+ end
+ end
+
+ methods (Hidden) % For backwards compatibility. Will be removed in future
moveExternalToolboxes(obj) % Method in separate file
end
- methods (Static, Access = ?nansen.internal.user.NansenUserSession)
+ methods (Static, Hidden) % For backwards compatibility. Will be removed in future
function tf = existExternalToolboxInRepository()
- % Note: This method will be removed in a future version (todo).
+ % Note: This method will be removed in a future version.
rootDir = fullfile(nansen.rootpath, 'external');
tf = isfolder(fullfile(rootDir, 'general_toolboxes')) || ...
isfolder(fullfile(rootDir, 'neuroscience_toolboxes'));
diff --git a/code/+nansen/+config/+addons/Contents.m b/code/+nansen/+config/+addons/Contents.m
new file mode 100644
index 00000000..fc972a40
--- /dev/null
+++ b/code/+nansen/+config/+addons/Contents.m
@@ -0,0 +1,16 @@
+% Overview of nansen.config.addons namespace
+% Managed addon installation and configuration for NANSEN.
+%
+% This package contains the stateful addon-management layer. It tracks
+% managed addons, persists enriched addon records, installs and updates
+% community toolboxes, and provides addon-management UI components.
+%
+% Files
+% AddonPreferences - Preferences for addon management.
+%
+% Classes
+% AddonManager - Track, install, update, and activate managed addons.
+% AddonManagerUI - UI table for addon management.
+% AddonManagerApp - App wrapper for the addon manager UI.
+%
+% See also nansen.internal.dependencies
diff --git a/code/+nansen/+config/+addons/README.md b/code/+nansen/+config/+addons/README.md
new file mode 100644
index 00000000..b662aab9
--- /dev/null
+++ b/code/+nansen/+config/+addons/README.md
@@ -0,0 +1,20 @@
+# `nansen.config.addons`
+
+This namespace contains the stateful addon-management layer for NANSEN.
+
+Its responsibilities are:
+
+- persist enriched addon records in `AddonList`
+- install, update, and locate managed addons
+- activate addons for the current MATLAB session
+- provide addon-management UI
+
+It consumes resolved dependency information from
+`nansen.internal.dependencies`, but it owns installation side effects and
+persisted addon state.
+
+Main entry points:
+
+- `AddonManager`
+- `AddonManagerUI`
+- `AddonManagerApp`
diff --git a/code/+nansen/+config/+addons/getDefaultAddonList.m b/code/+nansen/+config/+addons/getDefaultAddonList.m
deleted file mode 100644
index c6ffc5ba..00000000
--- a/code/+nansen/+config/+addons/getDefaultAddonList.m
+++ /dev/null
@@ -1,170 +0,0 @@
-function S = getDefaultAddonList()
-%getAddonList Return a list of addons that are needed
-
-% Name : Name of addon toolbox
-% Source : Where to download addon from (FileExchange or GitHub)
-% WebUrl : Web Url for addon download
-% HasSetupFile : Is there a setup file that should be run?
-% SetupName : Name of setup file if there are any
-% FunctionName : Name of function in repository (used to check if repository already exists on matlab's path)
-
-% Todo: Add git commit id, and use it for checking if latest version is
-% downloaded...
-% Use git instead of downloading zipped versions of repositories...
-
- i = 1;
-
- S(i).Name = 'YAML-Matlab';
- S(i).Description = 'Reading in and writing out a yaml file.';
- S(i).IsRequired = true;
- S(i).Type = 'General';
- S(i).Source = 'Github';
- S(i).WebUrl = 'https://github.com/ewiger/yamlmatlab';
- S(i).DownloadUrl = 'https://github.com/ehennestad/yamlmatlab/archive/refs/heads/master.zip'; % Fixed some bugs with original
- S(i).HasSetupFile = false;
- S(i).SetupFileName = 'nansen.internal.setup.addYamlJarToJavaClassPath';
- S(i).FunctionName = 'yaml.WriteYaml';
-
- i = i + 1;
- S(i).Name = 'TIFFStack';
- S(i).Description = 'Package for creating virtual tiff stack (Used for ScanImage tiff files).';
- S(i).IsRequired = false;
- S(i).Type = 'General';
- S(i).Source = 'Github';
- S(i).WebUrl = 'https://github.com/DylanMuir/TIFFStack';
- S(i).DownloadUrl = 'https://github.com/DylanMuir/TIFFStack/archive/refs/heads/master.zip';
- S(i).HasSetupFile = false;
- S(i).SetupFileName = '';
- S(i).FunctionName = 'TIFFStack';
-
- i = i + 1;
- S(i).Name = 'CaImAn-Matlab';
- S(i).Description = 'A Computational toolbox for large scale Calcium Imaging data Analysis';
- S(i).IsRequired = false;
- S(i).Type = 'Neuroscience';
- S(i).Source = 'Github';
- S(i).WebUrl = 'https://github.com/flatironinstitute/CaImAn-MATLAB';
- S(i).DownloadUrl = 'https://github.com/flatironinstitute/CaImAn-MATLAB/archive/refs/heads/master.zip';
- S(i).HasSetupFile = false;
- S(i).SetupFileName = '';
- S(i).FunctionName = 'CNMFSetParms';
- i = i + 1;
-
- S(i).Name = 'suite2P-Matlab';
- S(i).Description = 'Fast, accurate and complete two-photon pipeline';
- S(i).IsRequired = false;
- S(i).Type = 'Neuroscience';
- S(i).Source = 'Github';
- S(i).WebUrl = 'https://github.com/cortex-lab/Suite2P';
- S(i).DownloadUrl = 'https://github.com/cortex-lab/Suite2P/archive/refs/heads/master.zip';
- S(i).HasSetupFile = false;
- S(i).SetupFileName = '';
- S(i).FunctionName = 'build_ops3';
-
- i = i + 1;
- S(i).Name = 'EXTRACT';
- S(i).Description = 'Tractable and Robust Automated Cell extraction Tool for calcium imaging';
- S(i).IsRequired = false;
- S(i).Type = 'Neuroscience';
- S(i).Source = 'Github';
- S(i).WebUrl = 'https://github.com/schnitzer-lab/EXTRACT-public';
- S(i).DownloadUrl = 'https://github.com/schnitzer-lab/EXTRACT-public/archive/refs/heads/master.zip';
- S(i).HasSetupFile = false;
- S(i).SetupFileName = '';
- S(i).FunctionName = 'run_extract';
- S(i).RequiredToolboxes = { ...
- 'Bioinformatics Toolbox', ...
- 'Econometrics Toolbox', ...
- 'Image Processing Toolbox', ...
- 'Parallel Computing Toolbox', ...
- 'Signal Processing Toolbox', ...
- 'Statistics and Machine Learning Toolbox', ...
- 'Wavelet Toolbox' };
-
- i = i + 1;
- S(i).Name = 'NoRMCorre';
- S(i).Description = ' Non-Rigid Motion Correction for calcium imaging data';
- S(i).IsRequired = false;
- S(i).Type = 'Neuroscience';
- S(i).Source = 'Github';
- S(i).WebUrl = 'https://github.com/flatironinstitute/NoRMCorre';
- %S(i).DownloadUrl = 'https://github.com/flatironinstitute/NoRMCorre/archive/refs/heads/master.zip';
- S(i).DownloadUrl = 'https://github.com/ehennestad/NoRMCorre/archive/refs/heads/master.zip';
- S(i).HasSetupFile = false;
- S(i).SetupFileName = '';
- S(i).FunctionName = 'normcorre_batch';
-
- i = i + 1;
- S(i).Name = 'Flow Registration';
- S(i).Description = ' Optical Flow based correction for calcium imaging data';
- S(i).IsRequired = false;
- S(i).Type = 'Neuroscience';
- S(i).Source = 'Github';
- S(i).WebUrl = 'https://github.com/phflot/flow_registration';
- S(i).DownloadUrl = 'https://github.com/phflot/flow_registration/archive/refs/heads/master.zip';
- S(i).HasSetupFile = true;
- S(i).SetupFileName = 'nansen.wrapper.flowreg.install'; %'set_path';
- S(i).FunctionName = 'OF_Options';
-
- i = i + 1;
- S(i).Name = 'SEUDO';
- S(i).Description = 'Calcium signal decontamination';
- S(i).IsRequired = false;
- S(i).Type = 'Neuroscience';
- S(i).Source = 'Github';
- S(i).WebUrl = 'https://github.com/adamshch/SEUDO';
- S(i).DownloadUrl = 'https://github.com/adamshch/SEUDO/archive/refs/heads/master.zip';
- S(i).HasSetupFile = false;
- S(i).SetupFileName = '';
- S(i).FunctionName = 'globalSEUDOWrapper.m';
-
- i = i + 1;
- S(i).Name = 'Neurodata Without Borders';
- S(i).Description = 'A Matlab interface for reading and writing NWB files';
- S(i).IsRequired = false;
- S(i).Type = 'Neuroscience';
- S(i).Source = 'Github';
- S(i).WebUrl = 'https://github.com/NeurodataWithoutBorders/matnwb';
- %S(i).DownloadUrl = 'https://github.com/NeurodataWithoutBorders/matnwb/archive/refs/heads/master.zip';
- S(i).DownloadUrl = 'https://github.com/ehennestad/matnwb/archive/refs/heads/master.zip';
- S(i).HasSetupFile = true;
- S(i).SetupFileName = '';
- S(i).FunctionName = 'nwbRead.m';
-
- i = i + 1;
- S(i).Name = 'Brain Observatory Toolbox';
- S(i).Description = 'A MATLAB toolbox for interacting with the Allen Brain Observatory';
- S(i).IsRequired = false;
- S(i).Type = 'Neuroscience';
- S(i).Source = 'Github';
- S(i).WebUrl = 'https://github.com/emeyers/Brain-Observatory-Toolbox';
- S(i).DownloadUrl = 'https://github.com/emeyers/Brain-Observatory-Toolbox/archive/refs/heads/main.zip';
- S(i).HasSetupFile = false;
- S(i).SetupFileName = '';
- S(i).FunctionName = 'EphysQuickstart.mlx';
-
-% % i = i + 1 % Not implemented yet
-% % S(i).Name = 'PatchWarp';
-% % S(i).Description = 'Image processing pipeline to correct motion artifacts and complex image distortions in neuronal calcium imaging data.';
-% % S(i).IsRequired = false;
-% % S(i).Type = 'Neuroscience';
-% % S(i).Source = 'Github';
-% % S(i).WebUrl = 'https://github.com/ryhattori/PatchWarp';
-% % S(i).DownloadUrl = 'https://github.com/ryhattori/PatchWarp/archive/refs/heads/main.zip';
-% % S(i).HasSetupFile = false;
-% % S(i).SetupFileName = '';
-% % S(i).FunctionName = 'patchwarp.m';
-
-end
-
-% % i = i + 1 % Not implemented yet
-% % S(i).Name = 'DABEST';
-% % S(i).Description = '';
-% % S(i).IsRequired = false;
-% % S(i).Type = 'Statistics';
-% % S(i).Source = 'Github';
-% % S(i).WebUrl = 'https://github.com/ACCLAB/DABEST-Matlab';
-% % S(i).DownloadUrl = 'https://github.com/ACCLAB/DABEST-Matlab/archive/refs/heads/master.zip';
-% % S(i).HasSetupFile = false;
-% % S(i).SetupFileName = '';
-% % S(i).FunctionName = 'dabest.m';
diff --git a/code/+nansen/+config/+module/@ModuleManager/ModuleManager.m b/code/+nansen/+config/+module/@ModuleManager/ModuleManager.m
index 569c87a4..74e11ade 100644
--- a/code/+nansen/+config/+module/@ModuleManager/ModuleManager.m
+++ b/code/+nansen/+config/+module/@ModuleManager/ModuleManager.m
@@ -108,6 +108,12 @@ function getModuleList(obj)
modules{i}.PackageName = modulePackageName;
modules{i}.isCoreModule = strcmp(modules{i}.ModuleCategory, 'general');
modules{i}.FolderPath = fileparts( moduleSpecFiles{i});
+ requirementManifestPath = fullfile(modules{i}.FolderPath, 'dependencies.nansen.json');
+ if isfile(requirementManifestPath)
+ modules{i}.RequirementManifestPath = requirementManifestPath;
+ else
+ modules{i}.RequirementManifestPath = "";
+ end
end
obj.ModuleList = cat(1, modules{:});
diff --git a/code/+nansen/+internal/+dependencies/Contents.m b/code/+nansen/+internal/+dependencies/Contents.m
new file mode 100644
index 00000000..ed23ec6e
--- /dev/null
+++ b/code/+nansen/+internal/+dependencies/Contents.m
@@ -0,0 +1,13 @@
+% Overview for nansen.internal.dependencies namespace.
+% Dependency manifest utilities for NANSEN.
+%
+% This package is the stateless dependency layer. It reads dependency
+% manifests, resolves dependencies across scopes, and computes dependency
+% status for the current MATLAB session.
+%
+% Functions
+% readManifest - Parse a dependencies.nansen.json file.
+% resolveRequirements - Resolve, deduplicate, and filter dependencies.
+% checkInstallationStatus - Compute IsInstalled and IsOnPath status.
+% getRequiredMathworksProducts - List required MathWorks products.
+% checkRequiredMathworksProducts - Warn or error on missing MathWorks products.
diff --git a/code/+nansen/+internal/+dependencies/README.md b/code/+nansen/+internal/+dependencies/README.md
new file mode 100644
index 00000000..c9df8c87
--- /dev/null
+++ b/code/+nansen/+internal/+dependencies/README.md
@@ -0,0 +1,20 @@
+# `nansen.internal.dependencies`
+
+This namespace contains the stateless dependency layer for NANSEN.
+
+Its responsibilities are:
+
+- read dependency manifests
+- resolve dependencies across core and module scopes
+- deduplicate and filter dependency requirements
+- compute dependency status for the current MATLAB session
+
+It should not own installation side effects, UI, or persisted addon state.
+
+Functions:
+
+- `nansen.internal.dependencies.readManifest`
+- `nansen.internal.dependencies.resolveRequirements`
+- `nansen.internal.dependencies.checkInstallationStatus`
+- `nansen.internal.dependencies.getRequiredMathworksProducts`
+- `nansen.internal.dependencies.checkRequiredMathworksProducts`
diff --git a/code/+nansen/+internal/+dependencies/checkInstallationStatus.m b/code/+nansen/+internal/+dependencies/checkInstallationStatus.m
new file mode 100644
index 00000000..f7aa9fd4
--- /dev/null
+++ b/code/+nansen/+internal/+dependencies/checkInstallationStatus.m
@@ -0,0 +1,154 @@
+function statusResults = checkInstallationStatus(resolvedRequirements, trackedAddons)
+%checkInstallationStatus Check install and path state of resolved requirements.
+%
+% statusResults = nansen.internal.dependencies.checkInstallationStatus(resolvedRequirements)
+% populates the IsInstalled and IsOnPath fields for each dependency.
+%
+% statusResults = nansen.internal.dependencies.checkInstallationStatus( ...
+% resolvedRequirements, trackedAddons)
+% uses tracked addon records from AddonManager for richer status checks.
+%
+% Input:
+% resolvedRequirements - struct array from readManifest or
+% resolveRequirements.
+% trackedAddons - optional AddonManager addon records.
+%
+% Output:
+% statusResults - same struct array with fields populated:
+% - IsInstalled: dependency is installed/tracked
+% - IsOnPath: dependency is available on MATLAB's search path
+
+ arguments
+ resolvedRequirements (1,:) struct
+ trackedAddons (1,:) struct = struct.empty(1, 0)
+ end
+
+ statusResults = resolvedRequirements;
+
+ if isempty(statusResults)
+ return
+ end
+
+ versionInfo = ver();
+ installedProductNames = string({versionInfo.Name});
+ installedAddonTable = getInstalledAddonsTable();
+
+ for i = 1:numel(statusResults)
+ entry = statusResults(i);
+ trackedEntry = getTrackedAddonEntry(entry, trackedAddons);
+
+ if entry.DependencyType == "mathworks-product"
+ isInstalled = any(entry.Name == installedProductNames);
+ isOnPath = isInstalled;
+ else
+ isOnPath = isDependencyOnPath(entry);
+ isInstalled = isOnPath || isTrackedAddonInstalled(trackedEntry, installedAddonTable);
+ end
+
+ statusResults(i).IsInstalled = isInstalled;
+ statusResults(i).IsOnPath = isOnPath;
+ end
+end
+
+function tf = isDependencyOnPath(entry)
+%isDependencyOnPath Check if a dependency is available in the current session.
+ tf = false;
+ if isfield(entry, 'InstallCheck') && strlength(string(entry.InstallCheck)) > 0
+ checkName = char(entry.InstallCheck);
+ try
+ answer = which(checkName);
+ catch ME
+ warning('NANSEN:Dependencies:InstallCheckFailed', ...
+ 'Failed to evaluate InstallCheck "%s": %s', checkName, ME.message)
+ return
+ end
+
+ if isempty(answer) || strcmp(answer, 'Not on MATLAB path')
+ tf = false;
+ elseif isfile(answer)
+ tf = true;
+ else
+ % Our install check should be pointing to a file
+ tf = false;
+ warning('NANSEN:Dependencies:InstallCheckUnexpectedWhichOutput', ...
+ 'InstallCheck "%s" resolved to unexpected output: %s', ...
+ checkName, answer)
+ end
+ end
+end
+
+function trackedEntry = getTrackedAddonEntry(entry, trackedAddons)
+%getTrackedAddonEntry Find a matching tracked addon entry by dependency name.
+ trackedEntry = struct.empty(1, 0);
+
+ if isempty(trackedAddons) || ~isfield(entry, 'Name') || ~isfield(trackedAddons, 'Name')
+ return
+ end
+
+ trackedNames = string({trackedAddons.Name});
+ matchIndex = find(trackedNames == string(entry.Name), 1, 'first');
+ if ~isempty(matchIndex)
+ trackedEntry = trackedAddons(matchIndex);
+ end
+end
+
+function tf = isTrackedAddonInstalled(trackedEntry, installedAddonTable)
+%isTrackedAddonInstalled Determine whether a tracked addon is installed.
+ tf = false;
+ if isempty(trackedEntry)
+ return
+ end
+
+ installationType = string(getStructFieldOrDefault(trackedEntry, 'InstallationType', ""));
+ filePath = string(getStructFieldOrDefault(trackedEntry, 'FilePath', ""));
+ addonName = string(getStructFieldOrDefault(trackedEntry, 'Name', ""));
+ toolboxIdentifier = string(getStructFieldOrDefault(trackedEntry, 'ToolboxIdentifier', ""));
+
+ switch installationType
+ case "folder"
+ tf = strlength(filePath) > 0 && isfolder(filePath);
+ case "mltbx"
+ tf = isInstalledToolboxAddon(addonName, toolboxIdentifier, installedAddonTable);
+ otherwise
+ if strlength(filePath) > 0
+ tf = isfolder(filePath);
+ elseif strlength(toolboxIdentifier) > 0 || strlength(addonName) > 0
+ tf = isInstalledToolboxAddon(addonName, toolboxIdentifier, installedAddonTable);
+ elseif isfield(trackedEntry, 'IsInstalled')
+ tf = logical(trackedEntry.IsInstalled);
+ end
+ end
+end
+
+function tf = isInstalledToolboxAddon(addonName, toolboxIdentifier, installedAddonTable)
+%isInstalledToolboxAddon Check if an addon is installed through MATLAB's addon system.
+ tf = false;
+ if isempty(installedAddonTable)
+ return
+ end
+
+ variableNames = string(installedAddonTable.Properties.VariableNames);
+ if strlength(toolboxIdentifier) > 0 && ismember("Identifier", variableNames)
+ tf = any(string(installedAddonTable.Identifier) == toolboxIdentifier);
+ elseif strlength(addonName) > 0 && ismember("Name", variableNames)
+ tf = any(string(installedAddonTable.Name) == addonName);
+ end
+end
+
+function addonsTable = getInstalledAddonsTable()
+%getInstalledAddonsTable Return installed MATLAB addons or an empty table.
+ try
+ addonsTable = matlab.addons.installedAddons();
+ catch
+ addonsTable = table();
+ end
+end
+
+function value = getStructFieldOrDefault(S, fieldName, defaultValue)
+%getStructFieldOrDefault Read struct field if present, otherwise return default.
+ if isfield(S, fieldName)
+ value = S.(fieldName);
+ else
+ value = defaultValue;
+ end
+end
diff --git a/code/+nansen/+internal/+dependencies/checkRequiredMathworksProducts.m b/code/+nansen/+internal/+dependencies/checkRequiredMathworksProducts.m
new file mode 100644
index 00000000..37d9945c
--- /dev/null
+++ b/code/+nansen/+internal/+dependencies/checkRequiredMathworksProducts.m
@@ -0,0 +1,43 @@
+function checkRequiredMathworksProducts(mode, options)
+%checkRequiredMathworksProducts Check for required MathWorks products.
+%
+% nansen.internal.dependencies.checkRequiredMathworksProducts() checks if the
+% required MathWorks products for core NANSEN are installed.
+%
+% nansen.internal.dependencies.checkRequiredMathworksProducts(mode) uses the
+% specified mode ("warning" or "error") for reporting missing products.
+%
+% nansen.internal.dependencies.checkRequiredMathworksProducts(mode, Name, Value)
+% accepts additional options for module-aware checking.
+%
+% Name-Value Arguments:
+% ModuleNames (string array) - Module scope IDs to include in the
+% check. Default: string.empty (core only).
+
+ arguments
+ mode (1,1) string {mustBeMember(mode, ["warning", "error"])} = "warning"
+ options.ModuleNames (1,:) string = string.empty
+ end
+
+ % Use the resolver to get missing required MathWorks products
+ missingProducts = nansen.internal.dependencies.resolveRequirements( ...
+ "SelectedModules", options.ModuleNames, ...
+ "DependencyTypes", "mathworks-product", ...
+ "RequirementLevels", "required", ...
+ "MissingOnly", true);
+
+ if isempty(missingProducts)
+ return
+ end
+
+ missingNames = " - " + [missingProducts.Name];
+
+ message = sprintf( ...
+ "The following required MathWorks products are needed for NANSEN " + ...
+ "to work reliably:\n%s\n\nYou can install these from MATLAB's " + ...
+ "Add-On Explorer.", ...
+ strjoin(missingNames, newline));
+
+ reportFunction = str2func(mode);
+ reportFunction("NANSEN:MathworksProductCheck:MissingRequiredProducts", message)
+end
diff --git a/code/+nansen/+internal/+dependencies/getRequiredMathworksProducts.m b/code/+nansen/+internal/+dependencies/getRequiredMathworksProducts.m
new file mode 100644
index 00000000..5c9efbfc
--- /dev/null
+++ b/code/+nansen/+internal/+dependencies/getRequiredMathworksProducts.m
@@ -0,0 +1,41 @@
+function requiredToolboxNames = getRequiredMathworksProducts(options)
+%getRequiredMathworksProducts Get names of required MathWorks toolboxes.
+%
+% requiredToolboxNames = nansen.internal.dependencies.getRequiredMathworksProducts()
+% returns the names of required MathWorks toolboxes for core NANSEN.
+%
+% requiredToolboxNames = nansen.internal.dependencies.getRequiredMathworksProducts(Name, Value)
+% accepts additional options for module-aware checking.
+%
+% Name-Value Arguments:
+% ModuleNames (string array) - Module scope IDs to include.
+% Default: string.empty (core only).
+% RequirementLevel (string) - Filter by level: "all", "required",
+% "optional". Default: "required".
+%
+% Output:
+% requiredToolboxNames (string array) - Names of the required
+% MathWorks toolboxes.
+
+ arguments
+ options.ModuleNames (1,:) string = string.empty
+ options.RequirementLevel (1,1) string ...
+ {mustBeMember(options.RequirementLevel, ...
+ ["all", "required", "optional"])} = "required"
+ end
+
+ requirementLevels = string.empty;
+ if options.RequirementLevel ~= "all"
+ requirementLevels = options.RequirementLevel;
+ end
+ resolvedRequirements = nansen.internal.dependencies.resolveRequirements( ...
+ "SelectedModules", options.ModuleNames, ...
+ "DependencyTypes", "mathworks-product", ...
+ "RequirementLevels", requirementLevels);
+
+ if isempty(resolvedRequirements)
+ requiredToolboxNames = string.empty;
+ else
+ requiredToolboxNames = [resolvedRequirements.Name];
+ end
+end
diff --git a/code/+nansen/+internal/+dependencies/readManifest.m b/code/+nansen/+internal/+dependencies/readManifest.m
new file mode 100644
index 00000000..40d90e4f
--- /dev/null
+++ b/code/+nansen/+internal/+dependencies/readManifest.m
@@ -0,0 +1,126 @@
+function dependencies = readManifest(manifestFilePath)
+%readManifest Parse a dependencies.nansen.json manifest file.
+%
+% dependencies = nansen.internal.dependencies.readManifest(manifestFilePath)
+% reads the manifest at the given path and returns a struct array of
+% dependency entries, validated against the schema.
+%
+% Input:
+% manifestFilePath (1,1) string - Absolute path to a
+% dependencies.nansen.json file.
+%
+% Output:
+% dependencies - struct array where each element has all schema
+% fields. Missing optional fields are filled with defaults.
+%
+% Errors:
+% Throws if the file does not exist, is not valid JSON, or if
+% required fields (name, dependencyType, requirementLevel) are
+% missing from any entry.
+
+ arguments
+ manifestFilePath (1,1) string {mustBeFile} = fullfile(nansen.toolboxdir, 'dependencies.nansen.json')
+ end
+
+ jsonText = fileread(manifestFilePath);
+ manifestData = jsondecode(jsonText);
+
+ validateManifestStructure(manifestData);
+
+ manifestScope = string(manifestData.scope);
+ manifestScopeId = string(manifestData.scopeId);
+
+ rawDependencies = manifestData.dependencies;
+ if isstruct(rawDependencies)
+ numDependencies = numel(rawDependencies);
+ else
+ numDependencies = length(rawDependencies);
+ end
+
+ dependencies = repmat(getDefaultEntry(), numDependencies, 1);
+
+ for i = 1:numDependencies
+ if iscell(rawDependencies)
+ entry = rawDependencies{i};
+ else
+ entry = rawDependencies(i);
+ end
+ dependencies(i) = populateEntry(entry, manifestScope, manifestScopeId);
+ end
+end
+
+function validateManifestStructure(manifestData)
+%validateManifestStructure Verify top-level manifest fields exist.
+ manifestFields = string(fieldnames(manifestData));
+ % jsondecode mangles leading underscores, so _schema_version becomes
+ % x_schema_version in the resulting struct.
+ requiredPayloadFields = ["scope", "scopeId", "dependencies"];
+ hasPayloadFields = all(ismember(requiredPayloadFields, manifestFields));
+ hasVersionField = ismember("x_schema_version", manifestFields);
+ if ~hasPayloadFields || ~hasVersionField
+ error("NANSEN:Dependencies:InvalidManifest", ...
+ "Manifest is missing required top-level fields.");
+ end
+end
+
+function entry = populateEntry(rawEntry, manifestScope, manifestScopeId)
+%populateEntry Convert a raw JSON entry to a normalized dependency struct.
+ entry = getDefaultEntry();
+
+ % Required fields
+ requiredFields = ["name", "dependencyType", "requirementLevel"];
+ entryFields = string(fieldnames(rawEntry));
+ missingFields = setdiff(requiredFields, entryFields);
+ if ~isempty(missingFields)
+ entryName = "(unknown)";
+ if ismember("name", entryFields)
+ entryName = rawEntry.name;
+ end
+ error("NANSEN:Dependencies:InvalidEntry", ...
+ "Dependency entry '%s' is missing required fields: %s", ...
+ entryName, strjoin(missingFields, ", "));
+ end
+
+ % Map all camelCase JSON fields to PascalCase struct fields
+ defaultFieldNames = string(fieldnames(entry));
+ for j = 1:numel(entryFields)
+ pascalName = camelToPascal(entryFields(j));
+ if ismember(pascalName, defaultFieldNames) && isfield(rawEntry, entryFields(j))
+ entry.(pascalName) = string(rawEntry.(entryFields(j)));
+ end
+ end
+
+ % Scope: inherit from manifest if not overridden at entry level
+ if ~ismember("scope", entryFields)
+ entry.Scope = manifestScope;
+ end
+ if ~ismember("scopeId", entryFields)
+ entry.ScopeId = manifestScopeId;
+ end
+end
+
+function pascalName = camelToPascal(camelName)
+%camelToPascal Convert camelCase to PascalCase by uppercasing the first letter.
+ camelName = char(camelName);
+ pascalName = string([upper(camelName(1)), camelName(2:end)]);
+end
+
+function entry = getDefaultEntry()
+%getDefaultEntry Return a dependency struct with all fields set to defaults.
+ entry = struct( ...
+ 'Name', "", ...
+ 'DependencyType', "", ...
+ 'Scope', "", ...
+ 'ScopeId', "", ...
+ 'RequirementLevel', "", ...
+ 'Source', "", ...
+ 'DocsSource', "", ...
+ 'Description', "", ...
+ 'Reason', "", ...
+ 'WorkflowNotes', "", ...
+ 'SetupHook', "", ...
+ 'StartupHook', "", ...
+ 'InstallCheck', "", ...
+ 'VersionConstraint', "" ...
+ );
+end
diff --git a/code/+nansen/+internal/+dependencies/resolveRequirements.m b/code/+nansen/+internal/+dependencies/resolveRequirements.m
new file mode 100644
index 00000000..23deb316
--- /dev/null
+++ b/code/+nansen/+internal/+dependencies/resolveRequirements.m
@@ -0,0 +1,230 @@
+function resolvedRequirements = resolveRequirements(options)
+%resolveRequirements Aggregate, deduplicate and check dependencies.
+%
+% resolvedRequirements = nansen.internal.dependencies.resolveRequirements()
+% returns the full resolved requirement set for core NANSEN.
+%
+% resolvedRequirements = nansen.internal.dependencies.resolveRequirements(Name, Value)
+% accepts filtering options.
+%
+% Name-Value Arguments:
+% IncludeCore (logical) - Include core dependencies. Default: true.
+% SelectedModules (string array) - Module package names to include.
+% SelectedWorkflows (string array) - Workflow ScopeIds to include.
+% DependencyTypes (string array) - Filter by DependencyType.
+% Empty means all types.
+% RequirementLevels (string array) - Filter by RequirementLevel.
+% Empty means all levels.
+% MissingOnly (logical) - Return only missing dependencies.
+% Default: false.
+% TrackedAddons (struct array) - Optional tracked addon records from
+% AddonManager. Used for richer install/path status checks.
+% ManifestPaths (string array) - Explicit manifest file paths.
+% When provided, skips automatic manifest discovery and uses
+% only the given paths. Useful for testing.
+%
+% Output:
+% resolvedRequirements - struct array with all schema fields plus:
+% .IsInstalled (logical) - whether the dependency is tracked as
+% installed or otherwise known to be present
+% .IsOnPath (logical) - whether the dependency is currently
+% available on MATLAB's search path
+
+ arguments
+ options.IncludeCore (1,1) logical = true
+ options.SelectedModules (1,:) string = string.empty
+ options.SelectedWorkflows (1,:) string = string.empty
+ options.DependencyTypes (1,:) string = string.empty
+ options.RequirementLevels (1,:) string = string.empty
+ options.MissingOnly (1,1) logical = false
+ options.TrackedAddons (1,:) struct = struct.empty(1, 0)
+ options.ManifestPaths (1,:) string = string.empty
+ end
+
+ if isempty(options.ManifestPaths)
+ manifestPaths = collectManifestPaths(options.IncludeCore, options.SelectedModules);
+ else
+ manifestPaths = options.ManifestPaths;
+ end
+ allDependencies = readAllDependencies(manifestPaths);
+
+ % Filter by scope inclusion
+ allDependencies = filterByScope(allDependencies, ...
+ options.IncludeCore, options.SelectedModules, options.SelectedWorkflows);
+
+ % Deduplicate
+ resolvedRequirements = deduplicateDependencies(allDependencies);
+
+ % Check installation status
+ resolvedRequirements = nansen.internal.dependencies.checkInstallationStatus( ...
+ resolvedRequirements, options.TrackedAddons);
+
+ % Apply filters
+ resolvedRequirements = applyFilters(resolvedRequirements, ...
+ options.DependencyTypes, options.RequirementLevels, options.MissingOnly);
+end
+
+function manifestPaths = collectManifestPaths(includeCore, selectedModules)
+%collectManifestPaths Get manifest file paths from core and selected modules.
+ manifestPaths = string.empty(1, 0);
+
+ if includeCore
+ coreManifestPath = fullfile(nansen.toolboxdir, 'dependencies.nansen.json');
+ if isfile(coreManifestPath)
+ manifestPaths(end+1) = string(coreManifestPath);
+ end
+ end
+
+ % Module manifests
+ if ~isempty(selectedModules)
+
+ for i = 1:numel(selectedModules)
+
+ currentModulePath = utility.path.packagename2pathstr(selectedModules(i));
+
+ moduleInfo = what(currentModulePath);
+ if isempty(moduleInfo)
+ warning('NANSEN:Dependencies:ModuleNotFound', ...
+ 'No module with name "%s" on MATLAB''s search path', selectedModules(i))
+ continue
+ elseif numel(moduleInfo) > 1
+ warning('NANSEN:Dependencies:MultipleModulesFound', ...
+ 'Multiple modules with name "%s" was found on MATLAB''s search path', selectedModules(i))
+ end
+ currentModuleManifestPath = fullfile(moduleInfo(1).path, 'dependencies.nansen.json');
+ if isfile(currentModuleManifestPath)
+ manifestPaths = [manifestPaths, currentModuleManifestPath]; %#ok
+ end
+ end
+ end
+
+ manifestPaths = unique(manifestPaths, "stable");
+end
+
+function allDependencies = readAllDependencies(manifestPaths)
+%readAllDependencies Read and concatenate dependencies from all manifests.
+ allDependencies = struct([]);
+ for i = 1:numel(manifestPaths)
+ if ~isfile(manifestPaths(i)); continue; end
+ dependencies = nansen.internal.dependencies.readManifest(manifestPaths(i));
+ for j = 1:numel(dependencies)
+ if isempty(allDependencies)
+ allDependencies = dependencies(j);
+ else
+ allDependencies(end+1) = dependencies(j); %#ok
+ end
+ end
+ end
+end
+
+function dependencies = filterByScope(dependencies, includeCore, selectedModules, selectedWorkflows)
+%filterByScope Keep only dependencies matching the requested scopes.
+ if isempty(dependencies)
+ return
+ end
+ keepMask = false(1, numel(dependencies));
+ for i = 1:numel(dependencies)
+ switch dependencies(i).Scope
+ case "core"
+ keepMask(i) = includeCore;
+ case "module"
+ keepMask(i) = ~isempty(selectedModules) && ...
+ any(dependencies(i).ScopeId == selectedModules);
+ case "workflow"
+ keepMask(i) = ~isempty(selectedWorkflows) && ...
+ any(dependencies(i).ScopeId == selectedWorkflows);
+ end
+ end
+ dependencies = dependencies(keepMask);
+end
+
+function resolved = deduplicateDependencies(dependencies)
+%deduplicateDependencies Merge duplicate entries across scopes.
+% Entries with the same Name + DependencyType are merged. When merging:
+% - "required" wins over "optional"
+% - Reason and WorkflowNotes are concatenated (unique values only)
+ if isempty(dependencies)
+ resolved = dependencies;
+ return
+ end
+
+ numDependencies = numel(dependencies);
+ keys = strings(1, numDependencies);
+ for i = 1:numDependencies
+ keys(i) = dependencies(i).Name + "|" + dependencies(i).DependencyType;
+ end
+
+ [~, ~, groupIndices] = unique(keys, 'stable');
+ numUnique = max(groupIndices);
+ resolved = repmat(dependencies(1), 1, numUnique);
+
+ for i = 1:numUnique
+ groupEntries = dependencies(groupIndices' == i);
+ merged = groupEntries(1);
+ for j = 2:numel(groupEntries)
+ other = groupEntries(j);
+ if other.RequirementLevel == "required"
+ merged.RequirementLevel = "required";
+ end
+ merged.Reason = mergeStringField(merged.Reason, other.Reason);
+ merged.WorkflowNotes = mergeStringField(merged.WorkflowNotes, other.WorkflowNotes);
+ merged = preferNonEmpty(merged, other, "Source");
+ merged = preferNonEmpty(merged, other, "DocsSource");
+ merged = preferNonEmpty(merged, other, "Description");
+ merged = preferNonEmpty(merged, other, "SetupHook");
+ merged = preferNonEmpty(merged, other, "StartupHook");
+ merged = preferNonEmpty(merged, other, "InstallCheck");
+ end
+ resolved(i) = merged;
+ end
+end
+
+function result = mergeStringField(existing, incoming)
+%mergeStringField Concatenate two string values, keeping only unique parts.
+ if existing == "" && incoming == ""
+ result = "";
+ elseif existing == ""
+ result = incoming;
+ elseif incoming == "" || existing == incoming
+ result = existing;
+ else
+ result = existing + "; " + incoming;
+ end
+end
+
+function merged = preferNonEmpty(merged, other, fieldName)
+%preferNonEmpty Use the other entry's value if the merged entry's is empty.
+ if merged.(fieldName) == "" && other.(fieldName) ~= ""
+ merged.(fieldName) = other.(fieldName);
+ end
+end
+
+function dependencies = applyFilters(dependencies, dependencyTypes, requirementLevels, missingOnly)
+%applyFilters Filter resolved dependencies by type, level, and install state.
+ if isempty(dependencies)
+ return
+ end
+ keepMask = true(1, numel(dependencies));
+ if ~isempty(dependencyTypes)
+ for i = 1:numel(dependencies)
+ if ~any(dependencies(i).DependencyType == dependencyTypes)
+ keepMask(i) = false;
+ end
+ end
+ end
+ if ~isempty(requirementLevels)
+ for i = 1:numel(dependencies)
+ if ~any(dependencies(i).RequirementLevel == requirementLevels)
+ keepMask(i) = false;
+ end
+ end
+ end
+ if missingOnly
+ for i = 1:numel(dependencies)
+ if dependencies(i).IsInstalled
+ keepMask(i) = false;
+ end
+ end
+ end
+ dependencies = dependencies(keepMask);
+end
diff --git a/code/+nansen/+internal/+setup/Contents.m b/code/+nansen/+internal/+setup/Contents.m
new file mode 100644
index 00000000..d29f39f9
--- /dev/null
+++ b/code/+nansen/+internal/+setup/Contents.m
@@ -0,0 +1,12 @@
+% Overview of nansen.internal.setup namespace
+% Environment setup utilities for NANSEN.
+%
+% This package contains setup-time helpers for preparing the MATLAB
+% environment, userpath, Java class path, and other runtime prerequisites.
+%
+% Functions
+% resolveEmptyUserpath - Resolve missing MATLAB userpath.
+% addYamlJarToJavaClassPath - Add YAML jar to Java class path.
+% addUiwidgetsJarToJavaClassPath - Add Widgets Toolbox jar to Java path.
+% isUiwidgetsOnJavapath - Check Widgets Toolbox Java path setup.
+% checkWidgetsToolboxVersion - Check Widgets Toolbox version.
diff --git a/code/+nansen/+internal/+setup/README.md b/code/+nansen/+internal/+setup/README.md
new file mode 100644
index 00000000..90d9127c
--- /dev/null
+++ b/code/+nansen/+internal/+setup/README.md
@@ -0,0 +1,9 @@
+# `nansen.internal.setup`
+
+This namespace environment setup helpers for NANSEN.
+
+Its responsibilities are:
+
+- resolve setup prerequisites such as `userpath`
+- configure Java class path dependencies
+- host setup-time environment utilities
diff --git a/code/+nansen/+internal/+setup/checkRequiredMathworksProducts.m b/code/+nansen/+internal/+setup/checkRequiredMathworksProducts.m
deleted file mode 100644
index f1cbfdc2..00000000
--- a/code/+nansen/+internal/+setup/checkRequiredMathworksProducts.m
+++ /dev/null
@@ -1,31 +0,0 @@
-function checkRequiredMathworksProducts(mode)
-% checkRequiredMathworksProducts - Checks for required Mathworks products.
-%
-% Syntax:
-% nansen.internal.setup.checkRequiredMathworksProducts() Checks if the
-% required Mathworks products for NANSEN are installed.
-
- arguments
- mode (1,1) string {mustBeMember(mode, ["warning", "error"])} = "warning"
- end
-
- versionInfo = ver();
- installedToolboxNames = {versionInfo.Name};
-
- requiredToolboxNames = nansen.internal.setup.getRequiredMatlabToolboxes();
-
- missingToolboxNames = setdiff(requiredToolboxNames, installedToolboxNames);
- missingToolboxNames = string(missingToolboxNames);
-
- if ~isempty(missingToolboxNames)
- missingToolboxNames = " - " + missingToolboxNames;
-
- message = sprintf(...
- "The following required Mathworks products are needed for NANSEN " + ...
- "to work reliably:\n%s\n\nYou can install these from MATLAB's Add-On Manager.", ...
- strjoin(missingToolboxNames, newline));
-
- fcn = str2func(mode);
- fcn("NANSEN:MathworksProductCheck:MissingRequiredProducts", message)
- end
-end
diff --git a/code/+nansen/+internal/+setup/getRequiredMatlabToolboxes.m b/code/+nansen/+internal/+setup/getRequiredMatlabToolboxes.m
deleted file mode 100644
index b20e6296..00000000
--- a/code/+nansen/+internal/+setup/getRequiredMatlabToolboxes.m
+++ /dev/null
@@ -1,6 +0,0 @@
-function requiredToolboxes = getRequiredMatlabToolboxes()
-%getRequiredMatlabToolboxes - Get names of required Mathworks Toolboxes
- filePath = fullfile(nansen.toolboxdir, 'resources', 'requirements.txt');
- requiredToolboxes = strsplit( fileread(filePath), newline);
- requiredToolboxes = requiredToolboxes(cellfun(@(c) ~isempty(c), requiredToolboxes));
-end
diff --git a/code/+nansen/+internal/+setup/installAddons.m b/code/+nansen/+internal/+setup/installAddons.m
deleted file mode 100644
index 2d002fc1..00000000
--- a/code/+nansen/+internal/+setup/installAddons.m
+++ /dev/null
@@ -1,17 +0,0 @@
-function installAddons()
-% installAddons - Install addons using nansen's addon manager
-
- addonManager = nansen.AddonManager();
-
- for i = 1:numel(addonManager.AddonList)
- S = addonManager.AddonList(i);
- if ~S.IsInstalled
- fprintf('Downloading %s...', S.Name)
- addonManager.downloadAddon(S.Name)
- addonManager.addAddonToMatlabPath(S.Name)
- fprintf('Finished.\n')
- end
- end
-
- addonManager.saveAddonList()
-end
diff --git a/code/+nansen/+internal/+setup/listDependencies.m b/code/+nansen/+internal/+setup/listDependencies.m
deleted file mode 100644
index 73b623ee..00000000
--- a/code/+nansen/+internal/+setup/listDependencies.m
+++ /dev/null
@@ -1,15 +0,0 @@
-function dependencies = listDependencies(flag)
-%listDependencies - List the dependencies for NANSEN
-
- % if nargin < 1; flag = 'required'; end
- % flag = validatestring(flag, {'required', 'all'}, 1);
-
- % Todo:
- % Deprecate?
- % Generate this table from the requirements.txt file
-
- rootPath = fullfile(nansen.toolboxdir, 'resources', 'dependencies');
- jsonStr = fileread( fullfile(rootPath, 'fex_submissions.json') );
-
- dependencies = struct2table( jsondecode(jsonStr) );
-end
diff --git a/code/+nansen/+internal/+user/@NansenUserSession/NansenUserSession.m b/code/+nansen/+internal/+user/@NansenUserSession/NansenUserSession.m
index 8492cb5e..69b44ef5 100644
--- a/code/+nansen/+internal/+user/@NansenUserSession/NansenUserSession.m
+++ b/code/+nansen/+internal/+user/@NansenUserSession/NansenUserSession.m
@@ -89,8 +89,11 @@ function reset()
end
methods
- function am = getAddonManager(obj)
- am = obj.AddonManager;
+ function am = getAddonManager(~)
+ %getAddonManager Get the AddonManager singleton.
+ warning("NANSEN:UserSession:DeprecatedMethod", ...
+ "Deprecated - use nansen.AddonManager() directly")
+ am = nansen.AddonManager();
end
function pm = getProjectManager(obj)
@@ -115,7 +118,6 @@ function assertProjectsAvailable(obj)
function obj = NansenUserSession(userName, skipProjectCheck)
% NansenUserSession - Constructor method
- import nansen.config.addons.AddonManager
import nansen.config.project.ProjectManager
obj.CurrentUserName = userName;
obj.SkipProjectCheck = skipProjectCheck;
@@ -125,7 +127,7 @@ function assertProjectsAvailable(obj)
obj.preStartup()
- obj.AddonManager = AddonManager(preferenceDirectory);
+ obj.AddonManager = nansen.AddonManager();
obj.ProjectManager = ProjectManager.instance(preferenceDirectory, 'reset');
obj.postStartup()
diff --git a/code/+nansen/+internal/+utility/createCommandWindowWebLink.m b/code/+nansen/+internal/+utility/createCommandWindowWebLink.m
new file mode 100644
index 00000000..21ac1ce5
--- /dev/null
+++ b/code/+nansen/+internal/+utility/createCommandWindowWebLink.m
@@ -0,0 +1,9 @@
+function linkStr = createCommandWindowWebLink(url, title)
+
+ arguments
+ url (1,:) char
+ title (1,:) char
+ end
+
+ linkStr = sprintf('%s', url, title);
+end
diff --git a/code/dependencies.nansen.json b/code/dependencies.nansen.json
new file mode 100644
index 00000000..97955b94
--- /dev/null
+++ b/code/dependencies.nansen.json
@@ -0,0 +1,158 @@
+{
+ "_schema_id": "https://raw.githubusercontent.com/VervaekeLab/NANSEN/dev/schemas/dependencies.nansen.json",
+ "_schema_version": "1.0",
+ "_type": "NANSEN Dependency Manifest",
+ "scope": "core",
+ "scopeId": "core",
+ "dependencies": [
+ {
+ "name": "Image Processing Toolbox",
+ "dependencyType": "mathworks-product",
+ "requirementLevel": "required",
+ "reason": "Needed for basic NANSEN image handling"
+ },
+ {
+ "name": "Parallel Computing Toolbox",
+ "dependencyType": "mathworks-product",
+ "requirementLevel": "optional",
+ "reason": "Enables parallel batch processing of sessions"
+ },
+ {
+ "name": "Widgets Toolbox - Compatibility Support",
+ "dependencyType": "community-toolbox",
+ "requirementLevel": "required",
+ "source": "fex://66235-widgets-toolbox-compatibility-support/1.3.330",
+ "description": "UI widget components used throughout NANSEN",
+ "installCheck": "uiw.widget.Table"
+ },
+ {
+ "name": "Recursively list files and folders",
+ "dependencyType": "community-toolbox",
+ "requirementLevel": "required",
+ "source": "fex://160058-recursively-list-files-and-folders",
+ "description": "Recursive directory listing utility",
+ "installCheck": "recursiveDir"
+ },
+ {
+ "name": "Scalebar for images and plots",
+ "dependencyType": "community-toolbox",
+ "requirementLevel": "required",
+ "source": "fex://109114-scalebar-for-images-and-plots",
+ "description": "Adds scalebars to image and plot axes",
+ "installCheck": "scalebar"
+ },
+ {
+ "name": "Limit figure size",
+ "dependencyType": "community-toolbox",
+ "requirementLevel": "required",
+ "source": "fex://38527-limit-figure-size",
+ "description": "Constrains figure window dimensions",
+ "installCheck": "limitFigSize"
+ },
+ {
+ "name": "cmocean - Perceptually-uniform colormaps",
+ "dependencyType": "community-toolbox",
+ "requirementLevel": "required",
+ "source": "fex://57773-cmocean-perceptually-uniform-colormaps",
+ "description": "Perceptually-uniform colormaps for scientific visualization",
+ "installCheck": "cmocean"
+ },
+ {
+ "name": "Spectral and XYZ Color Functions",
+ "dependencyType": "community-toolbox",
+ "requirementLevel": "required",
+ "source": "fex://7021-spectral-and-xyz-color-functions",
+ "description": "Spectral color conversion functions",
+ "installCheck": "spectrumRGB"
+ },
+ {
+ "name": "Drag & Drop functionality for Java GUI components",
+ "dependencyType": "community-toolbox",
+ "requirementLevel": "required",
+ "source": "fex://53511-drag-drop-functionality-for-java-gui-components",
+ "description": "Drag and drop support for MATLAB Java-based UIs",
+ "installCheck": "dndcontrol"
+ },
+ {
+ "name": "Get structure field names in recursive manner",
+ "dependencyType": "community-toolbox",
+ "requirementLevel": "required",
+ "source": "fex://33262-get-structure-field-names-in-recursive-manner",
+ "description": "Recursively retrieves field names from nested structs",
+ "installCheck": "fieldnamesr"
+ },
+ {
+ "name": "plotboxpos",
+ "dependencyType": "community-toolbox",
+ "requirementLevel": "required",
+ "source": "fex://9615-plotboxpos",
+ "description": "Returns the visible position of a plot box",
+ "installCheck": "plotboxpos"
+ },
+ {
+ "name": "findjobj - Find java handles of Matlab graphic objects",
+ "dependencyType": "community-toolbox",
+ "requirementLevel": "required",
+ "source": "fex://14317-findjobj-find-java-handles-of-matlab-graphic-objects",
+ "description": "Finds underlying Java objects for MATLAB graphics",
+ "installCheck": "findjobj"
+ },
+ {
+ "name": "getjframe - Retrieves a figure's underlying Java frame",
+ "dependencyType": "community-toolbox",
+ "requirementLevel": "required",
+ "source": "fex://15830-getjframe-retrieves-a-figure-s-underlying-java-frame",
+ "description": "Retrieves the Java frame for a MATLAB figure",
+ "installCheck": "getjframe"
+ },
+ {
+ "name": "undecorateFig",
+ "dependencyType": "community-toolbox",
+ "requirementLevel": "required",
+ "source": "fex://50111-undecoratefig-remove-restore-figure-border-and-title-bar",
+ "description": "Removes or restores figure border and title bar",
+ "installCheck": "undecorateFig"
+ },
+ {
+ "name": "Catalog",
+ "dependencyType": "community-toolbox",
+ "requirementLevel": "required",
+ "source": "fex://158241-catalog",
+ "description": "Collection for unique, named and ordered items",
+ "installCheck": "PersistentCatalog"
+ },
+ {
+ "name": "File downloader/uploader with progress monitor",
+ "dependencyType": "community-toolbox",
+ "requirementLevel": "required",
+ "source": "fex://118460-file-downloader-uploader-with-progress-monitor",
+ "description": "Downloads and uploads files with progress indication",
+ "installCheck": "downloadFile"
+ },
+ {
+ "name": "MatBox",
+ "dependencyType": "community-toolbox",
+ "requirementLevel": "required",
+ "source": "fex://180185-matbox",
+ "description": "MATLAB package manager for requirements installation",
+ "installCheck": "matbox.VersionNumber"
+ },
+ {
+ "name": "Hierarchical Data Tree",
+ "dependencyType": "community-toolbox",
+ "requirementLevel": "required",
+ "source": "https://github.com/ehennestad/hierarchical-data-tree-matlab",
+ "description": "Tree data structure for hierarchical data",
+ "installCheck": "datatree.model.TreeNodeProvider"
+ },
+ {
+ "name": "YAML-Matlab",
+ "dependencyType": "community-toolbox",
+ "requirementLevel": "required",
+ "source": "https://github.com/ehennestad/yamlmatlab",
+ "description": "Reading and writing YAML files in MATLAB",
+ "setupHook": "nansen.internal.setup.addYamlJarToJavaClassPath",
+ "installCheck": "yaml.WriteYaml"
+ }
+ ]
+}
diff --git a/code/modules/+nansen/+module/+ophys/+twophoton/dependencies.nansen.json b/code/modules/+nansen/+module/+ophys/+twophoton/dependencies.nansen.json
new file mode 100644
index 00000000..03bc79dd
--- /dev/null
+++ b/code/modules/+nansen/+module/+ophys/+twophoton/dependencies.nansen.json
@@ -0,0 +1,155 @@
+{
+ "_schema_id": "https://raw.githubusercontent.com/VervaekeLab/NANSEN/dev/schemas/dependencies.nansen.json",
+ "_schema_version": "1.0",
+ "_type": "NANSEN Dependency Manifest",
+ "scope": "module",
+ "scopeId": "nansen.module.ophys.twophoton",
+ "dependencies": [
+ {
+ "name": "Image Processing Toolbox",
+ "dependencyType": "mathworks-product",
+ "requirementLevel": "required",
+ "reason": "Needed for two-photon image processing and ROI analysis"
+ },
+ {
+ "name": "Signal Processing Toolbox",
+ "dependencyType": "mathworks-product",
+ "requirementLevel": "optional",
+ "reason": "Used by some motion correction and signal extraction workflows"
+ },
+ {
+ "name": "Statistics and Machine Learning Toolbox",
+ "dependencyType": "mathworks-product",
+ "requirementLevel": "optional",
+ "reason": "Used by EXTRACT for signal decontamination"
+ },
+ {
+ "name": "Parallel Computing Toolbox",
+ "dependencyType": "mathworks-product",
+ "requirementLevel": "optional",
+ "reason": "Speeds up motion correction and ROI extraction"
+ },
+ {
+ "name": "TIFFStack",
+ "dependencyType": "community-toolbox",
+ "requirementLevel": "required",
+ "source": "https://github.com/DylanMuir/TIFFStack",
+ "description": "Virtual TIFF stack for efficient reading of ScanImage TIFF files",
+ "installCheck": "TIFFStack"
+ },
+ {
+ "name": "CaImAn-MATLAB",
+ "dependencyType": "community-toolbox",
+ "scope": "workflow",
+ "scopeId": "nansen.module.ophys.twophoton.workflow.caiman",
+ "requirementLevel": "optional",
+ "source": "https://github.com/flatironinstitute/CaImAn-MATLAB",
+ "description": "Computational toolbox for large scale calcium imaging data analysis",
+ "workflowNotes": "Needed for the CaImAn ROI extraction workflow",
+ "installCheck": "CNMFSetParms"
+ },
+ {
+ "name": "Suite2P-MATLAB",
+ "dependencyType": "community-toolbox",
+ "scope": "workflow",
+ "scopeId": "nansen.module.ophys.twophoton.workflow.suite2p",
+ "requirementLevel": "optional",
+ "source": "https://github.com/cortex-lab/Suite2P",
+ "description": "Fast, accurate and complete two-photon pipeline",
+ "workflowNotes": "Needed for the Suite2P ROI extraction workflow",
+ "installCheck": "build_ops3"
+ },
+ {
+ "name": "EXTRACT",
+ "dependencyType": "community-toolbox",
+ "scope": "workflow",
+ "scopeId": "nansen.module.ophys.twophoton.workflow.extract",
+ "requirementLevel": "optional",
+ "source": "https://github.com/schnitzer-lab/EXTRACT-public",
+ "description": "Tractable and robust automated cell extraction tool for calcium imaging",
+ "workflowNotes": "Needed for the EXTRACT ROI extraction workflow",
+ "installCheck": "run_extract"
+ },
+ {
+ "name": "Bioinformatics Toolbox",
+ "dependencyType": "mathworks-product",
+ "scope": "workflow",
+ "scopeId": "nansen.module.ophys.twophoton.workflow.extract",
+ "requirementLevel": "required",
+ "reason": "Required by the EXTRACT algorithm",
+ "workflowNotes": "Required when using the EXTRACT workflow"
+ },
+ {
+ "name": "Econometrics Toolbox",
+ "dependencyType": "mathworks-product",
+ "scope": "workflow",
+ "scopeId": "nansen.module.ophys.twophoton.workflow.extract",
+ "requirementLevel": "required",
+ "reason": "Required by the EXTRACT algorithm",
+ "workflowNotes": "Required when using the EXTRACT workflow"
+ },
+ {
+ "name": "Wavelet Toolbox",
+ "dependencyType": "mathworks-product",
+ "scope": "workflow",
+ "scopeId": "nansen.module.ophys.twophoton.workflow.extract",
+ "requirementLevel": "required",
+ "reason": "Required by the EXTRACT algorithm",
+ "workflowNotes": "Required when using the EXTRACT workflow"
+ },
+ {
+ "name": "NoRMCorre",
+ "dependencyType": "community-toolbox",
+ "scope": "workflow",
+ "scopeId": "nansen.module.ophys.twophoton.workflow.normcorre",
+ "requirementLevel": "optional",
+ "source": "https://github.com/ehennestad/NoRMCorre",
+ "docsSource": "https://github.com/flatironinstitute/NoRMCorre",
+ "description": "Non-rigid motion correction for calcium imaging data",
+ "installCheck": "normcorre_batch"
+ },
+ {
+ "name": "Flow Registration",
+ "dependencyType": "community-toolbox",
+ "scope": "workflow",
+ "scopeId": "nansen.module.ophys.twophoton.workflow.flowreg",
+ "requirementLevel": "optional",
+ "source": "https://github.com/phflot/flow_registration",
+ "docsSource": "https://github.com/phflot/flow_registration",
+ "description": "Optical flow based motion correction for calcium imaging data",
+ "setupHook": "nansen.wrapper.flowreg.install",
+ "installCheck": "OF_Options"
+ },
+ {
+ "name": "SEUDO",
+ "dependencyType": "community-toolbox",
+ "scope": "workflow",
+ "scopeId": "nansen.module.ophys.twophoton.workflow.seudo",
+ "requirementLevel": "optional",
+ "source": "https://github.com/adamshch/SEUDO",
+ "docsSource": "https://github.com/adamshch/SEUDO",
+ "description": "Calcium signal decontamination",
+ "workflowNotes": "Needed only for the SEUDO signal decontamination workflow",
+ "installCheck": "globalSEUDOWrapper"
+ },
+ {
+ "name": "Neurodata Without Borders",
+ "dependencyType": "community-toolbox",
+ "requirementLevel": "optional",
+ "source": "https://github.com/ehennestad/matnwb",
+ "docsSource": "https://matnwb.readthedocs.io/en/latest/",
+ "description": "MATLAB interface for reading and writing NWB files",
+ "reason": "Required for NWB file export",
+ "installCheck": "nwbRead"
+ },
+ {
+ "name": "Brain Observatory Toolbox",
+ "dependencyType": "community-toolbox",
+ "requirementLevel": "optional",
+ "source": "https://github.com/emeyers/Brain-Observatory-Toolbox",
+ "description": "MATLAB toolbox for interacting with the Allen Brain Observatory",
+ "reason": "Required for Allen Brain Observatory data access",
+ "installCheck": "bot.getSessions"
+ }
+ ]
+}
diff --git a/code/resources/requirements.txt b/code/resources/requirements.txt
deleted file mode 100644
index 0407f8e6..00000000
--- a/code/resources/requirements.txt
+++ /dev/null
@@ -1,4 +0,0 @@
-Statistics and Machine Learning Toolbox
-Image Processing Toolbox
-Parallel Computing Toolbox
-Signal Processing Toolbox
\ No newline at end of file
diff --git a/code/shortcuts/+nansen/AddonManager.m b/code/shortcuts/+nansen/AddonManager.m
index 265c1f8f..dc9d6ce1 100644
--- a/code/shortcuts/+nansen/AddonManager.m
+++ b/code/shortcuts/+nansen/AddonManager.m
@@ -5,8 +5,7 @@
%
% See also nansen.config.addons.AddonManager
- userSession = nansen.internal.user.NansenUserSession.instance();
- addonManager = userSession.getAddonManager();
+ addonManager = nansen.config.addons.AddonManager.instance();
if ~nargout
nansen.config.addons.AddonManagerApp(addonManager)
diff --git a/code/wrappers/+nansen/+wrapper/+flowreg/install.m b/code/wrappers/+nansen/+wrapper/+flowreg/install.m
index 27c0601a..8d82b6c2 100644
--- a/code/wrappers/+nansen/+wrapper/+flowreg/install.m
+++ b/code/wrappers/+nansen/+wrapper/+flowreg/install.m
@@ -3,7 +3,7 @@
flowregdir = fileparts( which('set_path') );
[~, dirname] = fileparts(flowregdir);
-assert( strcmp(dirname, 'flow_registration'), ...
+assert( contains(dirname, 'flow_registration'), ...
'Could not locate the directory containing the flow_registration toolbox')
addpath(fullfile(flowregdir, 'core'));
diff --git a/nansen_install.m b/nansen_install.m
index 41a694eb..30b3704b 100644
--- a/nansen_install.m
+++ b/nansen_install.m
@@ -10,6 +10,8 @@ function nansen_install(options)
arguments
options.SavePath (1,1) logical = true
+ options.Modules (1,:) string = string.empty
+ options.Update (1,1) logical = false
end
nansenProjectFolder = fileparts(mfilename('fullpath')); % Path to nansen codebase
@@ -20,38 +22,46 @@ function nansen_install(options)
error('NANSEN:Setup:CodeFolderNotFound', ...
'Could not find folder with code for Nansen')
end
-
+
% Check that userpath is not empty (can happen on linux platforms)
if isempty(userpath)
nansen.internal.setup.resolveEmptyUserpath()
end
-
- warnState = warning('off', 'MATLAB:javaclasspath:jarAlreadySpecified');
- warningCleanup = onCleanup(@(state) warning(warnState));
+
+ % Supress a warning which is not relevant for users
+ warningIdentifier = 'MATLAB:javaclasspath:jarAlreadySpecified';
+ warningCleanup = nansen.common.suppressWarning(warningIdentifier); %#ok
+
+ % Get the AddonManager singleton
+ addonManager = nansen.AddonManager();
- % Use MatBox to install dependencies/requirements
- downloadAndInstallMatBox();
+ % We need MatBox first to install other dependencies
+ addonManager.downloadAndInstallMatBox()
+
+ % Install core requirements
+ addonManager.installMissingAddons();
- requirementsInstallationFolder = fullfile(userpath, 'NANSEN', 'Requirements');
- matbox.installRequirements(nansenProjectFolder, 'u', ...
- 'InstallationLocation', requirementsInstallationFolder)
+ if options.Update
+ addonManager.updateAddons(options.Modules);
+ else
+ numAddonsInstalled = addonManager.installMissingAddons(options.Modules);
+ if numAddonsInstalled == 0
+ disp([ ...
+ 'All dependencies are installed. ', ...
+ 'Use nansen_install(Update=true) to update dependencies.'])
+ end
+ end
- % Add NANSEN toolbox folder to path if it was not added already
+ % Add NANSEN toolbox folder to path if it was not added already
if ~contains(path(), nansenToolboxFolder)
addpath(genpath(nansenToolboxFolder))
- savepath()
end
if options.SavePath
- savepath()
- end
-end
-
-function downloadAndInstallMatBox()
- if ~exist('+matbox/installRequirements', 'file')
- sourceFile = 'https://raw.githubusercontent.com/ehennestad/matbox-actions/refs/heads/main/install-matbox/installMatBox.m';
- filePath = websave('installMatBox.m', sourceFile);
- installMatBox('commit')
- rehash()
- delete(filePath);
+ status = savepath();
+ if status ~= 0
+ warning('NANSEN:Setup:SavePathFailed', ...
+ ['Could not save the MATLAB path. NANSEN is available for this session, ', ...
+ 'but you may need to save the path manually.'])
+ end
end
end
diff --git a/schemas/dependencies.nansen.json b/schemas/dependencies.nansen.json
new file mode 100644
index 00000000..b100c8fa
--- /dev/null
+++ b/schemas/dependencies.nansen.json
@@ -0,0 +1,115 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://raw.githubusercontent.com/VervaekeLab/NANSEN/dev/schemas/dependencies.nansen.json",
+ "title": "NANSEN Dependency Manifest",
+ "description": "Schema for dependencies.nansen.json files that declare dependencies for NANSEN core and modules.",
+ "type": "object",
+ "required": ["_schema_version", "_type", "scope", "scopeId", "dependencies"],
+ "properties": {
+ "_schema_id": {
+ "type": "string",
+ "description": "Canonical URI of the schema that validates this manifest. Used for runtime type-checking by MATLAB and Python."
+ },
+ "_schema_version": {
+ "type": "string",
+ "description": "Version of this schema format.",
+ "const": "1.0"
+ },
+ "_type": {
+ "type": "string",
+ "description": "Type of this manifest.",
+ "const": "NANSEN Dependency Manifest"
+ },
+ "scope": {
+ "type": "string",
+ "enum": ["core", "module"],
+ "description": "Whether this manifest belongs to core NANSEN or a module."
+ },
+ "scopeId": {
+ "type": "string",
+ "description": "Unique identifier for the scope. 'core' for core NANSEN, or a dot-separated module package name (e.g. 'nansen.module.ophys.twophoton')."
+ },
+ "dependencies": {
+ "type": "array",
+ "description": "List of dependency entries.",
+ "items": {
+ "$ref": "#/$defs/dependency"
+ }
+ }
+ },
+ "additionalProperties": false,
+ "$defs": {
+ "dependency": {
+ "type": "object",
+ "required": ["name", "dependencyType", "requirementLevel"],
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "Display name of the dependency."
+ },
+ "dependencyType": {
+ "type": "string",
+ "enum": ["mathworks-product", "community-toolbox"],
+ "description": "Whether this is a MathWorks product or a community toolbox."
+ },
+ "requirementLevel": {
+ "type": "string",
+ "enum": ["required", "optional"],
+ "description": "'required' means NANSEN or the module will not work without it. 'optional' means only specific workflows need it."
+ },
+ "scope": {
+ "type": "string",
+ "enum": ["core", "module", "workflow"],
+ "description": "Overrides the manifest-level scope for this entry. Use 'workflow' for dependencies that belong to a specific workflow within a module. If omitted, inherits from the manifest."
+ },
+ "scopeId": {
+ "type": "string",
+ "description": "Overrides the manifest-level scopeId for this entry. Use a workflow-specific identifier like 'nansen.module.ophys.twophoton.workflow.suite2p'. If omitted, inherits from the manifest."
+ },
+ "source": {
+ "type": "string",
+ "description": "Install URI for community toolboxes. Supports 'fex://' for FileExchange and 'https://github.com/...' for GitHub. Used to generate requirements.txt."
+ },
+ "docsSource": {
+ "type": "string",
+ "description": "URL pointing to installation or usage documentation."
+ },
+ "description": {
+ "type": "string",
+ "description": "Human-readable summary of what the dependency provides."
+ },
+ "reason": {
+ "type": "string",
+ "description": "Why this dependency is needed. Shown to users for optional dependencies."
+ },
+ "workflowNotes": {
+ "type": "string",
+ "description": "Extra context shown when scope is 'workflow'. Explains which workflow requires this dependency."
+ },
+ "setupHook": {
+ "type": "string",
+ "description": "Callable to invoke once after installation (e.g. a MATLAB function name or Python entry point). Run during nansen_install or equivalent."
+ },
+ "startupHook": {
+ "type": "string",
+ "description": "Callable to invoke on NANSEN startup after the dependency is installed and registered."
+ },
+ "installCheck": {
+ "type": "string",
+ "description": "Symbol name used to verify the dependency is installed (e.g. a function, class, or module name). Checked via language-appropriate introspection."
+ },
+ "versionConstraint": {
+ "type": "string",
+ "description": "Version requirement string. Reserved for future use."
+ }
+ },
+ "if": {
+ "properties": { "dependencyType": { "const": "community-toolbox" } }
+ },
+ "then": {
+ "required": ["source"]
+ },
+ "additionalProperties": false
+ }
+ }
+}
diff --git a/tests/+nansen/+fixture/AddonManagerFixture.m b/tests/+nansen/+fixture/AddonManagerFixture.m
new file mode 100644
index 00000000..8cd09a80
--- /dev/null
+++ b/tests/+nansen/+fixture/AddonManagerFixture.m
@@ -0,0 +1,89 @@
+classdef AddonManagerFixture < matlab.unittest.fixtures.Fixture
+%AddonManagerFixture Fixture for creating an isolated AddonManager for testing.
+%
+% Creates a singleton AddonManager backed by a temporary installation
+% folder and manifest file. The constructor only loads persisted state
+% (no manifest discovery), then the fixture calls refreshManagedAddons
+% with explicit test manifest paths.
+%
+% On teardown, resets the singleton so subsequent tests get a fresh
+% instance.
+%
+% By default, the fixture resolves dependencies from the test manifests
+% shipped in tests/+nansen/+fixture/manifests/. Pass custom
+% DependencyManifestPaths to override.
+%
+% Example:
+% fixture = testCase.applyFixture(nansen.fixture.AddonManagerFixture);
+% manager = fixture.AddonManager;
+
+ properties (SetAccess = private)
+ % InstallationFolder - Temporary folder for addon installation
+ InstallationFolder (1,1) string
+
+ % ManifestFilePath - Temporary path for the installed_addons.json
+ ManifestFilePath (1,1) string
+
+ % AddonManager - The singleton instance created by this fixture
+ AddonManager
+ end
+
+ properties (Access = private)
+ DependencyManifestPaths (1,:) string
+ end
+
+ methods
+ function fixture = AddonManagerFixture(options)
+ %AddonManagerFixture Create a fixture with optional custom manifests.
+ arguments
+ options.DependencyManifestPaths (1,:) string = string.empty
+ end
+ fixture.DependencyManifestPaths = options.DependencyManifestPaths;
+ end
+ end
+
+ methods
+ function setup(fixture)
+ import matlab.unittest.fixtures.TemporaryFolderFixture
+ temporaryFolderFixture = fixture.applyFixture(TemporaryFolderFixture);
+ fixture.InstallationFolder = fullfile(temporaryFolderFixture.Folder, 'Add-Ons');
+ mkdir(fixture.InstallationFolder);
+ fixture.ManifestFilePath = fullfile( ...
+ temporaryFolderFixture.Folder, 'installed_addons.json');
+
+ dependencyManifestPaths = fixture.DependencyManifestPaths;
+ if isempty(dependencyManifestPaths)
+ dependencyManifestPaths = fixture.getDefaultTestManifestPaths();
+ end
+
+ % Create singleton with clean state (no manifest discovery)
+ fixture.AddonManager = nansen.config.addons.AddonManager.instance( ...
+ "reset", fixture.InstallationFolder, fixture.ManifestFilePath);
+
+ % Populate from test manifests only
+ fixture.AddonManager.refreshManagedAddons( ...
+ "ManifestPaths", dependencyManifestPaths);
+
+ fixture.addTeardown(@() nansen.config.addons.AddonManager.instance("reset"));
+ end
+ end
+
+ methods (Access = protected)
+ function isCompatibleResult = isCompatible(fixture, other)
+ %isCompatible Two fixtures are compatible if they use the same manifest paths.
+ isCompatibleResult = isequal( ...
+ fixture.DependencyManifestPaths, other.DependencyManifestPaths);
+ end
+ end
+
+ methods (Static, Access = private)
+ function manifestPaths = getDefaultTestManifestPaths()
+ %getDefaultTestManifestPaths Return paths to the bundled test manifests.
+ manifestDirectory = fullfile( ...
+ fileparts(mfilename('fullpath')), 'manifests');
+ manifestPaths = string({ ...
+ fullfile(manifestDirectory, 'test_core_dependencies.nansen.json'), ...
+ fullfile(manifestDirectory, 'test_module_dependencies.nansen.json') });
+ end
+ end
+end
diff --git a/tests/+nansen/+fixture/manifests/test_core_dependencies.nansen.json b/tests/+nansen/+fixture/manifests/test_core_dependencies.nansen.json
new file mode 100644
index 00000000..86efd05f
--- /dev/null
+++ b/tests/+nansen/+fixture/manifests/test_core_dependencies.nansen.json
@@ -0,0 +1,38 @@
+{
+ "_schema_id": "https://raw.githubusercontent.com/VervaekeLab/NANSEN/dev/schemas/dependencies.nansen.json",
+ "_schema_version": "1.0",
+ "_type": "NANSEN Dependency Manifest",
+ "scope": "core",
+ "scopeId": "core",
+ "dependencies": [
+ {
+ "name": "TestToolboxA",
+ "dependencyType": "community-toolbox",
+ "requirementLevel": "required",
+ "source": "https://github.com/test-org/TestToolboxA",
+ "description": "Required core community toolbox for testing",
+ "installCheck": "TestToolboxACheck"
+ },
+ {
+ "name": "TestToolboxB",
+ "dependencyType": "community-toolbox",
+ "requirementLevel": "optional",
+ "source": "fex://99999-test-toolbox-b",
+ "description": "Optional core community toolbox for testing",
+ "installCheck": "TestToolboxBCheck",
+ "reason": "Enables optional test feature"
+ },
+ {
+ "name": "Image Processing Toolbox",
+ "dependencyType": "mathworks-product",
+ "requirementLevel": "required",
+ "reason": "Required MathWorks product for testing"
+ },
+ {
+ "name": "Parallel Computing Toolbox",
+ "dependencyType": "mathworks-product",
+ "requirementLevel": "optional",
+ "reason": "Optional MathWorks product for testing"
+ }
+ ]
+}
diff --git a/tests/+nansen/+fixture/manifests/test_module_dependencies.nansen.json b/tests/+nansen/+fixture/manifests/test_module_dependencies.nansen.json
new file mode 100644
index 00000000..c7b67bbb
--- /dev/null
+++ b/tests/+nansen/+fixture/manifests/test_module_dependencies.nansen.json
@@ -0,0 +1,42 @@
+{
+ "_schema_id": "https://raw.githubusercontent.com/VervaekeLab/NANSEN/dev/schemas/dependencies.nansen.json",
+ "_schema_version": "1.0",
+ "_type": "NANSEN Dependency Manifest",
+ "scope": "module",
+ "scopeId": "test.module.alpha",
+ "dependencies": [
+ {
+ "name": "TestToolboxC",
+ "dependencyType": "community-toolbox",
+ "requirementLevel": "required",
+ "source": "https://github.com/test-org/TestToolboxC",
+ "description": "Required module community toolbox for testing",
+ "installCheck": "TestToolboxCCheck"
+ },
+ {
+ "name": "TestToolboxA",
+ "dependencyType": "community-toolbox",
+ "requirementLevel": "optional",
+ "source": "https://github.com/test-org/TestToolboxA",
+ "description": "Duplicate of core dependency at module scope (optional here)",
+ "installCheck": "TestToolboxACheck"
+ },
+ {
+ "name": "TestWorkflowTool",
+ "dependencyType": "community-toolbox",
+ "requirementLevel": "optional",
+ "scope": "workflow",
+ "scopeId": "test.module.alpha.workflow.beta",
+ "source": "https://github.com/test-org/TestWorkflowTool",
+ "description": "Workflow-scoped community toolbox for testing",
+ "installCheck": "TestWorkflowToolCheck",
+ "reason": "Needed for beta workflow"
+ },
+ {
+ "name": "Signal Processing Toolbox",
+ "dependencyType": "mathworks-product",
+ "requirementLevel": "optional",
+ "reason": "Optional MathWorks product at module scope"
+ }
+ ]
+}
diff --git a/tests/+nansen/+unittest/AddonManagerTest.m b/tests/+nansen/+unittest/AddonManagerTest.m
new file mode 100644
index 00000000..d447d61a
--- /dev/null
+++ b/tests/+nansen/+unittest/AddonManagerTest.m
@@ -0,0 +1,331 @@
+classdef AddonManagerTest < matlab.unittest.TestCase
+%AddonManagerTest Tests for the AddonManager singleton.
+%
+% Tests initialization, manifest loading, dependency refresh,
+% installation status tracking, merge/deduplication, and edge cases
+% using isolated test manifests.
+
+ properties (Access = private)
+ Fixture
+ end
+
+ properties (Constant, Access = private)
+ TestManifestDirectory = fullfile( ...
+ fileparts(fileparts(mfilename('fullpath'))), ...
+ '+fixture', 'manifests')
+ CoreManifestPath (1,1) string = fullfile( ...
+ fileparts(fileparts(mfilename('fullpath'))), ...
+ '+fixture', 'manifests', 'test_core_dependencies.nansen.json')
+ ModuleManifestPath (1,1) string = fullfile( ...
+ fileparts(fileparts(mfilename('fullpath'))), ...
+ '+fixture', 'manifests', 'test_module_dependencies.nansen.json')
+ end
+
+ methods (TestMethodSetup)
+ function applyAddonManagerFixture(testCase)
+ testCase.Fixture = testCase.applyFixture( ...
+ nansen.fixture.AddonManagerFixture);
+ end
+ end
+
+ % --- Initialization & manifest loading ---
+
+ methods (Test)
+ function createWithEmptyManifest(testCase)
+ %createWithEmptyManifest Singleton starts with addon list from test manifests.
+ manager = testCase.Fixture.AddonManager;
+ testCase.verifyNotEmpty(manager, ...
+ 'AddonManager instance should be created')
+ testCase.verifyClass(manager, ...
+ 'nansen.config.addons.AddonManager')
+ end
+
+ function installationFolderIsTemporary(testCase)
+ %installationFolderIsTemporary Installation folder points to temp dir.
+ manager = testCase.Fixture.AddonManager;
+ testCase.verifyTrue( ...
+ contains(manager.InstallationFolder, tempdir) || ...
+ contains(manager.InstallationFolder, "tmp"), ...
+ 'Installation folder should be in a temporary location')
+ end
+
+ function persistAndReloadManifest(testCase)
+ %persistAndReloadManifest Save, reset, and reload round-trip.
+ manager = testCase.Fixture.AddonManager;
+ manager.saveAddonList();
+ testCase.verifyTrue(isfile(testCase.Fixture.ManifestFilePath), ...
+ 'Manifest file should exist after save')
+
+ originalAddonNames = string({manager.AddonList.Name});
+
+ % Reset with same storage paths to reload persisted data
+ reloadedManager = nansen.config.addons.AddonManager.instance( ...
+ "reset", ...
+ testCase.Fixture.InstallationFolder, ...
+ testCase.Fixture.ManifestFilePath);
+ % Refresh from same test manifests
+ reloadedManager.refreshManagedAddons( ...
+ "ManifestPaths", [testCase.CoreManifestPath, testCase.ModuleManifestPath]);
+ reloadedAddonNames = string({reloadedManager.AddonList.Name});
+
+ testCase.verifyEqual(sort(reloadedAddonNames), sort(originalAddonNames), ...
+ 'Addon names should survive save/reload round-trip')
+ end
+ end
+
+ % --- Refresh from test manifests ---
+
+ methods (Test)
+ function refreshFromCoreManifest(testCase)
+ %refreshFromCoreManifest Core test manifest resolves expected entries.
+ manager = testCase.Fixture.AddonManager;
+ addonNames = string({manager.AddonList.Name});
+
+ testCase.verifyTrue(ismember("TestToolboxA", addonNames), ...
+ 'TestToolboxA should be in addon list')
+ testCase.verifyTrue(ismember("TestToolboxB", addonNames), ...
+ 'TestToolboxB should be in addon list')
+ end
+
+ function coreAddonHasCorrectSource(testCase)
+ %coreAddonHasCorrectSource Community toolbox source is preserved.
+ manager = testCase.Fixture.AddonManager;
+ addonIndex = find(strcmp({manager.AddonList.Name}, 'TestToolboxA'));
+ testCase.assumeNotEmpty(addonIndex, 'TestToolboxA not found')
+
+ testCase.verifyEqual(string(manager.AddonList(addonIndex).Source), ...
+ "https://github.com/test-org/TestToolboxA")
+ end
+
+ function requiredFlagIsSet(testCase)
+ %requiredFlagIsSet Required addon has IsRequired=true.
+ manager = testCase.Fixture.AddonManager;
+ addonIndex = find(strcmp({manager.AddonList.Name}, 'TestToolboxA'));
+ testCase.assumeNotEmpty(addonIndex)
+
+ testCase.verifyTrue(manager.AddonList(addonIndex).IsRequired, ...
+ 'TestToolboxA should be marked as required')
+ end
+
+ function optionalFlagIsSet(testCase)
+ %optionalFlagIsSet Optional addon has IsRequired=false.
+ manager = testCase.Fixture.AddonManager;
+ addonIndex = find(strcmp({manager.AddonList.Name}, 'TestToolboxB'));
+ testCase.assumeNotEmpty(addonIndex)
+
+ testCase.verifyFalse(manager.AddonList(addonIndex).IsRequired, ...
+ 'TestToolboxB should be marked as optional')
+ end
+
+ function refreshWithModuleDependencies(testCase)
+ %refreshWithModuleDependencies Module deps appear after refresh with module selection.
+ manager = testCase.Fixture.AddonManager;
+ manager.refreshManagedAddons( ...
+ "ManifestPaths", [testCase.CoreManifestPath, testCase.ModuleManifestPath], ...
+ "SelectedModules", "test.module.alpha");
+
+ addonNames = string({manager.AddonList.Name});
+ testCase.verifyTrue(ismember("TestToolboxC", addonNames), ...
+ 'TestToolboxC (module dep) should appear after refresh with module')
+ end
+
+ function mathworksProductsExcludedFromAddonList(testCase)
+ %mathworksProductsExcludedFromAddonList MathWorks products are not in AddonList.
+ % refreshManagedAddons filters for community-toolbox only.
+ manager = testCase.Fixture.AddonManager;
+ addonNames = string({manager.AddonList.Name});
+
+ testCase.verifyFalse(ismember("Image Processing Toolbox", addonNames), ...
+ 'MathWorks products should not appear in AddonList')
+ testCase.verifyFalse(ismember("Parallel Computing Toolbox", addonNames), ...
+ 'MathWorks products should not appear in AddonList')
+ end
+ end
+
+ % --- Deduplication and merge ---
+
+ methods (Test)
+ function duplicateAcrossScopesMergesCorrectly(testCase)
+ %duplicateAcrossScopesMergesCorrectly Same dependency from core+module deduplicates.
+ manager = testCase.Fixture.AddonManager;
+ manager.refreshManagedAddons( ...
+ "ManifestPaths", [testCase.CoreManifestPath, testCase.ModuleManifestPath], ...
+ "SelectedModules", "test.module.alpha");
+
+ % TestToolboxA is in both core (required) and module (optional)
+ matchIndices = find(strcmp({manager.AddonList.Name}, 'TestToolboxA'));
+ testCase.verifyNumElements(matchIndices, 1, ...
+ 'TestToolboxA should appear exactly once after deduplication')
+ end
+
+ function requiredWinsOverOptional(testCase)
+ %requiredWinsOverOptional Required level wins when same dep appears in multiple scopes.
+ manager = testCase.Fixture.AddonManager;
+ manager.refreshManagedAddons( ...
+ "ManifestPaths", [testCase.CoreManifestPath, testCase.ModuleManifestPath], ...
+ "SelectedModules", "test.module.alpha");
+
+ addonIndex = find(strcmp({manager.AddonList.Name}, 'TestToolboxA'));
+ testCase.assumeNotEmpty(addonIndex)
+
+ testCase.verifyTrue(manager.AddonList(addonIndex).IsRequired, ...
+ 'TestToolboxA should be required (core=required wins over module=optional)')
+ end
+
+ function existingAddonPreservesInstallState(testCase)
+ %existingAddonPreservesInstallState Installed addon stays installed after refresh.
+ % Seeds a manifest file with one addon marked as installed,
+ % creates a fresh singleton, and verifies the install state
+ % survives a refreshManagedAddons call.
+ import matlab.unittest.fixtures.TemporaryFolderFixture
+ temporaryFolderFixture = testCase.applyFixture(TemporaryFolderFixture);
+ installationFolder = fullfile(temporaryFolderFixture.Folder, 'Add-Ons');
+ mkdir(installationFolder);
+ manifestFilePath = fullfile(temporaryFolderFixture.Folder, 'installed_addons.json');
+
+ % Create a fake installed addon folder
+ fakeAddonFolder = fullfile(installationFolder, 'TestToolboxA');
+ mkdir(fakeAddonFolder);
+ testCase.addTeardown(@() rmpath(fakeAddonFolder));
+
+ % Write a pre-seeded manifest with one installed addon
+ defaultEntry = nansen.config.addons.AddonManager.DefaultAddonEntry;
+ addonEntry = defaultEntry;
+ addonEntry.Name = 'TestToolboxA';
+ addonEntry.IsInstalled = true;
+ addonEntry.FilePath = char(fakeAddonFolder);
+ addonEntry.InstallationType = 'folder';
+ addonEntry.Source = 'https://github.com/test-org/TestToolboxA';
+ addonEntry.InstallCheck = 'TestToolboxACheck';
+ savedData = struct( ...
+ 'type', 'Nansen Configuration: List of Installed Addons', ...
+ 'description', 'Test manifest', ...
+ 'AddonList', addonEntry);
+ jsonText = jsonencode(savedData, 'PrettyPrint', true);
+ fileIdentifier = fopen(manifestFilePath, 'w');
+ fwrite(fileIdentifier, jsonText, 'char');
+ fclose(fileIdentifier);
+
+ % Create singleton from pre-seeded manifest (no discovery)
+ manager = nansen.config.addons.AddonManager.instance( ...
+ "reset", installationFolder, manifestFilePath);
+ % Refresh from test manifest — should preserve install state
+ manager.refreshManagedAddons( ...
+ "ManifestPaths", testCase.CoreManifestPath);
+ addonIndex = find(strcmp({manager.AddonList.Name}, 'TestToolboxA'));
+ testCase.assumeNotEmpty(addonIndex, 'TestToolboxA should be in list')
+ testCase.verifyTrue(manager.AddonList(addonIndex).IsInstalled, ...
+ 'Pre-seeded installation state should be preserved after refresh')
+ end
+
+ function newDependencyAddedOnRefresh(testCase)
+ %newDependencyAddedOnRefresh A dependency not previously tracked appears after refresh.
+ manager = testCase.Fixture.AddonManager;
+
+ % Initially only core manifest was used (default fixture)
+ addonNamesBefore = string({manager.AddonList.Name});
+ testCase.verifyFalse(ismember("TestToolboxC", addonNamesBefore), ...
+ 'TestToolboxC should not be present before module refresh')
+
+ % Refresh with module manifest
+ manager.refreshManagedAddons( ...
+ "ManifestPaths", [testCase.CoreManifestPath, testCase.ModuleManifestPath], ...
+ "SelectedModules", "test.module.alpha");
+
+ addonNamesAfter = string({manager.AddonList.Name});
+ testCase.verifyTrue(ismember("TestToolboxC", addonNamesAfter), ...
+ 'TestToolboxC should appear after module refresh')
+ end
+ end
+
+ % --- Installation status ---
+
+ methods (Test)
+ function uninstalledAddonReportsFalse(testCase)
+ %uninstalledAddonReportsFalse isAddonInstalled returns false for missing addon.
+ manager = testCase.Fixture.AddonManager;
+ testCase.verifyFalse(manager.isAddonInstalled('TestToolboxA'), ...
+ 'TestToolboxA should not be installed in test environment')
+ end
+
+ function isAddonInstalledReturnsFalseForUnknown(testCase)
+ %isAddonInstalledReturnsFalseForUnknown Unknown addon name returns false.
+ manager = testCase.Fixture.AddonManager;
+ testCase.verifyFalse(manager.isAddonInstalled('NonExistentToolbox'), ...
+ 'Unknown addon should return false')
+ end
+
+ function saveAddonListCreatesValidJson(testCase)
+ %saveAddonListCreatesValidJson Saved manifest is valid JSON with expected structure.
+ manager = testCase.Fixture.AddonManager;
+ manager.saveAddonList();
+
+ jsonText = fileread(testCase.Fixture.ManifestFilePath);
+ savedData = jsondecode(jsonText);
+
+ testCase.verifyTrue(isfield(savedData, 'AddonList'), ...
+ 'Saved data should have AddonList field')
+ testCase.verifyTrue(isfield(savedData, 'type'), ...
+ 'Saved data should have type field')
+ end
+
+ function markDirtyAndClean(testCase)
+ %markDirtyAndClean IsDirty flag tracks unsaved changes.
+ manager = testCase.Fixture.AddonManager;
+ manager.markDirty();
+ testCase.verifyTrue(manager.IsDirty, ...
+ 'Manager should be dirty after markDirty')
+
+ manager.saveAddonList();
+ testCase.verifyFalse(manager.IsDirty, ...
+ 'Manager should be clean after save')
+ end
+ end
+
+ % --- Edge cases ---
+
+ methods (Test)
+ function isAddonInstalledCaseInsensitive(testCase)
+ %isAddonInstalledCaseInsensitive Addon lookup by name is case-insensitive.
+ % isAddonInstalled uses case-sensitive strcmp for the name check,
+ % so this test documents the current behavior.
+ manager = testCase.Fixture.AddonManager;
+ testCase.assumeTrue(any(strcmp({manager.AddonList.Name}, 'TestToolboxA')))
+
+ % isAddonInstalled checks exact name match first, then uses
+ % getAddonIndex (case-insensitive). With exact name, lookup works.
+ resultExact = manager.isAddonInstalled('TestToolboxA');
+ testCase.verifyFalse(resultExact, ...
+ 'TestToolboxA is not installed but name should be found')
+ end
+
+ function downloadUnknownAddonThrowsError(testCase)
+ %downloadUnknownAddonThrowsError Unknown addon name causes error.
+ manager = testCase.Fixture.AddonManager;
+ testCase.verifyError( ...
+ @() manager.downloadAddon('CompletelyUnknownToolbox', false, true), ...
+ 'NANSEN:AddonManager:NotFound')
+ end
+
+ function managedAddonsReturnsTable(testCase)
+ %managedAddonsReturnsTable ManagedAddons property returns a table.
+ manager = testCase.Fixture.AddonManager;
+ managedAddons = manager.ManagedAddons;
+ testCase.verifyClass(managedAddons, 'table')
+ testCase.verifyTrue(ismember('Name', managedAddons.Properties.VariableNames), ...
+ 'ManagedAddons table should have Name column')
+ end
+
+ function workflowScopedDepsRequireWorkflowSelection(testCase)
+ %workflowScopedDepsRequireWorkflowSelection Workflow deps excluded without selection.
+ manager = testCase.Fixture.AddonManager;
+ manager.refreshManagedAddons( ...
+ "ManifestPaths", [testCase.CoreManifestPath, testCase.ModuleManifestPath], ...
+ "SelectedModules", "test.module.alpha");
+
+ addonNames = string({manager.AddonList.Name});
+ testCase.verifyFalse(ismember("TestWorkflowTool", addonNames), ...
+ 'Workflow-scoped deps should not appear without SelectedWorkflows')
+ end
+ end
+end
diff --git a/tests/+nansen/+unittest/TestDependencyManagement.m b/tests/+nansen/+unittest/TestDependencyManagement.m
new file mode 100644
index 00000000..e24f15cb
--- /dev/null
+++ b/tests/+nansen/+unittest/TestDependencyManagement.m
@@ -0,0 +1,270 @@
+classdef TestDependencyManagement < matlab.unittest.TestCase
+%TestDependencyManagement Tests for the dependency manifest system.
+%
+% Tests the real-world workflows: reading manifests, resolving
+% dependencies across scopes, checking installation status, and
+% verifying schema/manifest consistency.
+
+ properties (Constant, Access = private)
+ CoreManifestPath = fullfile(nansen.toolboxdir, 'dependencies.nansen.json')
+ SchemaPath = fullfile(nansen.toolboxdir, '..', 'schemas', 'dependencies.nansen.json')
+ end
+
+ % --- Manifest reading ---
+
+ methods (Test)
+ function readCoreManifest(testCase)
+ %readCoreManifest Verify the core manifest loads and has expected structure.
+ testCase.assumeTrue(isfile(testCase.CoreManifestPath), ...
+ 'Core manifest file not found')
+
+ dependencies = nansen.internal.dependencies.readManifest( ...
+ testCase.CoreManifestPath);
+
+ testCase.verifyNotEmpty(dependencies, ...
+ 'Core manifest should contain at least one dependency')
+ testCase.verifyTrue(isstruct(dependencies), ...
+ 'readManifest should return a struct array')
+
+ expectedFields = ["Name", "DependencyType", "Scope", "ScopeId", ...
+ "RequirementLevel", "Source", "DocsSource", "Description", ...
+ "Reason", "WorkflowNotes", "SetupHook", "StartupHook", ...
+ "InstallCheck", "VersionConstraint"];
+ actualFields = string(fieldnames(dependencies));
+ for i = 1:numel(expectedFields)
+ testCase.verifyTrue(ismember(expectedFields(i), actualFields), ...
+ sprintf('Missing field: %s', expectedFields(i)))
+ end
+ end
+
+ function coreManifestScopeInheritance(testCase)
+ %coreManifestScopeInheritance Verify scope inherits from manifest level.
+ testCase.assumeTrue(isfile(testCase.CoreManifestPath))
+
+ dependencies = nansen.internal.dependencies.readManifest( ...
+ testCase.CoreManifestPath);
+
+ for i = 1:numel(dependencies)
+ testCase.verifyEqual(string(dependencies(i).Scope), "core", ...
+ sprintf('Dependency "%s" should inherit scope "core"', ...
+ dependencies(i).Name))
+ end
+ end
+
+ function communityToolboxesHaveSource(testCase)
+ %communityToolboxesHaveSource Every community toolbox must have a source URI.
+ testCase.assumeTrue(isfile(testCase.CoreManifestPath))
+
+ dependencies = nansen.internal.dependencies.readManifest( ...
+ testCase.CoreManifestPath);
+
+ for i = 1:numel(dependencies)
+ if dependencies(i).DependencyType == "community-toolbox"
+ testCase.verifyTrue( ...
+ strlength(dependencies(i).Source) > 0, ...
+ sprintf('Community toolbox "%s" has no source', ...
+ dependencies(i).Name))
+ end
+ end
+ end
+
+ function mathworksProductsHaveNoSource(testCase)
+ %mathworksProductsHaveNoSource MathWorks products should not have source URIs.
+ testCase.assumeTrue(isfile(testCase.CoreManifestPath))
+
+ dependencies = nansen.internal.dependencies.readManifest( ...
+ testCase.CoreManifestPath);
+
+ for i = 1:numel(dependencies)
+ if dependencies(i).DependencyType == "mathworks-product"
+ testCase.verifyEqual(dependencies(i).Source, "", ...
+ sprintf('MathWorks product "%s" should not have a source URI', ...
+ dependencies(i).Name))
+ end
+ end
+ end
+ end
+
+ % --- Module manifest with scope overrides ---
+
+ methods (Test)
+ function readModuleManifestWithWorkflowScopes(testCase)
+ %readModuleManifestWithWorkflowScopes Verify per-entry scope overrides.
+ moduleManifestPath = fullfile(nansen.toolboxdir, 'modules', ...
+ '+nansen', '+module', '+ophys', '+twophoton', ...
+ 'dependencies.nansen.json');
+ testCase.assumeTrue(isfile(moduleManifestPath), ...
+ 'Two-photon module manifest not found')
+
+ dependencies = nansen.internal.dependencies.readManifest( ...
+ moduleManifestPath);
+
+ % Find a workflow-scoped entry (e.g. EXTRACT)
+ names = string({dependencies.Name});
+ extractIdx = find(names == "EXTRACT", 1);
+ testCase.assumeNotEmpty(extractIdx, 'EXTRACT dependency not found')
+
+ testCase.verifyEqual(string(dependencies(extractIdx).Scope), "workflow")
+ testCase.verifyTrue(contains(dependencies(extractIdx).ScopeId, "extract"))
+
+ % Entries without scope override should inherit "module"
+ iptIdx = find(names == "Image Processing Toolbox", 1);
+ testCase.assumeNotEmpty(iptIdx)
+ testCase.verifyEqual(string(dependencies(iptIdx).Scope), "module")
+ end
+ end
+
+ % --- Resolve requirements ---
+
+ methods (Test)
+ function resolveCoreOnly(testCase)
+ %resolveCoreOnly Resolve core dependencies and verify filtering works.
+ resolved = nansen.internal.dependencies.resolveRequirements();
+
+ testCase.verifyNotEmpty(resolved, ...
+ 'Resolving core dependencies should return results')
+
+ % All should be core scope
+ scopes = string({resolved.Scope});
+ testCase.verifyTrue(all(scopes == "core"), ...
+ 'Core-only resolution should only contain core-scoped entries')
+ end
+
+ function filterByDependencyType(testCase)
+ %filterByDependencyType Verify filtering by mathworks-product.
+ resolved = nansen.internal.dependencies.resolveRequirements( ...
+ "DependencyTypes", "mathworks-product");
+
+ types = string({resolved.DependencyType});
+ testCase.verifyTrue(all(types == "mathworks-product"), ...
+ 'All results should be mathworks-product')
+ end
+
+ function filterByRequirementLevel(testCase)
+ %filterByRequirementLevel Verify filtering by required level.
+ resolved = nansen.internal.dependencies.resolveRequirements( ...
+ "RequirementLevels", "required");
+
+ levels = string({resolved.RequirementLevel});
+ testCase.verifyTrue(all(levels == "required"), ...
+ 'All results should be required')
+ end
+
+ function resolvedHasIsInstalledField(testCase)
+ %resolvedHasIsInstalledField Verify installation status is annotated.
+ resolved = nansen.internal.dependencies.resolveRequirements();
+ testCase.assumeNotEmpty(resolved)
+
+ testCase.verifyTrue(isfield(resolved, 'IsInstalled'), ...
+ 'Resolved requirements should have IsInstalled field')
+ testCase.verifyTrue(islogical([resolved.IsInstalled]), ...
+ 'IsInstalled should be logical')
+ end
+
+ function missingOnlyFilter(testCase)
+ %missingOnlyFilter Verify MissingOnly returns only uninstalled deps.
+ resolved = nansen.internal.dependencies.resolveRequirements( ...
+ "MissingOnly", true);
+
+ if isempty(resolved); return; end
+
+ installedFlags = [resolved.IsInstalled];
+ testCase.verifyTrue(~any(installedFlags), ...
+ 'MissingOnly should only return uninstalled dependencies')
+ end
+ end
+
+ % --- Check installation status ---
+
+ methods (Test)
+ function imageProcessingToolboxDetection(testCase)
+ %imageProcessingToolboxDetection Verify MathWorks product detection works.
+ entry = struct( ...
+ 'Name', "Image Processing Toolbox", ...
+ 'DependencyType', "mathworks-product", ...
+ 'InstallCheck', "");
+
+ result = nansen.internal.dependencies.checkInstallationStatus(entry);
+
+ % If IPT is installed, IsInstalled should be true
+ versionInfo = ver();
+ expectedInstalled = any(string({versionInfo.Name}) == "Image Processing Toolbox");
+ testCase.verifyEqual(result.IsInstalled, expectedInstalled)
+ end
+
+ function communityToolboxCheckDetection(testCase)
+ %communityToolboxCheckDetection Verify InstallCheck-based detection.
+ % Test with a function we know exists (part of MATLAB itself)
+ entry = struct( ...
+ 'Name', "Test Check", ...
+ 'DependencyType', "community-toolbox", ...
+ 'InstallCheck', "nansen");
+
+ result = nansen.internal.dependencies.checkInstallationStatus(entry);
+ testCase.verifyTrue(result.IsInstalled, ...
+ '"nansen" should always be found via exist()')
+ end
+
+ function missingCheckReportsNotInstalled(testCase)
+ %missingCheckReportsNotInstalled Empty InstallCheck → not installed.
+ entry = struct( ...
+ 'Name', "No Check", ...
+ 'DependencyType', "community-toolbox", ...
+ 'InstallCheck', "");
+
+ result = nansen.internal.dependencies.checkInstallationStatus(entry);
+ testCase.verifyFalse(result.IsInstalled, ...
+ 'Empty InstallCheck should report not installed')
+ end
+ end
+
+ % --- Schema consistency ---
+
+ methods (Test)
+ function schemaAndManifestConsistency(testCase)
+ %schemaAndManifestConsistency Verify manifest validates against schema.
+ testCase.assumeTrue(isfile(testCase.SchemaPath), ...
+ 'Schema file not found')
+ testCase.assumeTrue(isfile(testCase.CoreManifestPath), ...
+ 'Core manifest not found')
+
+ schemaData = jsondecode(fileread(testCase.SchemaPath));
+ manifestData = jsondecode(fileread(testCase.CoreManifestPath));
+
+ % Check that manifest has all required top-level fields
+ % (jsondecode mangles leading _ to x_, so _schema_id → x_schema_id)
+ manifestFields = string(fieldnames(manifestData));
+ testCase.verifyTrue(ismember("x_schema_id", manifestFields))
+ testCase.verifyTrue(ismember("x_schema_version", manifestFields))
+ testCase.verifyTrue(ismember("x_type", manifestFields))
+ testCase.verifyTrue(ismember("scope", manifestFields))
+ testCase.verifyTrue(ismember("scopeId", manifestFields))
+ testCase.verifyTrue(ismember("dependencies", manifestFields))
+
+ % Verify _schema_id matches schema $id
+ testCase.verifyEqual( ...
+ string(manifestData.x_schema_id), ...
+ string(schemaData.x_id), ...
+ 'Manifest _schema_id should match schema $id')
+ end
+
+ function camelToPascalMappingRoundTrip(testCase)
+ %camelToPascalMappingRoundTrip Verify all schema fields map correctly.
+ testCase.assumeTrue(isfile(testCase.CoreManifestPath))
+
+ dependencies = nansen.internal.dependencies.readManifest( ...
+ testCase.CoreManifestPath);
+
+ % These PascalCase fields should exist from camelCase JSON keys
+ expectedPascalFields = ["Name", "DependencyType", "RequirementLevel", ...
+ "Source", "Description", "Reason", "SetupHook"];
+ actualFields = string(fieldnames(dependencies));
+
+ for i = 1:numel(expectedPascalFields)
+ testCase.verifyTrue(ismember(expectedPascalFields(i), actualFields), ...
+ sprintf('camelToPascal should produce field: %s', ...
+ expectedPascalFields(i)))
+ end
+ end
+ end
+end
diff --git a/tools/+nansentools/generateRequirementsTxt.m b/tools/+nansentools/generateRequirementsTxt.m
new file mode 100644
index 00000000..54f34bb3
--- /dev/null
+++ b/tools/+nansentools/generateRequirementsTxt.m
@@ -0,0 +1,49 @@
+function generateRequirementsTxt(manifestFilePath, outputFilePath)
+%generateRequirementsTxt Generate requirements.txt from a manifest file.
+%
+% generateRequirementsTxt(manifestFilePath, outputFilePath) reads a
+% dependencies.nansen.json manifest and writes a requirements.txt
+% containing only community-toolbox entries with their Source URIs.
+%
+% MathWorks products are excluded. Entries without a Source field are
+% skipped with a warning.
+%
+% Input:
+% manifestFilePath (1,1) string - Path to dependencies.nansen.json
+% outputFilePath (1,1) string - Path to write requirements.txt
+
+ arguments
+ manifestFilePath (1,1) string {mustBeFile} = fullfile(nansentools.projectdir, 'code', 'dependencies.nansen.json')
+ outputFilePath (1,1) string = fullfile(nansentools.projectdir, 'requirements.txt')
+ end
+
+ dependencies = nansen.internal.dependencies.readManifest(manifestFilePath);
+
+ sourceLines = string.empty;
+ for i = 1:numel(dependencies)
+ entry = dependencies(i);
+
+ if entry.DependencyType ~= "community-toolbox"
+ continue
+ end
+
+ if entry.Source == ""
+ warning("NANSEN:Dependencies:MissingSource", ...
+ "Community toolbox '%s' has no Source field — skipping.", ...
+ entry.Name);
+ continue
+ end
+
+ sourceLines(end+1) = entry.Source; %#ok
+ end
+
+ % Write output file
+ fileContent = strjoin(sourceLines, newline) + newline;
+ fileId = fopen(outputFilePath, 'w');
+ cleanupObj = onCleanup(@() fclose(fileId));
+ if fileId == -1
+ error("NANSEN:Dependencies:FileWriteError", ...
+ "Could not open '%s' for writing.", outputFilePath);
+ end
+ fprintf(fileId, '%s', fileContent);
+end