diff --git a/.github/badges/code_issues.svg b/.github/badges/code_issues.svg index 367e4cc71..b6eedcd61 100644 --- a/.github/badges/code_issues.svg +++ b/.github/badges/code_issues.svg @@ -1 +1 @@ -code issuescode issues12981298 \ No newline at end of file +code issuescode issues12971297 \ No newline at end of file diff --git a/.github/badges/tests.svg b/.github/badges/tests.svg index 13f7d3cb6..57016f9d6 100644 --- a/.github/badges/tests.svg +++ b/.github/badges/tests.svg @@ -1 +1 @@ -teststests199 passed199 passed \ No newline at end of file +teststests223 passed223 passed \ No newline at end of file diff --git a/code/+nansen/+config/+project/@Project/Project.m b/code/+nansen/+config/+project/@Project/Project.m index 4635bb6ac..5783ee9c2 100644 --- a/code/+nansen/+config/+project/@Project/Project.m +++ b/code/+nansen/+config/+project/@Project/Project.m @@ -28,7 +28,8 @@ end properties (SetAccess = private) - FolderPath char % Path to the project folder + FolderPath char % Path to the project folder + FileAdapterRegistry nansen.plugin.fileadapter.Registry % Registry for FileAdapter plugins end properties (SetAccess = private, Hidden) @@ -89,6 +90,7 @@ obj.FolderPath = projectFolder; obj.initializeModules() + obj.initializeFileAdapterRegistry() end end @@ -666,8 +668,9 @@ function updateModules(obj) % Remove any modules that were included but not any more for i = numel(removeIdx):-1:1 - % removedModule = obj.IncludedModules(removeIdx(i)); + removedId = string(currentModules(removeIdx(i)).ID); obj.IncludedModules(removeIdx(i)) = []; + obj.FileAdapterRegistry.removeSource(removedId); % Remove variables (Not needed?): %variableList = removedModule.DataVariables; @@ -682,10 +685,49 @@ function updateModules(obj) % Update variable model based on module's template variables variableList = table2struct( module.getTable('DataVariables') ); obj.VariableModel.addDataVariableSet(variableList) + + moduleRoot = fileparts(module.getFileAdapterFolder()); + obj.FileAdapterRegistry.addSource(string(module.ID), moduleRoot); + end + + if ~isempty(addedModuleID) || ~isempty(removeIdx) + obj.FileAdapterRegistry.reconcile(); + notify(obj, 'ModuleListChanged') end end end + function initializeFileAdapterRegistry(obj) + %initializeFileAdapterRegistry Build and populate the FileAdapter registry. + % + % Pinned source ordering (highest to lowest priority): + % top - project's own +fileadapter/ folder + % middle - included optional modules (in IncludedModules order) + % bottom - core module + + obj.FileAdapterRegistry = nansen.plugin.fileadapter.Registry(); + + coreModuleName = string(obj.RequiredModuleName); + coreRoot = fullfile(nansen.common.constant.ModuleRootDirectory, ... + utility.path.packagename2pathstr(coreModuleName)); + obj.FileAdapterRegistry.pinBottom(coreModuleName, coreRoot); + + % Middle: included modules (excluding the required/core module which + % is already pinned at the bottom). + for i = 1:numel(obj.IncludedModules) + module = obj.IncludedModules(i); + moduleId = string(module.ID); + if moduleId == coreModuleName; continue; end + moduleRoot = fileparts(module.getFileAdapterFolder()); + obj.FileAdapterRegistry.addSource(moduleId, moduleRoot); + end + + projectModuleRoot = obj.getModuleFolder(); + obj.FileAdapterRegistry.pinTop(string(obj.ID), projectModuleRoot); + + obj.FileAdapterRegistry.reconcile(); + end + function initializeVariableModel(obj) % initializeVariableModel - Initialize a variable model filePath = obj.getCatalogPath('VariableModel'); diff --git a/code/+nansen/+config/+varmodel/VariableModel.m b/code/+nansen/+config/+varmodel/VariableModel.m index 83ebbe181..6a5fa8a19 100644 --- a/code/+nansen/+config/+varmodel/VariableModel.m +++ b/code/+nansen/+config/+varmodel/VariableModel.m @@ -175,7 +175,7 @@ function updateDefaultValues(obj) [obj.Data(:).DataType] = deal(''); for i = 1:numel(obj.Data) if ~strcmp(obj.Data(i).FileAdapter, 'Default') - isMatch = strcmp({fileAdapterList.FileAdapterName}, obj.Data(i).FileAdapter); + isMatch = [fileAdapterList.FileAdapterName] == string(obj.Data(i).FileAdapter); if any(isMatch) fileAdapterFcn = str2func(fileAdapterList(isMatch).FunctionName); obj.Data(i).DataType = fileAdapterFcn().DataType; @@ -300,7 +300,7 @@ function updateDefaultValues(obj) end % Find file adapter match for name - isMatch = strcmp({fileAdapterList.FileAdapterName}, variableInfo.FileAdapter); + isMatch = [fileAdapterList.FileAdapterName] == string(variableInfo.FileAdapter); if ~any(isMatch) error('File adapter was not found') @@ -642,7 +642,7 @@ function disableNotifications(obj) function variableItem = updateVariableDataType(variableItem) fileAdapterList = nansen.dataio.listFileAdapters(); if ~strcmp(variableItem.FileAdapter, 'Default') - isMatch = strcmp({fileAdapterList.FileAdapterName}, variableItem.FileAdapter); + isMatch = [fileAdapterList.FileAdapterName] == string(variableItem.FileAdapter); if any(isMatch) fileAdapterFcn = str2func(fileAdapterList(isMatch).FunctionName); diff --git a/code/+nansen/+config/+varmodel/VariableModelUI.m b/code/+nansen/+config/+varmodel/VariableModelUI.m index 2d426a7e5..8c0d0c7bc 100644 --- a/code/+nansen/+config/+varmodel/VariableModelUI.m +++ b/code/+nansen/+config/+varmodel/VariableModelUI.m @@ -115,7 +115,7 @@ function delete(obj) S(j).Subfolder = hRow.SubfolderName.Value; % Update data type based on fileadapter selection - isMatch = strcmp({fileAdapterList.FileAdapterName}, S(j).FileAdapter); + isMatch = [fileAdapterList.FileAdapterName] == string(S(j).FileAdapter); if any(isMatch) && ~strcmp( S(j).FileAdapter, 'Default' ) S(j).DataType = fileAdapterList(isMatch).DataType; end @@ -297,9 +297,9 @@ function assignDefaultTablePropertyValues(obj) if ~isempty(rowData.FileType) fileAdapterOptions = nansen.dataio.listFileAdapters(rowData.FileType); - fileAdapterOptions = {fileAdapterOptions.FileAdapterName}; + fileAdapterOptions = [fileAdapterOptions.FileAdapterName]; else - fileAdapterOptions = {obj.FileAdapterList.FileAdapterName}; + fileAdapterOptions = [obj.FileAdapterList.FileAdapterName]; end hRow.FileAdapterSelect.Items = fileAdapterOptions; @@ -446,18 +446,18 @@ function onFileTypeChanged(obj, src, ~) fileType = lower( strrep(fileType, '.', '') ); fileAdapterOptions = nansen.dataio.listFileAdapters(fileType); - fileAdapterOptions = {fileAdapterOptions.FileAdapterName}; + fileAdapterOptions = [fileAdapterOptions.FileAdapterName]; % Update the list of file adapters available for this filetype - if ~isequal(fileAdapterOptions, {'N/A'}) + if ~isequal(fileAdapterOptions, "N/A") hRow.FileAdapterSelect.Items = fileAdapterOptions; if ~contains(hRow.FileAdapterSelect.Value, fileAdapterOptions) - hRow.FileAdapterSelect.Value = fileAdapterOptions{1}; + hRow.FileAdapterSelect.Value = fileAdapterOptions(1); end else hRow.FileAdapterSelect.Items = fileAdapterOptions; - hRow.FileAdapterSelect.Value = fileAdapterOptions{1}; + hRow.FileAdapterSelect.Value = fileAdapterOptions(1); end end @@ -477,13 +477,14 @@ function onFileAdapterChanged(obj, src, evt) newValue = evt.Value; fileAdapterList = obj.FileAdapterList; - isMatch = strcmp({fileAdapterList.FileAdapterName}, newValue); + isMatch = [fileAdapterList.FileAdapterName] == string(newValue); isSupported = false; if any(isMatch) supportedFileTypes = fileAdapterList(isMatch).SupportedFileTypes; - if any(ismember(supportedFileTypes, {fileType, ['.' fileType]})) + supportedFileTypesWithDot = "." + strrep(supportedFileTypes, ".", ""); + if any(ismember(supportedFileTypesWithDot, "." + string(fileType))) isSupported = true; end end @@ -492,7 +493,8 @@ function onFileAdapterChanged(obj, src, evt) if ~isSupported hFig = ancestor(obj.Parent, 'figure'); if any(isMatch) - allowedFileTypes = strcat('.', fileAdapterList(isMatch).SupportedFileTypes); + allowedFileTypes = fileAdapterList(isMatch).SupportedFileTypes; + allowedFileTypes = "." + strrep(allowedFileTypes, ".", ""); supportedFileTypes = strjoin(allowedFileTypes, ', '); else if strcmp(newValue, 'Default') % Todo: Should not "Default" be part of FileAdapterList? diff --git a/code/+nansen/+config/+varmodel/uiCreateDataVariableFromFile.m b/code/+nansen/+config/+varmodel/uiCreateDataVariableFromFile.m index 6e19e89e5..f52f383d0 100644 --- a/code/+nansen/+config/+varmodel/uiCreateDataVariableFromFile.m +++ b/code/+nansen/+config/+varmodel/uiCreateDataVariableFromFile.m @@ -40,8 +40,8 @@ S = struct(); S.VariableName = ''; S.FileNameExpression = fileName; - S.FileAdapter = fileAdapterList(1).FileAdapterName; - S.FileAdapter_ = {fileAdapterList.FileAdapterName}; + S.FileAdapter = char(fileAdapterList(1).FileAdapterName); + S.FileAdapter_ = cellstr([fileAdapterList.FileAdapterName]); S.Favorite = false; if ~isempty(options.SkipFields) @@ -92,7 +92,7 @@ end % Get data type from file adapter - fileAdapterIdx = strcmp({fileAdapterList.FileAdapterName}, S.FileAdapter); + fileAdapterIdx = [fileAdapterList.FileAdapterName] == string(S.FileAdapter); % Normalize to char. Todo: Should support string type varItem.DataType = char( fileAdapterList(fileAdapterIdx).DataType ); diff --git a/code/+nansen/+dataio/listFileAdapters.m b/code/+nansen/+dataio/listFileAdapters.m index 9c4be6336..601b2563f 100644 --- a/code/+nansen/+dataio/listFileAdapters.m +++ b/code/+nansen/+dataio/listFileAdapters.m @@ -1,43 +1,39 @@ -function fileAdapterList = listFileAdapters(fileExtension, refresh) -%listFileAdapters Create a list of file adapters +function fileAdapterList = listFileAdapters(fileExtension, options) +%listFileAdapters Return a struct array of available file adapters. % -% fileAdapterList = nansen.dataio.listFileAdapters() returns a struct -% array containing information about file adapters. +% fileAdapterList = nansen.dataio.listFileAdapters() returns all adapters +% visible to the current project. % -% The fileAdapterList struct array contains the following fields: -% FileAdapterName (char) : Name of fileadapter -% FunctionName (char) : Name of function for file adapter -% SupportedFileTypes (cell) : File types that are supported with this fileadapter -% DataType (char) : Name of datatype returned by this file adapter - - % Todo: Ignore file adapters with a name that are already in the list - % Todo: Start adding from project dir, then watchfolder, then internal? - - if nargin < 2 || isempty(refresh); refresh = false; end - - project = nansen.getCurrentProject(); - if isempty(project); fileAdapterList = struct.empty; return; end - - fileAdapterList = table2struct(project.getTable('FileAdapter', refresh)); +% fileAdapterList = nansen.dataio.listFileAdapters(fileExtension) filters +% to adapters that support the given extension (with or without a dot). +% +% Each element of fileAdapterList has fields: +% FileAdapterName (string) : Class name of the adapter +% FunctionName (string) : Full MATLAB function/package name +% SupportedFileTypes (string) : Supported file extensions +% DataType (string) : Data type returned on load +% IsDynamic (logical) : true for sidecar-based adapters +% +% Optional name-value arguments: +% Project - Project instance to query (defaults to current project) - if nargin < 1; fileExtension = ''; end - if ~isempty(fileExtension); fileExtension = strrep(fileExtension, '.', ''); end + arguments + fileExtension (1,1) string = "" + options.Project = nansen.getCurrentProject() + end - if ~isempty(fileExtension) - validationFcn = @(extList) any(contains(extList, fileExtension, "IgnoreCase", true)); - keep = arrayfun(@(s) validationFcn(s.SupportedFileTypes), ... - fileAdapterList); - else - keep = true(1, numel(fileAdapterList)); + if isempty(options.Project) + fileAdapterList = struct.empty; + return end - fileAdapterList = fileAdapterList(keep); + fileAdapterList = options.Project.FileAdapterRegistry.list(fileExtension); if isempty(fileAdapterList) - fileAdapterList(1).FileAdapterName = 'N/A'; - fileAdapterList(1).FunctionName = ''; - fileAdapterList(1).SupportedFileTypes = {}; - fileAdapterList(1).DataType = ''; + fileAdapterList(1).FileAdapterName = "N/A"; + fileAdapterList(1).FunctionName = ""; + fileAdapterList(1).SupportedFileTypes = strings(1, 0); + fileAdapterList(1).DataType = ""; end if ~nargout @@ -46,7 +42,7 @@ fileAdapterList.FunctionName = string(fileAdapterList.FunctionName); fileAdapterList.DataType = string(fileAdapterList.DataType); fileFormats = fileAdapterList.SupportedFileTypes; - fileFormats = cellfun(@(c) string(strjoin(c, ', ')), fileFormats); + fileFormats = cellfun(@(c) strjoin(string(c), ', '), fileFormats); fileAdapterList.SupportedFileTypes = fileFormats; end end diff --git a/code/+nansen/+metadata/+type/Session.m b/code/+nansen/+metadata/+type/Session.m index 1ac5aac8d..c4374c13e 100644 --- a/code/+nansen/+metadata/+type/Session.m +++ b/code/+nansen/+metadata/+type/Session.m @@ -692,7 +692,7 @@ function updateSessionFolder(obj, dataLocationName, folderPath) %#ok end % Get file adapter % Todo: make this more persistent... - isMatch = strcmp({fileAdapterList.FileAdapterName}, variableInfo.FileAdapter); + isMatch = [fileAdapterList.FileAdapterName] == string(variableInfo.FileAdapter); if ~any(isMatch) error('NANSEN:Session:FileAdapterNotFound', 'File adapter was not found') diff --git a/code/+nansen/+module/Module.m b/code/+nansen/+module/Module.m index a04cdf335..8cdc0c34a 100644 --- a/code/+nansen/+module/Module.m +++ b/code/+nansen/+module/Module.m @@ -143,6 +143,11 @@ itemType = validatestring(itemType, {'SessionMethod', ... 'TableVariable', 'FileAdapter', 'DataVariables', ... 'DataLocations'}, 1); + if strcmp(itemType, 'FileAdapter') + warning('NANSEN:DeprecatedPath:ModuleFileAdapters', ... + ['Module.getTable(''FileAdapter'') is deprecated. ', ... + 'Use project.FileAdapterRegistry.list() instead.']) + end itemTable = obj.rehash(itemType, forceRefresh); end @@ -163,6 +168,9 @@ end function fileAdapterList = get.FileAdapters(obj) + warning('NANSEN:DeprecatedPath:ModuleFileAdapters', ... + ['Module.FileAdapters is deprecated. ', ... + 'Use project.FileAdapterRegistry.list() instead.']) itemTable = obj.rehash('FileAdapter'); fileAdapterList = itemTable.FileAdapterName; fileAdapterList = string(fileAdapterList)'; diff --git a/code/+nansen/+plugin/+base/PluginEventData.m b/code/+nansen/+plugin/+base/PluginEventData.m new file mode 100644 index 000000000..b8a177f9c --- /dev/null +++ b/code/+nansen/+plugin/+base/PluginEventData.m @@ -0,0 +1,23 @@ +classdef PluginEventData < event.EventData +%PluginEventData Payload for registry plugin events. +% +% Carries the affected entry (for PluginAdded and PluginChanged events) +% and the plugin ID (for PluginRemoved events). Either field may be +% empty depending on the event type. + + properties (SetAccess = immutable) + Entry % Struct with parsed adapter fields (Added / Changed events) + PluginId (1,1) string = "" % Adapter name id (Removed events) + end + + methods + function obj = PluginEventData(entry, pluginId) + arguments + entry struct = struct.empty + pluginId (1,1) string = "" + end + obj.Entry = entry; + obj.PluginId = pluginId; + end + end +end diff --git a/code/+nansen/+plugin/+base/Registry.m b/code/+nansen/+plugin/+base/Registry.m new file mode 100644 index 000000000..3be56705a --- /dev/null +++ b/code/+nansen/+plugin/+base/Registry.m @@ -0,0 +1,251 @@ +classdef (Abstract) Registry < handle +%Registry Abstract base class for project-owned plugin registries. +% +% Stores plugin entries from multiple sources with three precedence tiers: +% - Top (single pinned slot, highest priority) +% - Middle (ordered list of sources, appended with addSource) +% - Bottom (single pinned slot, lowest priority) +% +% Subclasses implement scanSource (what to parse) and primaryId (which +% field of the returned entry struct is the unique identifier). +% +% Usage: +% reg = MyConcreteRegistry(); +% reg.pinBottom("core", "/path/to/core"); +% reg.addSource("mymod", "/path/to/module"); +% reg.pinTop("project", "/path/to/project"); +% reg.reconcile(); +% entries = reg.list(); + + events + PluginAdded + PluginRemoved + PluginChanged + PluginRegistryReconciled + end + + properties (Access = private) + % Three-tier source storage + TopSource struct % scalar or empty struct with fields: id, path + MiddleSources struct % struct array with fields: id, path + BottomSource struct % scalar or empty struct with fields: id, path + + % Resolved entry list after last reconcile (struct array, subclass-defined fields) + Entries struct + end + + methods % Constructor + function obj = Registry() + obj.TopSource = struct('id', {}, 'path', {}); + obj.MiddleSources = struct('id', {}, 'path', {}); + obj.BottomSource = struct('id', {}, 'path', {}); + obj.Entries = struct.empty; + end + end + + methods % Source management API + function pinTop(obj, id, path) + %pinTop Set or replace the top-priority source slot. + % + % pinTop(id, path) assigns the top slot. If a different source is + % already pinned at the top it is silently replaced. + arguments + obj + id (1,1) string + path (1,1) string + end + obj.TopSource = struct('id', id, 'path', path); + end + + function pinBottom(obj, id, path) + %pinBottom Set or replace the bottom-priority source slot. + % + % pinBottom(id, path) assigns the bottom slot. If a different source + % is already pinned at the bottom it is silently replaced. + arguments + obj + id (1,1) string + path (1,1) string + end + obj.BottomSource = struct('id', id, 'path', path); + end + + function addSource(obj, id, path) + %addSource Append a source to the middle tier. + % + % Errors if the id is already present in any slot. + arguments + obj + id (1,1) string + path (1,1) string + end + if obj.hasSourceId(id) + error('NANSEN:Registry:DuplicateSourceId', ... + 'A source with id "%s" is already registered.', id) + end + obj.MiddleSources(end+1) = struct('id', id, 'path', path); + end + + function removeSource(obj, id) + %removeSource Remove a source by id from any slot. + % + % Silently ignores unknown ids. + arguments + obj + id (1,1) string + end + if ~isempty(obj.TopSource) && obj.TopSource.id == id + obj.TopSource = struct('id', {}, 'path', {}); return + end + if ~isempty(obj.BottomSource) && obj.BottomSource.id == id + obj.BottomSource = struct('id', {}, 'path', {}); return + end + if ~isempty(obj.MiddleSources) + keep = [obj.MiddleSources.id] ~= id; + obj.MiddleSources = obj.MiddleSources(keep); + end + end + + function reconcile(obj) + %reconcile Rescan all sources, diff against current entries, emit events. + % + % Fires PluginAdded, PluginRemoved, or PluginChanged for each + % changed entry, then fires PluginRegistryReconciled once at the end. + sources = obj.orderedSources(); + newEntries = obj.buildEntries(sources); + + oldIds = obj.entryIds(obj.Entries); + newIds = obj.entryIds(newEntries); + + removedIds = setdiff(oldIds, newIds); + for i = 1:numel(removedIds) + evtData = nansen.plugin.base.PluginEventData( ... + struct.empty, removedIds(i)); + notify(obj, 'PluginRemoved', evtData); + end + + addedIds = setdiff(newIds, oldIds); + for i = 1:numel(addedIds) + entry = obj.findEntry(newEntries, addedIds(i)); + evtData = nansen.plugin.base.PluginEventData(entry, addedIds(i)); + notify(obj, 'PluginAdded', evtData); + end + + sharedIds = intersect(oldIds, newIds); + for i = 1:numel(sharedIds) + oldEntry = obj.findEntry(obj.Entries, sharedIds(i)); + newEntry = obj.findEntry(newEntries, sharedIds(i)); + if ~isequal(oldEntry, newEntry) + evtData = nansen.plugin.base.PluginEventData(newEntry, sharedIds(i)); + notify(obj, 'PluginChanged', evtData); + end + end + + obj.Entries = newEntries; + notify(obj, 'PluginRegistryReconciled'); + end + end + + methods % Read API + function entries = list(obj) + %list Return the resolved entry struct array. + entries = obj.Entries; + end + + function entry = get(obj, id) + %get Return the entry with the given id, or empty struct if not found. + entry = obj.findEntry(obj.Entries, id); + end + + function entry = resolve(obj, id) + %resolve Return the highest-priority entry with the given id. + % + % Equivalent to get() for registries that already deduplicate by id + % during reconcile. + entry = obj.get(id); + end + end + + methods (Abstract, Access = protected) + entries = scanSource(obj, sourceId, sourcePath) + %scanSource Scan one source directory and return its entries. + % + % Subclass defines the entry struct fields and parsing logic. + + id = primaryId(obj, entry) + %primaryId Return the unique id string for an entry. + % + % Subclass returns the field of the entry struct that uniquely + % identifies the plugin (e.g. string(entry.FileAdapterName)). + end + + methods (Access = private) + function sources = orderedSources(obj) + %orderedSources Return all sources in precedence order (top > middle > bottom). + sources = [obj.TopSource, obj.MiddleSources, obj.BottomSource]; + end + + function allEntries = buildEntries(obj, sources) + %buildEntries Scan all sources and merge with precedence dedup. + allEntries = []; % Becomes a struct array on first assignment + seenIds = string.empty; + + for i = 1:numel(sources) + raw = obj.scanSource(sources(i).id, sources(i).path); + if isempty(raw); continue; end + + for j = 1:numel(raw) + entryId = obj.primaryId(raw(j)); + if ~ismember(entryId, seenIds) + seenIds(end+1) = entryId; %#ok + if isempty(allEntries) + allEntries = raw(j); + else + allEntries(end+1) = raw(j); %#ok + end + end + % Higher-priority (earlier) source wins; lower-priority entries + % are silently dropped. + end + end + + if isempty(allEntries) + allEntries = struct.empty; + end + end + + function tf = hasSourceId(obj, id) + %hasSourceId Return true if id is already registered in any slot. + tf = false; + if ~isempty(obj.TopSource) && obj.TopSource.id == id + tf = true; return + end + if ~isempty(obj.BottomSource) && obj.BottomSource.id == id + tf = true; return + end + if ~isempty(obj.MiddleSources) && any([obj.MiddleSources.id] == id) + tf = true; + end + end + + function ids = entryIds(obj, entries) + %entryIds Return a string array of all entry ids. + if isempty(entries) + ids = string.empty; + else + ids = arrayfun(@(e) obj.primaryId(e), entries); + end + end + + function entry = findEntry(obj, entries, id) + %findEntry Find an entry by id; returns empty struct if not found. + entry = struct.empty; + if isempty(entries); return; end + ids = obj.entryIds(entries); + idx = find(ids == id, 1); + if ~isempty(idx) + entry = entries(idx); + end + end + end +end diff --git a/code/+nansen/+plugin/+fileadapter/+internal/detectFileAdapterForFilepath.m b/code/+nansen/+plugin/+fileadapter/+internal/detectFileAdapterForFilepath.m index 6a17646a3..01aff9992 100644 --- a/code/+nansen/+plugin/+fileadapter/+internal/detectFileAdapterForFilepath.m +++ b/code/+nansen/+plugin/+fileadapter/+internal/detectFileAdapterForFilepath.m @@ -19,15 +19,8 @@ if isempty(fileAdapterList) error('No file adapters exist that can open files of type "%s"', fileExtension) - elseif numel(fileAdapterList) > 1 - fileAdapterNames = {fileAdapterList.FunctionName}; - fileAdapterNames = nansen.util.text.strArrayToBulletList(fileAdapterNames); - - % warning(... - % ['Multiple matching file adapters: \n%s\n Using first one. ', ... - % 'To use another file adapter, please specify the file ', ... - % 'adapter using the "FileAdapter" input'], fileAdapterNames) end + fileAdapterName = fileAdapterList(1).FunctionName; isDynamic = fileAdapterList(1).IsDynamic; end diff --git a/code/+nansen/+plugin/+fileadapter/Registry.m b/code/+nansen/+plugin/+fileadapter/Registry.m new file mode 100644 index 000000000..a06b8f7a6 --- /dev/null +++ b/code/+nansen/+plugin/+fileadapter/Registry.m @@ -0,0 +1,98 @@ +classdef Registry < nansen.plugin.base.Registry +%Registry Project-owned registry for FileAdapter plugins. +% +% Scans +fileadapter/ subfolders of registered sources and provides the +% same struct-array shape that nansen.dataio.listFileAdapters previously +% returned via Module.getTable. +% +% Fields per entry: +% FileAdapterName - Class name (string) +% FunctionName - Full MATLAB function/package name (string) +% SupportedFileTypes - Supported extensions (string array) +% DataType - Data type returned on load (string) +% IsDynamic - true for sidecar-based, false for class-based +% +% See also: nansen.plugin.base.Registry, nansen.dataio.listFileAdapters + + methods % Public list extension + function entries = list(obj, fileExtension) + %list Return the struct array, optionally filtered by file extension. + % + % entries = list() returns all adapters. + % entries = list(fileExtension) filters to adapters that support the + % given extension (with or without a leading dot). + arguments + obj + fileExtension (1,1) string = "" + end + entries = list@nansen.plugin.base.Registry(obj); + + if fileExtension == "" || isempty(entries) + return + end + + ext = strrep(fileExtension, ".", ""); + matchFcn = @(extList) any(contains(extList, ext, "IgnoreCase", true)); + keep = arrayfun(@(s) matchFcn(s.SupportedFileTypes), entries); + entries = entries(keep); + end + end + + methods (Access = protected) + function id = primaryId(~, entry) + %primaryId Return the FileAdapterName as the unique entry id. + id = string(entry.FileAdapterName); + end + + function entries = scanSource(obj, sourceId, sourcePath) %#ok + %scanSource Scan a +fileadapter/ subfolder for adapters. + % + % Returns a struct array with the five standard adapter fields. + % Returns an empty struct with those fields if the source has no + % +fileadapter/ subfolder. + emptyEntry = struct( ... + 'FileAdapterName', {}, ... + 'FunctionName', {}, ... + 'SupportedFileTypes', {}, ... + 'DataType', {}, ... + 'IsDynamic', {}); + + fileadapterRoot = fullfile(sourcePath, '+fileadapter'); + if ~isfolder(fileadapterRoot) + entries = emptyEntry; + return + end + + mFiles = nansen.module.Module.listMFiles( ... + fileadapterRoot, ["read.m", "write.m", "view.m"]); + jsonFiles = nansen.module.Module.listJsonFiles( ... + fileadapterRoot, 'fileadapter'); + fileList = [mFiles; jsonFiles]; + + if isempty(fileList) + entries = emptyEntry; + return + end + + tbl = nansen.dataio.FileAdapter.buildFileAdapterTable(fileList); + if isempty(tbl) + entries = emptyEntry; + else + entries = table2struct(tbl); + entries = obj.normalizeEntries(entries); + end + end + end + + methods (Access = private) + function entries = normalizeEntries(~, entries) + %normalizeEntries Keep file adapter metadata text fields as strings. + for i = 1:numel(entries) + entries(i).FileAdapterName = string(entries(i).FileAdapterName); + entries(i).FunctionName = string(entries(i).FunctionName); + entries(i).SupportedFileTypes = string(entries(i).SupportedFileTypes); + entries(i).DataType = string(entries(i).DataType); + end + end + end +end diff --git a/code/+nansen/+plugin/editFileAdapter.m b/code/+nansen/+plugin/editFileAdapter.m index 417b51de7..d0a8eda51 100644 --- a/code/+nansen/+plugin/editFileAdapter.m +++ b/code/+nansen/+plugin/editFileAdapter.m @@ -7,9 +7,9 @@ function editFileAdapter(fileAdapterName) fileAdapterList = nansen.dataio.listFileAdapters(); if contains(fileAdapterName, 'fileadapter.') - isMatch = strcmp(fileAdapterName, {fileAdapterList.FunctionName}); + isMatch = [fileAdapterList.FunctionName] == fileAdapterName; elseif ~contains(fileAdapterName, '.') - isMatch = strcmp(fileAdapterName, {fileAdapterList.FileAdapterName}); + isMatch = [fileAdapterList.FileAdapterName] == fileAdapterName; else error('"%s" is not the name of a file adapter', fileAdapterName) end diff --git a/code/+nansen/load.m b/code/+nansen/load.m index 8291c7865..b73984eda 100644 --- a/code/+nansen/load.m +++ b/code/+nansen/load.m @@ -14,9 +14,9 @@ else fileAdapterName = options.FileAdapter; if contains(fileAdapterName, 'fileadapter.') - isMatch = strcmp(fileAdapterName, {fileAdapterList.FunctionName}); + isMatch = [fileAdapterList.FunctionName] == fileAdapterName; elseif ~contains(fileAdapterName, '.') - isMatch = strcmp(fileAdapterName, {fileAdapterList.FileAdapterName}); + isMatch = [fileAdapterList.FileAdapterName] == fileAdapterName; fileAdapterName = fileAdapterList(isMatch).FunctionName; else error('"%s" is not the name of a file adapter', fileAdapterName) diff --git a/code/+nansen/save.m b/code/+nansen/save.m index 8ce543bcb..fc429e56f 100644 --- a/code/+nansen/save.m +++ b/code/+nansen/save.m @@ -15,9 +15,9 @@ function save(filePath, data, options) else fileAdapterName = options.FileAdapter; if contains(fileAdapterName, 'fileadapter.') - isMatch = strcmp(fileAdapterName, {fileAdapterList.FunctionName}); + isMatch = [fileAdapterList.FunctionName] == fileAdapterName; elseif ~contains(fileAdapterName, '.') - isMatch = strcmp(fileAdapterName, {fileAdapterList.FileAdapterName}); + isMatch = [fileAdapterList.FileAdapterName] == fileAdapterName; fileAdapterName = fileAdapterList(isMatch).FunctionName; else error('"%s" is not the name of a file adapter', fileAdapterName) diff --git a/code/apps/+nansen/@FileViewer/FileViewer.m b/code/apps/+nansen/@FileViewer/FileViewer.m index 353620ae2..e9a24dab9 100644 --- a/code/apps/+nansen/@FileViewer/FileViewer.m +++ b/code/apps/+nansen/@FileViewer/FileViewer.m @@ -1286,7 +1286,7 @@ function onViewFileAdapterItemClicked(obj, src, filePath) fileAdapterName = src.Text; fileAdapterList = nansen.dataio.listFileAdapters(); - isMatch = strcmp({fileAdapterList.FileAdapterName}, fileAdapterName); + isMatch = [fileAdapterList.FileAdapterName] == string(fileAdapterName); edit( sprintf('%s/read', fileAdapterList(isMatch).FunctionName) ); end diff --git a/tests/+nansen/+unittest/+plugin/+helper/ConcreteRegistry.m b/tests/+nansen/+unittest/+plugin/+helper/ConcreteRegistry.m new file mode 100644 index 000000000..34b1d02fa --- /dev/null +++ b/tests/+nansen/+unittest/+plugin/+helper/ConcreteRegistry.m @@ -0,0 +1,23 @@ +classdef ConcreteRegistry < nansen.plugin.base.Registry +%ConcreteRegistry Test double for nansen.plugin.base.Registry. +% +% scanSource reads a plugins.mat file from the given directory. Each +% element in the 'plugins' variable must be a struct with at least a +% 'PluginId' field. The PluginId is used as the primary identifier. + + methods (Access = protected) + function id = primaryId(~, entry) + id = string(entry.PluginId); + end + + function entries = scanSource(~, ~, sourcePath) + matFile = fullfile(sourcePath, 'plugins.mat'); + if ~isfile(matFile) + entries = struct('PluginId', {}, 'Source', {}); + return + end + loaded = load(matFile, 'plugins'); + entries = loaded.plugins; + end + end +end diff --git a/tests/+nansen/+unittest/+plugin/FileAdapterRegistryTest.m b/tests/+nansen/+unittest/+plugin/FileAdapterRegistryTest.m new file mode 100644 index 000000000..d141b79ae --- /dev/null +++ b/tests/+nansen/+unittest/+plugin/FileAdapterRegistryTest.m @@ -0,0 +1,171 @@ +classdef FileAdapterRegistryTest < matlab.unittest.TestCase +%FileAdapterRegistryTest Tests for nansen.plugin.fileadapter.Registry. +% +% Uses temporary directories so no Project object is needed. Verifies +% that the registry is project-agnostic and that class-based and +% sidecar-based adapters are discovered correctly. + + properties + TempDir (1,1) string + end + + methods (TestMethodSetup) + function createTempDir(testCase) + testCase.TempDir = string(tempname()); + mkdir(testCase.TempDir) + testCase.addTeardown(@() rmdir(testCase.TempDir, 's')) + end + end + + methods (Test) + + function testSourceWithNoFileadapterFolderIsEmpty(testCase) + reg = nansen.plugin.fileadapter.Registry(); + emptyDir = fullfile(testCase.TempDir, 'empty_source'); + mkdir(emptyDir) + + reg.pinBottom("empty", emptyDir); + reg.reconcile(); + + testCase.verifyEmpty(reg.list()) + end + + function testRegistryIsProjectAgnostic(testCase) + % The registry can be constructed and used without any Project. + reg = nansen.plugin.fileadapter.Registry(); + dirA = testCase.makeSidecarSource('sourceA', 'TestSidecar', 'mat'); + reg.pinBottom("src-a", dirA); + reg.reconcile(); + + entries = reg.list(); + testCase.verifyNotEmpty(entries) + testCase.verifyTrue(any([entries.FileAdapterName] == "TestSidecar")) + end + + function testSidecarAdapterDiscovery(testCase) + reg = nansen.plugin.fileadapter.Registry(); + srcDir = testCase.makeSidecarSource('src', 'MyAdapter', 'csv'); + reg.pinBottom("src", srcDir); + reg.reconcile(); + + entries = reg.list(); + testCase.verifyNumElements(entries, 1) + testCase.verifyEqual(entries.FileAdapterName, "MyAdapter") + testCase.verifyTrue(entries.IsDynamic) + end + + function testFilterByFileExtension(testCase) + reg = nansen.plugin.fileadapter.Registry(); + srcDir = fullfile(testCase.TempDir, 'multi'); + testCase.makeSidecarSourceInDir(srcDir, 'TxtAdapter', 'txt'); + testCase.makeSidecarSourceInDir(srcDir, 'CsvAdapter', 'csv'); + reg.pinBottom("src", srcDir); + reg.reconcile(); + + csvEntries = reg.list("csv"); + testCase.verifyNumElements(csvEntries, 1) + testCase.verifyEqual(csvEntries.FileAdapterName, "CsvAdapter") + end + + function testFilterByExtensionWithDot(testCase) + reg = nansen.plugin.fileadapter.Registry(); + srcDir = testCase.makeSidecarSource('src', 'MatAdapter', 'mat'); + reg.pinBottom("src", srcDir); + reg.reconcile(); + + withDot = reg.list(".mat"); + withoutDot = reg.list("mat"); + testCase.verifyEqual([withDot.FileAdapterName], [withoutDot.FileAdapterName]) + end + + function testSourceAddAndRemoveDynamic(testCase) + reg = nansen.plugin.fileadapter.Registry(); + srcA = testCase.makeSidecarSource('A', 'AdapterA', 'h5'); + srcB = testCase.makeSidecarSource('B', 'AdapterB', 'npy'); + + reg.addSource("src-a", srcA); + reg.reconcile(); + testCase.verifyNumElements(reg.list(), 1) + + reg.addSource("src-b", srcB); + reg.reconcile(); + testCase.verifyNumElements(reg.list(), 2) + + reg.removeSource("src-a"); + reg.reconcile(); + entries = reg.list(); + testCase.verifyNumElements(entries, 1) + testCase.verifyEqual(entries.FileAdapterName, "AdapterB") + end + + function testTopSourceWinsOnConflict(testCase) + reg = nansen.plugin.fileadapter.Registry(); + srcBottom = testCase.makeSidecarSource('Bottom', 'SharedName', 'tif'); + srcTop = testCase.makeSidecarSource('Top', 'SharedName', 'tiff'); + + reg.pinBottom("bottom", srcBottom); + reg.pinTop("top", srcTop); + reg.reconcile(); + + entries = reg.list(); + testCase.verifyNumElements(entries, 1) + % Top source should win; its supported file type is '.tiff' + testCase.verifyEqual(entries.SupportedFileTypes, ".tiff") + end + + function testModuleDeprecationWarning(testCase) + module = nansen.module.Module.fromName( ... + nansen.common.constant.BaseModuleName); + testCase.verifyWarning( ... + @() module.FileAdapters, ... + 'NANSEN:DeprecatedPath:ModuleFileAdapters') + end + + function testGetTableFileAdapterDeprecationWarning(testCase) + module = nansen.module.Module.fromName( ... + nansen.common.constant.BaseModuleName); + testCase.verifyWarning( ... + @() module.getTable('FileAdapter'), ... + 'NANSEN:DeprecatedPath:ModuleFileAdapters') + end + end + + methods (Access = private) + function srcDir = makeSidecarSource(testCase, name, adapterName, ext) + %makeSidecarSource Create a temp source dir with one sidecar adapter. + srcDir = fullfile(testCase.TempDir, name); + testCase.makeSidecarSourceInDir(srcDir, adapterName, ext); + end + + function makeSidecarSourceInDir(~, srcDir, adapterName, ext) + %makeSidecarSourceInDir Write a fileadapter.json sidecar into srcDir. + % + % Writes the JSON with a "_type" field (dot-prefixed extension), + % matching the format the parser expects. + faDir = fullfile(srcDir, '+fileadapter', ['+', adapterName]); + mkdir(faDir) + + % Build JSON manually so the field is literally "_type" + % (jsonencode would produce "x_type" from a struct, which also + % works after jsondecode, but this matches the canonical format). + jsonStr = sprintf( ... + ['{\n', ... + ' "_type": "FileAdapter",\n', ... + ' "_version": "1.0.0",\n', ... + ' "Properties": {\n', ... + ' "Name": "%s",\n', ... + ' "Description": "",\n', ... + ' "SupportedFileTypes": [".%s"],\n', ... + ' "FileExpression": "",\n', ... + ' "DataType": "generic",\n', ... + ' "IsGeneral": false,\n', ... + ' "ReadFunction": "",\n', ... + ' "WriteFunction": "",\n', ... + ' "ViewFunction": ""\n', ... + ' }\n', ... + '}'], adapterName, ext); + + utility.filewrite(fullfile(faDir, 'fileadapter.json'), jsonStr) + end + end +end diff --git a/tests/+nansen/+unittest/+plugin/RegistryBaseTest.m b/tests/+nansen/+unittest/+plugin/RegistryBaseTest.m new file mode 100644 index 000000000..1c78e8777 --- /dev/null +++ b/tests/+nansen/+unittest/+plugin/RegistryBaseTest.m @@ -0,0 +1,236 @@ +classdef RegistryBaseTest < matlab.unittest.TestCase +%RegistryBaseTest Unit tests for nansen.plugin.base.Registry. +% +% Tests source pinning, middle-tier ordering, removeSource from any slot, +% reconcile event emission, and precedence-based dedup. + + properties + TempDir (1,1) string % Temporary directory root for test sources + end + + methods (TestMethodSetup) + function createTempDir(testCase) + testCase.TempDir = string(tempname()); + mkdir(testCase.TempDir) + testCase.addTeardown(@() rmdir(testCase.TempDir, 's')) + end + end + + methods (Test) + + function testEmptyRegistryListReturnsEmpty(testCase) + reg = nansen.unittest.plugin.helper.ConcreteRegistry(); + entries = reg.list(); + testCase.verifyEmpty(entries) + end + + function testPinTopSingleSlot(testCase) + reg = nansen.unittest.plugin.helper.ConcreteRegistry(); + dirA = testCase.makeSourceDir('A', {'Alpha'}); + dirB = testCase.makeSourceDir('B', {'Beta'}); + + reg.pinTop("src-a", dirA); + reg.reconcile(); + testCase.verifyEqual([reg.list().PluginId], "Alpha") + + % Replacing the top slot should switch to the new source + reg.pinTop("src-b", dirB); + reg.reconcile(); + testCase.verifyEqual([reg.list().PluginId], "Beta") + end + + function testPinBottomSingleSlot(testCase) + reg = nansen.unittest.plugin.helper.ConcreteRegistry(); + dirA = testCase.makeSourceDir('A', {'Alpha'}); + dirB = testCase.makeSourceDir('B', {'Beta'}); + + reg.pinBottom("src-a", dirA); + reg.reconcile(); + testCase.verifyEqual([reg.list().PluginId], "Alpha") + + reg.pinBottom("src-b", dirB); + reg.reconcile(); + testCase.verifyEqual([reg.list().PluginId], "Beta") + end + + function testMiddleTierOrderedAppend(testCase) + reg = nansen.unittest.plugin.helper.ConcreteRegistry(); + dirA = testCase.makeSourceDir('A', {'Alpha'}); + dirB = testCase.makeSourceDir('B', {'Beta'}); + dirC = testCase.makeSourceDir('C', {'Gamma'}); + + reg.addSource("src-a", dirA); + reg.addSource("src-b", dirB); + reg.addSource("src-c", dirC); + reg.reconcile(); + + ids = [reg.list().PluginId]; + testCase.verifyEqual(ids, ["Alpha", "Beta", "Gamma"]) + end + + function testAddSourceErrorsOnDuplicateId(testCase) + reg = nansen.unittest.plugin.helper.ConcreteRegistry(); + dir1 = testCase.makeSourceDir('X1', {'P1'}); + dir2 = testCase.makeSourceDir('X2', {'P2'}); + reg.addSource("dup-id", dir1); + testCase.verifyError(@() reg.addSource("dup-id", dir2), ... + 'NANSEN:Registry:DuplicateSourceId') + end + + function testRemoveSourceFromMiddle(testCase) + reg = nansen.unittest.plugin.helper.ConcreteRegistry(); + dirA = testCase.makeSourceDir('A', {'Alpha'}); + dirB = testCase.makeSourceDir('B', {'Beta'}); + reg.addSource("src-a", dirA); + reg.addSource("src-b", dirB); + reg.reconcile(); + + reg.removeSource("src-a"); + reg.reconcile(); + ids = [reg.list().PluginId]; + testCase.verifyEqual(ids, "Beta") + end + + function testRemoveSourceFromTop(testCase) + reg = nansen.unittest.plugin.helper.ConcreteRegistry(); + dirA = testCase.makeSourceDir('A', {'Alpha'}); + reg.pinTop("top", dirA); + reg.reconcile(); + testCase.verifyNumElements(reg.list(), 1) + + reg.removeSource("top"); + reg.reconcile(); + testCase.verifyEmpty(reg.list()) + end + + function testRemoveSourceFromBottom(testCase) + reg = nansen.unittest.plugin.helper.ConcreteRegistry(); + dirB = testCase.makeSourceDir('B', {'Beta'}); + reg.pinBottom("bottom", dirB); + reg.reconcile(); + testCase.verifyNumElements(reg.list(), 1) + + reg.removeSource("bottom"); + reg.reconcile(); + testCase.verifyEmpty(reg.list()) + end + + function testRemoveUnknownSourceIsNoop(testCase) + reg = nansen.unittest.plugin.helper.ConcreteRegistry(); + dirA = testCase.makeSourceDir('A', {'Alpha'}); + reg.addSource("src-a", dirA); + reg.reconcile(); + + % Should not error + reg.removeSource("nonexistent"); + reg.reconcile(); + testCase.verifyNumElements(reg.list(), 1) + end + + function testPrecedenceTopWinsOverMiddleAndBottom(testCase) + reg = nansen.unittest.plugin.helper.ConcreteRegistry(); + + % Same plugin id 'Alpha' in all three tiers + dirTop = testCase.makeSourceDir('Top', {'Alpha'}, struct('Source', 'top')); + dirMiddle = testCase.makeSourceDir('Middle', {'Alpha'}, struct('Source', 'middle')); + dirBottom = testCase.makeSourceDir('Bottom', {'Alpha'}, struct('Source', 'bottom')); + + reg.pinBottom("bottom", dirBottom); + reg.addSource("middle", dirMiddle); + reg.pinTop("top", dirTop); + reg.reconcile(); + + entries = reg.list(); + testCase.verifyNumElements(entries, 1) + testCase.verifyEqual(entries.Source, "top") + end + + function testReconcileEmitsPluginAddedOnNew(testCase) + reg = nansen.unittest.plugin.helper.ConcreteRegistry(); + dirA = testCase.makeSourceDir('A', {'Alpha'}); + + addedCount = 0; + addlistener(reg, 'PluginAdded', @(~,~) incr()); + + reg.addSource("src-a", dirA); + reg.reconcile(); + testCase.verifyEqual(addedCount, 1) + + function incr(); addedCount = addedCount + 1; end + end + + function testReconcileEmitsPluginRemovedOnMissing(testCase) + reg = nansen.unittest.plugin.helper.ConcreteRegistry(); + dirA = testCase.makeSourceDir('A', {'Alpha'}); + + removedCount = 0; + addlistener(reg, 'PluginRemoved', @(~,~) incr()); + + reg.addSource("src-a", dirA); + reg.reconcile(); + + reg.removeSource("src-a"); + reg.reconcile(); + testCase.verifyEqual(removedCount, 1) + + function incr(); removedCount = removedCount + 1; end + end + + function testReconcileEmitsPluginRegistryReconciled(testCase) + reg = nansen.unittest.plugin.helper.ConcreteRegistry(); + reconciledCount = 0; + addlistener(reg, 'PluginRegistryReconciled', @(~,~) incr()); + + reg.reconcile(); + reg.reconcile(); + testCase.verifyEqual(reconciledCount, 2) + + function incr(); reconciledCount = reconciledCount + 1; end + end + + function testGetReturnsCorrectEntry(testCase) + reg = nansen.unittest.plugin.helper.ConcreteRegistry(); + dirA = testCase.makeSourceDir('A', {'Alpha', 'Beta'}); + reg.addSource("src-a", dirA); + reg.reconcile(); + + alpha = reg.get("Alpha"); + testCase.verifyEqual(alpha.PluginId, "Alpha") + end + + function testGetReturnsEmptyForUnknownId(testCase) + reg = nansen.unittest.plugin.helper.ConcreteRegistry(); + entry = reg.get("DoesNotExist"); + testCase.verifyEmpty(entry) + end + end + + methods (Access = private) + function dirPath = makeSourceDir(testCase, name, pluginIds, extraFields) + %makeSourceDir Create a temp directory that scanSource will read. + % + % The ConcreteRegistry's scanSource reads a plugins.mat file written + % by this helper. + if nargin < 4 + extraFields = struct(); + end + dirPath = fullfile(testCase.TempDir, name); + mkdir(dirPath) + + plugins = struct('PluginId', {}, 'Source', {}); + for i = 1:numel(pluginIds) + s = struct('PluginId', string(pluginIds{i}), 'Source', string(name)); + fields = fieldnames(extraFields); + for f = 1:numel(fields) + fieldValue = extraFields.(fields{f}); + if ischar(fieldValue) || isstring(fieldValue) + fieldValue = string(fieldValue); + end + s.(fields{f}) = fieldValue; + end + plugins(end+1) = s; %#ok + end + save(fullfile(dirPath, 'plugins.mat'), 'plugins') + end + end +end