diff --git a/+io/+internal/+h5/deleteAttribute.m b/+io/+internal/+h5/deleteAttribute.m new file mode 100644 index 00000000..e973929e --- /dev/null +++ b/+io/+internal/+h5/deleteAttribute.m @@ -0,0 +1,20 @@ +function deleteAttribute(fileReference, objectLocation, attributeName) +% deleteAttribute - Delete the specified attribute from an NWB file + + arguments + fileReference {io.internal.h5.mustBeH5FileReference} + objectLocation (1,1) string + attributeName (1,1) string + end + + objectLocation = io.internal.h5.validateLocation(objectLocation); + + % Open the HDF5 file in read-write mode + [fileId, fileCleanupObj] = io.internal.h5.resolveFileReference(fileReference, "w"); %#ok + + % Open the object (dataset or group) + [objectId, objectCleanupObj] = io.internal.h5.openObject(fileId, objectLocation); %#ok + + % Delete the attribute + H5A.delete(objectId, attributeName); +end diff --git a/+io/+internal/+h5/deleteGroup.m b/+io/+internal/+h5/deleteGroup.m new file mode 100644 index 00000000..c6eed426 --- /dev/null +++ b/+io/+internal/+h5/deleteGroup.m @@ -0,0 +1,16 @@ +function deleteGroup(fileReference, groupLocation) +% deleteGroup - Delete the specified group from an NWB file + + arguments + fileReference {io.internal.h5.mustBeH5FileReference} + groupLocation (1,1) string + end + + groupLocation = io.internal.h5.validateLocation(groupLocation); + + % Open the HDF5 file in read-write mode + [fileId, fileCleanupObj] = io.internal.h5.resolveFileReference(fileReference, "w"); %#ok + + % Delete the group + H5L.delete(fileId, groupLocation, 'H5P_DEFAULT'); +end diff --git a/+io/+internal/+h5/listGroupNames.m b/+io/+internal/+h5/listGroupNames.m new file mode 100644 index 00000000..12b6048a --- /dev/null +++ b/+io/+internal/+h5/listGroupNames.m @@ -0,0 +1,28 @@ +function groupNames = listGroupNames(fileReference, h5Location) + + arguments + fileReference {io.internal.h5.mustBeH5FileReference} + h5Location (1,1) string + end + + [fileId, fileCleanupObj] = io.internal.h5.resolveFileReference(fileReference); %#ok + + % Open the specified location (group) + [groupId, groupCleanupObj] = io.internal.h5.openGroup(fileId, h5Location); %#ok + + % Use H5L.iterate to iterate over the links + [~, ~, groupNames] = H5L.iterate(... + groupId, "H5_INDEX_NAME", "H5_ITER_INC", 0, @collectGroupNames, {}); + + % Define iteration function + function [status, groupNames] = collectGroupNames(groupId, name, groupNames) + % Only retrieve name of groups + objId = H5O.open(groupId, name, 'H5P_DEFAULT'); + objInfo = H5O.get_info(objId); + if objInfo.type == H5ML.get_constant_value('H5O_TYPE_GROUP') + groupNames{end+1} = name; + end + H5O.close(objId); + status = 0; % Continue iteration + end +end diff --git a/+io/+internal/+h5/mustBeH5File.m b/+io/+internal/+h5/mustBeH5File.m new file mode 100644 index 00000000..25794de9 --- /dev/null +++ b/+io/+internal/+h5/mustBeH5File.m @@ -0,0 +1,17 @@ +function mustBeH5File(value) + arguments + value {mustBeFile} + end + + VALID_FILE_ENDING = ["h5", "nwb"]; + validExtensions = "." + VALID_FILE_ENDING; + + hasH5Extension = endsWith(value, validExtensions, 'IgnoreCase', true); + + if ~hasH5Extension + exception = MException(... + 'MatNWB:validators:mustBeH5File', ... + 'Expected file "%s" to have .h5 or .nwb file extension', value); + throwAsCaller(exception) + end +end diff --git a/+io/+internal/+h5/mustBeH5FileReference.m b/+io/+internal/+h5/mustBeH5FileReference.m new file mode 100644 index 00000000..ec3f5dd3 --- /dev/null +++ b/+io/+internal/+h5/mustBeH5FileReference.m @@ -0,0 +1,15 @@ +function mustBeH5FileReference(value) + arguments + value {mustBeA(value, ["char", "string", "H5ML.id"])} + end + + if isa(value, "char") || isa(value, "string") + try + io.internal.h5.mustBeH5File(value) + catch ME + throwAsCaller(ME) + end + else + % value is a H5ML.id, ok! + end +end diff --git a/+io/+internal/+h5/openFile.m b/+io/+internal/+h5/openFile.m new file mode 100644 index 00000000..04f212b9 --- /dev/null +++ b/+io/+internal/+h5/openFile.m @@ -0,0 +1,46 @@ +function [fileId, fileCleanupObj] = openFile(fileName, permission) +% openFile Opens an HDF5 file with the specified permissions and ensures cleanup. +% +% [fileId, fileCleanupObj] = io.internal.h5.openFile(fileName) opens the HDF5 +% file specified by fileName in read-only mode ('r') by default. +% +% [fileId, fileCleanupObj] = io.internal.h5.openFile(fileName, permission) +% opens the HDF5 file specified by fileName with the access mode defined by +% permission. +% +% Input Arguments: +% fileName - A string or character vector specifying the path to the +% HDF5 file. This must be a .h5 or .nwb file. +% +% permission - (Optional) A scalar string specifying the file access mode. +% Valid values are "r" for read-only (default) and "w" for +% read-write. +% +% Output Arguments: +% fileId - The file identifier returned by H5F.open, used to +% reference the open file. +% +% fileCleanupObj - A cleanup object (onCleanup) that ensures the file is +% closed automatically when fileCleanupObj goes out of +% scope. +% +% Example: +% [fid, cleanupObj] = io.internal.h5.openFile("data.h5", "w"); +% % Use fid for file operations. +% % When cleanupObj is cleared or goes out of scope, the file is +% % automatically closed. + + arguments + fileName {io.internal.h5.mustBeH5File} + permission (1,1) string {mustBeMember(permission, ["r", "w"])} = "r" + end + + switch permission + case "r" + accessFlag = 'H5F_ACC_RDONLY'; + case "w" + accessFlag = 'H5F_ACC_RDWR'; + end + fileId = H5F.open(fileName, accessFlag, 'H5P_DEFAULT'); + fileCleanupObj = onCleanup(@(fid) H5F.close(fileId)); +end diff --git a/+io/+internal/+h5/openGroup.m b/+io/+internal/+h5/openGroup.m new file mode 100644 index 00000000..8edc6ad6 --- /dev/null +++ b/+io/+internal/+h5/openGroup.m @@ -0,0 +1,13 @@ +function [groupId, groupCleanupObj] = openGroup(fileId, h5Location) +% openGroup Opens an HDF5 group at given location and ensures cleanup. + + arguments + fileId {mustBeA(fileId, "H5ML.id")} + h5Location (1,1) string + end + + % Open the specified location (group) + groupLocation = io.internal.h5.validateLocation(h5Location); + groupId = H5G.open(fileId, groupLocation); + groupCleanupObj = onCleanup(@(gid) H5G.close(groupId)); +end diff --git a/+io/+internal/+h5/openObject.m b/+io/+internal/+h5/openObject.m new file mode 100644 index 00000000..8f15220b --- /dev/null +++ b/+io/+internal/+h5/openObject.m @@ -0,0 +1,13 @@ +function [objectId, objectCleanupObj] = openObject(fileId, objectLocation) +% openObject Opens an HDF5 object at given location and ensures cleanup. + + arguments + fileId {mustBeA(fileId, "H5ML.id")} + objectLocation (1,1) string + end + + % Open the object (dataset or group) + objectLocation = io.internal.h5.validateLocation(objectLocation); + objectId = H5O.open(fileId, objectLocation, 'H5P_DEFAULT'); + objectCleanupObj = onCleanup(@(oid) H5O.close(objectId)); +end diff --git a/+io/+internal/+h5/resolveFileReference.m b/+io/+internal/+h5/resolveFileReference.m new file mode 100644 index 00000000..58e225b0 --- /dev/null +++ b/+io/+internal/+h5/resolveFileReference.m @@ -0,0 +1,30 @@ +function [h5FileId, fileCleanupObj] = resolveFileReference(fileReference, permission) +% resolveFileReference - Resolve a file reference to a H5 File ID. +% +% Utility method to resolve a file reference, which can be either a +% filepath or a file id for a h5 file. +% +% The returned value will always be a file ID. This allows functions that +% does operations on h5 files to receive either a file path or a file id +% +% Note: If the file reference is a file ID for an open file, the permission +% might be different than the provided/requested permission. + + arguments + fileReference {io.internal.h5.mustBeH5FileReference} + permission (1,1) string {mustBeMember(permission, ["r", "w"])} = "r" + end + + if isa(fileReference, "char") || isa(fileReference, "string") + % Need to open the file + if isfile(fileReference) + [h5FileId, fileCleanupObj] = io.internal.h5.openFile(fileReference, permission); + else + error('File "%s" does not exist', fileReference) + end + else + h5FileId = fileReference; + % If the file is already open, we are not responsible for closing it + fileCleanupObj = []; + end +end diff --git a/+io/+internal/+h5/validateLocation.m b/+io/+internal/+h5/validateLocation.m new file mode 100644 index 00000000..754f1fcb --- /dev/null +++ b/+io/+internal/+h5/validateLocation.m @@ -0,0 +1,9 @@ +function locationName = validateLocation(locationName) + arguments + locationName (1,1) string + end + + if ~startsWith(locationName, "/") + locationName = "/" + locationName; + end +end diff --git a/+io/+spec/listEmbeddedSpecNamespaces.m b/+io/+spec/listEmbeddedSpecNamespaces.m new file mode 100644 index 00000000..9310bc57 --- /dev/null +++ b/+io/+spec/listEmbeddedSpecNamespaces.m @@ -0,0 +1,11 @@ +function namespaceNames = listEmbeddedSpecNamespaces(fileReference) + + arguments + fileReference {io.internal.h5.mustBeH5FileReference} + end + + [fileId, fileCleanupObj] = io.internal.h5.resolveFileReference(fileReference); %#ok + + specLocation = io.spec.internal.readEmbeddedSpecLocation(fileId); + namespaceNames = io.internal.h5.listGroupNames(fileId, specLocation); +end diff --git a/+io/+spec/validateEmbeddedSpecifications.m b/+io/+spec/validateEmbeddedSpecifications.m new file mode 100644 index 00000000..eadd9e90 --- /dev/null +++ b/+io/+spec/validateEmbeddedSpecifications.m @@ -0,0 +1,48 @@ +function validateEmbeddedSpecifications(h5_file_id, expectedNamespaceNames) +% validateEmbeddedSpecifications - Validate the embedded specifications +% +% This function does two things: +% 1) Displays a warning if specifications of expected namespaces +% are not embedded in the file. +% E.g if cached namespaces were cleared prior to export. +% +% 2) Deletes specifications for unused namespaces that are embedded. +% - E.g. If neurodata type from an embedded namespace was removed and the +% file was re-exported + +% NB: Input h5_file_id must point to a file opened with write access + + specLocation = io.spec.internal.readEmbeddedSpecLocation(h5_file_id); + embeddedNamespaceNames = io.internal.h5.listGroupNames(h5_file_id, specLocation); + + checkMissingNamespaces(expectedNamespaceNames, embeddedNamespaceNames) + + unusedNamespaces = checkUnusedNamespaces(... + expectedNamespaceNames, embeddedNamespaceNames); + + if ~isempty(unusedNamespaces) + deleteUnusedNamespaces(h5_file_id, unusedNamespaces, specLocation) + end +end + +function checkMissingNamespaces(expectedNamespaceNames, embeddedNamespaceNames) +% checkMissingNamespaces - Check if any namespace specs are missing from the file + missingNamespaces = setdiff(expectedNamespaceNames, embeddedNamespaceNames); + if ~isempty(missingNamespaces) + missingNamespacesStr = strjoin(" " + string(missingNamespaces), newline); + warning('NWB:validators:MissingEmbeddedNamespace', 'Namespace is missing:\n%s', missingNamespacesStr) + end +end + +function unusedNamespaces = checkUnusedNamespaces(expectedNamespaceNames, embeddedNamespaceNames) +% checkUnusedNamespaces - Check if any namespace specs in the file are unused + unusedNamespaces = setdiff(embeddedNamespaceNames, expectedNamespaceNames); +end + +function deleteUnusedNamespaces(fileId, unusedNamespaces, specRootLocation) + for i = 1:numel(unusedNamespaces) + thisName = unusedNamespaces{i}; + namespaceSpecLocation = strjoin( {specRootLocation, thisName}, '/'); + io.internal.h5.deleteGroup(fileId, namespaceSpecLocation) + end +end diff --git a/+io/+spec/writeEmbeddedSpecifications.m b/+io/+spec/writeEmbeddedSpecifications.m index 23c2e269..2c5396f3 100644 --- a/+io/+spec/writeEmbeddedSpecifications.m +++ b/+io/+spec/writeEmbeddedSpecifications.m @@ -1,4 +1,11 @@ function writeEmbeddedSpecifications(fid, jsonSpecs) +% writeEmbeddedSpecifications - Write schema specifications to an NWB file + + arguments + fid % File id for a h5 file + jsonSpecs % String representation of schema specifications in json format + end + specLocation = io.spec.internal.readEmbeddedSpecLocation(fid); if isempty(specLocation) @@ -37,8 +44,8 @@ function writeEmbeddedSpecifications(fid, jsonSpecs) function versionNames = getVersionNames(namespaceGroupId) [~, ~, versionNames] = H5L.iterate(namespaceGroupId,... 'H5_INDEX_NAME', 'H5_ITER_NATIVE',... - 0, @removeGroups, {}); - function [status, versionNames] = removeGroups(~, name, versionNames) + 0, @appendName, {}); + function [status, versionNames] = appendName(~, name, versionNames) versionNames{end+1} = name; status = 0; end diff --git a/+matnwb/+extension/+internal/buildRepoDownloadUrl.m b/+matnwb/+extension/+internal/buildRepoDownloadUrl.m new file mode 100644 index 00000000..59751f78 --- /dev/null +++ b/+matnwb/+extension/+internal/buildRepoDownloadUrl.m @@ -0,0 +1,25 @@ +function downloadUrl = buildRepoDownloadUrl(repositoryUrl, branchName) +% buildRepoDownloadUrl - Build a download URL for a given repository and branch + arguments + repositoryUrl (1,1) string + branchName (1,1) string + end + + if endsWith(repositoryUrl, '/') + repositoryUrl = extractBefore(repositoryUrl, strlength(repositoryUrl)); + end + + if contains(repositoryUrl, 'github.com') + downloadUrl = sprintf( '%s/archive/refs/heads/%s.zip', repositoryUrl, branchName ); + + elseif contains(repositoryUrl, 'gitlab.com') + repoPathSegments = strsplit(repositoryUrl, '/'); + repoName = repoPathSegments{end}; + downloadUrl = sprintf( '%s/-/archive/%s/%s-%s.zip', ... + repositoryUrl, branchName, repoName, branchName); + + else + error('NWB:BuildRepoDownloadUrl:UnsupportedRepository', ... + 'Expected repository URL to point to a GitHub or a GitLab repository') + end +end diff --git a/+matnwb/+extension/+internal/downloadExtensionRepository.m b/+matnwb/+extension/+internal/downloadExtensionRepository.m new file mode 100644 index 00000000..4c47dd18 --- /dev/null +++ b/+matnwb/+extension/+internal/downloadExtensionRepository.m @@ -0,0 +1,46 @@ +function [wasDownloaded, repoTargetFolder] = downloadExtensionRepository(... + repositoryUrl, repoTargetFolder, extensionName) +% downloadExtensionRepository - Download the repository (source) for an extension +% +% The metadata for a neurodata extension only provides the url to the +% repository containing the extension, not the full download url. This +% function tries to download a zipped version of the repository from +% either the "main" or the "master" branch. +% +% Works for repositories located on GitHub or GitLab +% +% As of Dec. 2024, this approach works for all registered extensions + + arguments + repositoryUrl (1,1) string + repoTargetFolder (1,1) string + extensionName (1,1) string + end + + import matnwb.extension.internal.downloadZippedRepo + import matnwb.extension.internal.buildRepoDownloadUrl + + defaultBranchNames = ["main", "master"]; + + wasDownloaded = false; + for i = 1:2 + try + branchName = defaultBranchNames(i); + downloadUrl = buildRepoDownloadUrl(repositoryUrl, branchName); + repoTargetFolder = downloadZippedRepo(downloadUrl, repoTargetFolder); + wasDownloaded = true; + break + catch ME + if strcmp(ME.identifier, 'MATLAB:webservices:HTTP404StatusCodeError') + continue + elseif strcmp(ME.identifier, 'NWB:BuildRepoDownloadUrl:UnsupportedRepository') + error('NWB:InstallExtension:UnsupportedRepository', ... + ['Extension "%s" is located in an unsupported repository ', ... + '/ source location. \nPlease create an issue on MatNWB''s ', ... + 'github page'], extensionName) + else + rethrow(ME) + end + end + end +end diff --git a/+matnwb/+extension/+internal/downloadZippedRepo.m b/+matnwb/+extension/+internal/downloadZippedRepo.m new file mode 100644 index 00000000..1d3acd94 --- /dev/null +++ b/+matnwb/+extension/+internal/downloadZippedRepo.m @@ -0,0 +1,37 @@ +function repoFolder = downloadZippedRepo(githubUrl, targetFolder) +%downloadZippedRepo - Download a zipped repository + + % Create a temporary path for storing the downloaded file. + [~, ~, fileType] = fileparts(githubUrl); + tempFilepath = [tempname, fileType]; + + % Download the file containing the zipped repository + tempFilepath = websave(tempFilepath, githubUrl); + fileCleanupObj = onCleanup( @(fname) delete(tempFilepath) ); + + unzippedFiles = unzip(tempFilepath, tempdir); + unzippedFolder = unzippedFiles{1}; + if endsWith(unzippedFolder, filesep) + unzippedFolder = unzippedFolder(1:end-1); + end + + [~, repoFolderName] = fileparts(unzippedFolder); + targetFolder = fullfile(targetFolder, repoFolderName); + + if isfolder(targetFolder) + try + rmdir(targetFolder, 's') + catch + error('Could not delete previously downloaded extension which is located at:\n"%s"', targetFolder) + end + else + % pass + end + + movefile(unzippedFolder, targetFolder); + + % Delete the temp zip file + clear fileCleanupObj + + repoFolder = targetFolder; +end diff --git a/+matnwb/+extension/getExtensionInfo.m b/+matnwb/+extension/getExtensionInfo.m new file mode 100644 index 00000000..83007790 --- /dev/null +++ b/+matnwb/+extension/getExtensionInfo.m @@ -0,0 +1,49 @@ +function info = getExtensionInfo(extensionName) +% getExtensionInfo - Get metadata for the specified Neurodata extension +% +% Syntax: +% info = matnwb.extension.GETEXTENSIONINFO(extensionName) Returns a struct +% with metadata/information about the specified extension. The extension +% must be registered in the Neurodata Extension Catalog. +% +% Input Arguments: +% - extensionName (string) - +% Name of a Neurodata Extension, e.g "ndx-miniscope". +% +% Output Arguments: +% - info (struct) - +% Struct with metadata / information for the specified extension. The struct +% has the following fields: +% +% - name - The name of the extension. +% - version - The current version of the extension. +% - last_updated - A timestamp indicating when the extension was last updated. +% - src - The URL to the source repository or homepage of the extension. +% - license - The license type under which the extension is distributed. +% - maintainers - A cell array or array of strings listing the maintainers. +% - readme - A string containing the README documentation or description. +% +% Usage: +% Example 1 - Retrieve and display information for the 'ndx-miniscope' extension:: +% +% info = matnwb.extension.getExtensionInfo('ndx-miniscope'); +% +% % Display the version of the extension. +% fprintf('Extension version: %s\n', info.version); +% +% See also: +% matnwb.extension.listExtensions + + arguments + extensionName (1,1) string + end + + T = matnwb.extension.listExtensions(); + isMatch = T.name == extensionName; + extensionList = join( compose(" %s", [T.name]), newline ); + assert( ... + any(isMatch), ... + 'NWB:DisplayExtensionMetadata:ExtensionNotFound', ... + 'Extension "%s" was not found in the extension catalog:\n%s', extensionName, extensionList) + info = table2struct(T(isMatch, :)); +end diff --git a/+matnwb/+extension/installAll.m b/+matnwb/+extension/installAll.m new file mode 100644 index 00000000..a4fff669 --- /dev/null +++ b/+matnwb/+extension/installAll.m @@ -0,0 +1,6 @@ +function installAll() + T = matnwb.extension.listExtensions(); + for i = 1:height(T) + matnwb.extension.installExtension( T.name(i) ) + end +end diff --git a/+matnwb/+extension/installExtension.m b/+matnwb/+extension/installExtension.m new file mode 100644 index 00000000..b333f9d7 --- /dev/null +++ b/+matnwb/+extension/installExtension.m @@ -0,0 +1,49 @@ +function installExtension(extensionName, options) +% installExtension - Install NWB extension from Neurodata Extensions Catalog +% +% matnwb.extension.nwbInstallExtension(extensionName) installs a Neurodata +% Without Borders (NWB) extension from the Neurodata Extensions Catalog to +% extend the functionality of the core NWB schemas. + + arguments + extensionName (1,1) string + options.savedir (1,1) string = misc.getMatnwbDir() + end + + import matnwb.extension.internal.downloadExtensionRepository + + repoTargetFolder = fullfile(userpath, "NWB-Extension-Source"); + if ~isfolder(repoTargetFolder); mkdir(repoTargetFolder); end + + T = matnwb.extension.listExtensions(); + isMatch = T.name == extensionName; + + extensionList = join( compose(" %s", [T.name]), newline ); + assert( ... + any(isMatch), ... + 'NWB:InstallExtension:ExtensionNotFound', ... + 'Extension "%s" was not found in the extension catalog:\n', extensionList) + + repositoryUrl = T{isMatch, 'src'}; + + [wasDownloaded, repoTargetFolder] = ... + downloadExtensionRepository(repositoryUrl, repoTargetFolder, extensionName); + + if ~wasDownloaded + error('NWB:InstallExtension:DownloadFailed', ... + 'Failed to download spec for extension "%s"', extensionName) + end + L = dir(fullfile(repoTargetFolder, 'spec', '*namespace.yaml')); + assert(... + ~isempty(L), ... + 'NWB:InstallExtension:NamespaceNotFound', ... + 'No namespace file was found for extension "%s"', extensionName ... + ) + assert(... + numel(L)==1, ... + 'NWB:InstallExtension:MultipleNamespacesFound', ... + 'More than one namespace file was found for extension "%s"', extensionName ... + ) + generateExtension( fullfile(L.folder, L.name), 'savedir', options.savedir ); + fprintf("Installed extension ""%s"".\n", extensionName) +end diff --git a/+matnwb/+extension/listExtensions.m b/+matnwb/+extension/listExtensions.m new file mode 100644 index 00000000..b93c2f71 --- /dev/null +++ b/+matnwb/+extension/listExtensions.m @@ -0,0 +1,83 @@ +function extensionTable = listExtensions(options) +% listExtensions - List available extensions in the Neurodata Extension Catalog +% +% Syntax: +% extensionTable = matnwb.extension.LISTEXTENSIONS() returns a table where +% each row holds information about a registered extension. +% +% Output Arguments: +% - extensionTable (table) - +% Table of metadata / information for each registered extension. The table +% has the following columns: +% +% - name - The name of the extension. +% - version - The current version of the extension. +% - last_updated - A timestamp indicating when the extension was last updated. +% - src - The URL to the source repository or homepage of the extension. +% - license - The license type under which the extension is distributed. +% - maintainers - A cell array or array of strings listing the maintainers. +% - readme - A string containing the README documentation or description. +% +% Usage: +% Example 1 - List and display extensions:: +% +% T = matnwb.extension.listExtensions(); +% disp(T) +% +% See also: +% matnwb.extension.getExtensionInfo + + arguments + % Refresh - Flag to refresh the catalog (Only relevant if the + % remote catalog has been updated). + options.Refresh (1,1) logical = false + end + + persistent extensionRecords + + if isempty(extensionRecords) || options.Refresh + catalogUrl = "https://raw.githubusercontent.com/nwb-extensions/nwb-extensions.github.io/refs/heads/main/data/records.json"; + extensionRecords = jsondecode(webread(catalogUrl)); + extensionRecords = consolidateStruct(extensionRecords); + + extensionRecords = struct2table(extensionRecords); + + fieldsKeep = ["name", "version", "last_updated", "src", "license", "maintainers", "readme"]; + extensionRecords = extensionRecords(:, fieldsKeep); + + for name = fieldsKeep + if ischar(extensionRecords.(name){1}) + extensionRecords.(name) = string(extensionRecords.(name)); + end + end + end + extensionTable = extensionRecords; +end + +function structArray = consolidateStruct(S) + % Get all field names of S + mainFields = fieldnames(S); + + % Initialize an empty struct array + structArray = struct(); + + % Iterate over each field of S + for i = 1:numel(mainFields) + subStruct = S.(mainFields{i}); % Extract sub-struct + + % Add all fields of the sub-struct to the struct array + fields = fieldnames(subStruct); + for j = 1:numel(fields) + structArray(i).(fields{j}) = subStruct.(fields{j}); + end + end + + % Ensure consistency by filling missing fields with [] + allFields = unique([fieldnames(structArray)]); + for i = 1:numel(structArray) + missingFields = setdiff(allFields, fieldnames(structArray(i))); + for j = 1:numel(missingFields) + structArray(i).(missingFields{j}) = []; + end + end +end diff --git a/+schemes/+utility/listNwbTypeHierarchy.m b/+schemes/+utility/listNwbTypeHierarchy.m new file mode 100644 index 00000000..432c9c80 --- /dev/null +++ b/+schemes/+utility/listNwbTypeHierarchy.m @@ -0,0 +1,21 @@ +function parentTypeNames = listNwbTypeHierarchy(nwbTypeName) +% listNwbTypeHierarchy - List the NWB type hierarchy for an NWB type + arguments + nwbTypeName (1,1) string + end + + parentTypeNames = string.empty; % Initialize an empty cell array + currentType = nwbTypeName; % Start with the specific type + + while ~strcmp(currentType, 'types.untyped.MetaClass') + parentTypeNames(end+1) = currentType; %#ok + + % Use MetaClass information to get the parent type + metaClass = meta.class.fromName(currentType); + if isempty(metaClass.SuperclassList) + break; % Reached the base type + end + % NWB parent type should always be the first superclass in the list + currentType = metaClass.SuperclassList(1).Name; + end +end diff --git a/+tests/+system/NWBFileIOTest.m b/+tests/+system/NWBFileIOTest.m index 03a38255..2772a485 100644 --- a/+tests/+system/NWBFileIOTest.m +++ b/+tests/+system/NWBFileIOTest.m @@ -58,15 +58,17 @@ function readFileWithoutSpec(testCase) fileName = ['MatNWB.' testCase.className() '.testReadFileWithoutSpec.nwb']; nwbExport(testCase.file, fileName) - testCase.deleteGroupFromFile(fileName, 'specifications') + io.internal.h5.deleteGroup(fileName, 'specifications') + nwbRead(fileName); end function readFileWithoutSpecLoc(testCase) + fileName = ['MatNWB.' testCase.className() '.testReadFileWithoutSpecLoc.nwb']; nwbExport(testCase.file, fileName) - testCase.deleteAttributeFromFile(fileName, '/', '.specloc') + io.internal.h5.deleteAttribute(fileName, '/', '.specloc') % When specloc is missing, the specifications are not added to % the blacklist, so it will get passed as an input to NwbFile. @@ -77,7 +79,7 @@ function readFileWithUnsupportedVersion(testCase) fileName = ['MatNWB.' testCase.className() '.testReadFileWithUnsupportedVersion.nwb']; nwbExport(testCase.file, fileName) - testCase.deleteAttributeFromFile(fileName, '/', 'nwb_version') + io.internal.h5.deleteAttribute(fileName, '/', 'nwb_version') file_id = H5F.open(fileName, 'H5F_ACC_RDWR', 'H5P_DEFAULT'); io.writeAttribute(file_id, '/nwb_version', '1.0.0') @@ -93,8 +95,8 @@ function readFileWithUnsupportedVersionAndNoSpecloc(testCase) fileName = ['MatNWB.' testCase.className() '.testReadFileWithUnsupportedVersionAndNoSpecloc.nwb']; nwbExport(testCase.file, fileName) - testCase.deleteAttributeFromFile(fileName, '/', '.specloc') - testCase.deleteAttributeFromFile(fileName, '/', 'nwb_version') + io.internal.h5.deleteAttribute(fileName, '/', '.specloc') + io.internal.h5.deleteAttribute(fileName, '/', 'nwb_version') file_id = H5F.open(fileName, 'H5F_ACC_RDWR', 'H5P_DEFAULT'); io.writeAttribute(file_id, '/nwb_version', '1.0.0') @@ -105,39 +107,4 @@ function readFileWithUnsupportedVersionAndNoSpecloc(testCase) testCase.verifyError(@(fn) nwbRead(fileName), 'MATLAB:TooManyInputs'); end end - - methods (Static, Access = private) - function deleteGroupFromFile(fileName, groupName) - if ~startsWith(groupName, '/') - groupName = ['/', groupName]; - end - - % Open the HDF5 file in read-write mode - file_id = H5F.open(fileName, 'H5F_ACC_RDWR', 'H5P_DEFAULT'); - - % Delete the group - H5L.delete(file_id, groupName, 'H5P_DEFAULT'); - - % Close the HDF5 file - H5F.close(file_id); - end - - function deleteAttributeFromFile(fileName, objectName, attributeName) - % Open the HDF5 file in read-write mode - file_id = H5F.open(fileName, 'H5F_ACC_RDWR', 'H5P_DEFAULT'); - - % Open the object (dataset or group) - object_id = H5O.open(file_id, objectName, 'H5P_DEFAULT'); - - % Delete the attribute - H5A.delete(object_id, attributeName); - - % Close the object - H5O.close(object_id); - - % Close the HDF5 file - H5F.close(file_id); - end - end end - diff --git a/+tests/+unit/InstallExtensionTest.m b/+tests/+unit/InstallExtensionTest.m new file mode 100644 index 00000000..db7e1826 --- /dev/null +++ b/+tests/+unit/InstallExtensionTest.m @@ -0,0 +1,87 @@ +classdef InstallExtensionTest < matlab.unittest.TestCase + + methods (TestClassSetup) + function setupClass(testCase) + % Get the root path of the matnwb repository + rootPath = misc.getMatnwbDir(); + + % Use a fixture to add the folder to the search path + testCase.applyFixture(matlab.unittest.fixtures.PathFixture(rootPath)); + + % Use a fixture to create a temporary working directory + testCase.applyFixture(matlab.unittest.fixtures.WorkingFolderFixture); + generateCore('savedir', '.'); + end + end + + methods (Test) + function testInstallExtensionFailsWithNoInputArgument(testCase) + testCase.verifyError(... + @(varargin) nwbInstallExtension(), ... + 'NWB:InstallExtension:MissingArgument') + end + + function testInstallExtension(testCase) + nwbInstallExtension("ndx-miniscope", 'savedir', '.') + + testCase.verifyTrue(isfolder('./+types/+ndx_miniscope'), ... + 'Folder with extension types does not exist') + end + + function testUseInstalledExtension(testCase) + nwbObject = testCase.initNwbFile(); + + miniscopeDevice = types.ndx_miniscope.Miniscope(... + 'deviceType', 'test_device', ... + 'compression', 'GREY', ... + 'frameRate', '30fps', ... + 'framesPerFile', int8(100) ); + + nwbObject.general_devices.set('TestMiniscope', miniscopeDevice); + + testCase.verifyClass(nwbObject.general_devices.get('TestMiniscope'), ... + 'types.ndx_miniscope.Miniscope') + end + + function testGetExtensionInfo(testCase) + extensionName = "ndx-miniscope"; + metadata = matnwb.extension.getExtensionInfo(extensionName); + testCase.verifyClass(metadata, 'struct') + testCase.verifyEqual(metadata.name, extensionName) + end + + function testDownloadUnknownRepository(testCase) + repositoryUrl = "https://www.unknown-repo.com/anon/my_nwb_extension"; + testCase.verifyError(... + @() matnwb.extension.internal.downloadExtensionRepository(repositoryUrl, "", "my_nwb_extension"), ... + 'NWB:InstallExtension:UnsupportedRepository'); + end + + function testBuildRepoDownloadUrl(testCase) + + import matnwb.extension.internal.buildRepoDownloadUrl + + repoUrl = buildRepoDownloadUrl('https://github.com/user/test', 'main'); + testCase.verifyEqual(repoUrl, 'https://github.com/user/test/archive/refs/heads/main.zip') + + repoUrl = buildRepoDownloadUrl('https://github.com/user/test/', 'main'); + testCase.verifyEqual(repoUrl, 'https://github.com/user/test/archive/refs/heads/main.zip') + + repoUrl = buildRepoDownloadUrl('https://gitlab.com/user/test', 'main'); + testCase.verifyEqual(repoUrl, 'https://gitlab.com/user/test/-/archive/main/test-main.zip') + + testCase.verifyError(... + @() buildRepoDownloadUrl('https://unsupported.com/user/test', 'main'), ... + 'NWB:BuildRepoDownloadUrl:UnsupportedRepository') + end + end + + methods (Static) + function nwb = initNwbFile() + nwb = NwbFile( ... + 'session_description', 'test file for nwb extension', ... + 'identifier', 'export_test', ... + 'session_start_time', datetime("now", 'TimeZone', 'local') ); + end + end +end diff --git a/+tests/+unit/nwbExportTest.m b/+tests/+unit/nwbExportTest.m index 4f25e78e..0590d17e 100644 --- a/+tests/+unit/nwbExportTest.m +++ b/+tests/+unit/nwbExportTest.m @@ -73,6 +73,53 @@ function testExportTimeseriesWithoutStartingTimeRate(testCase) nwbFilePath = fullfile(testCase.OutputFolder, 'test_part1.nwb'); testCase.verifyError(@(f, fn) nwbExport(testCase.NwbObject, nwbFilePath), 'NWB:CustomConstraintUnfulfilled') end + + function testEmbeddedSpecs(testCase) + + nwbFileName = 'testEmbeddedSpecs.nwb'; + + % Install extension. + nwbInstallExtension(["ndx-miniscope", "ndx-photostim"], 'savedir', '.') + + % Export a file not using a type from an extension + nwb = testCase.initNwbFile(); + + nwbExport(nwb, nwbFileName); + embeddedNamespaces = io.spec.listEmbeddedSpecNamespaces(nwbFileName); + testCase.verifyEmpty(embeddedNamespaces) + + ts = types.core.TimeSeries('data', rand(1,10), 'timestamps', 1:10); + nwb.acquisition.set('test', ts) + + nwbExport(nwb, nwbFileName); + embeddedNamespaces = io.spec.listEmbeddedSpecNamespaces(nwbFileName); + + % Verify that extension namespace is not part of embedded specs + testCase.verifyEqual(sort(embeddedNamespaces), {'core', 'hdmf-common'}) + + % Add type for extension. + testDevice = types.ndx_photostim.Laser('model', 'Spectra-Physics'); + nwb.general_devices.set('TestDevice', testDevice); + + nwbExport(nwb, nwbFileName); + embeddedNamespaces = io.spec.listEmbeddedSpecNamespaces(nwbFileName); + + % Verify that extension namespace is part of embedded specs. + testCase.verifyEqual(sort(embeddedNamespaces), {'core', 'hdmf-common', 'ndx-photostim'}) + + nwb.general_devices.remove('TestDevice'); + nwbExport(nwb, nwbFileName); + + embeddedNamespaces = io.spec.listEmbeddedSpecNamespaces(nwbFileName); + testCase.verifyEqual(sort(embeddedNamespaces), {'core', 'hdmf-common'}) + + % Test that warning for missing namespace works + [fileId, fileCleanupObj] = io.internal.h5.openFile(nwbFileName); %#ok + expectedNamespaces = {'core', 'hdmf-common', 'ndx-photostim'}; + testCase.verifyWarning( ... + @(fid,names) io.spec.validateEmbeddedSpecifications(fileId, expectedNamespaces), ... + 'NWB:validators:MissingEmbeddedNamespace') + end end methods (Static) diff --git a/.github/workflows/update_extension_list.yml b/.github/workflows/update_extension_list.yml new file mode 100644 index 00000000..f9dfb763 --- /dev/null +++ b/.github/workflows/update_extension_list.yml @@ -0,0 +1,49 @@ +name: Update extension list + +on: + schedule: + # Run at 8:15 on working days [Minute Hour Day Month Weekdays] + # Run this 15 minutes after source repo is updated + # https://github.com/nwb-extensions/nwb-extensions.github.io/blob/main/.github/workflows/data.yml + - cron: 15 8 * * 0-5 + workflow_dispatch: + +permissions: + contents: write + +jobs: + update_extension_list: + runs-on: ubuntu-latest + steps: + # Use deploy key to push back to protected branch + - name: Checkout repository using deploy key + uses: actions/checkout@v4 + with: + ref: refs/heads/main + ssh-key: ${{ secrets.DEPLOY_KEY }} + + - name: Install MATLAB + uses: matlab-actions/setup-matlab@v2 + + - name: Update extension list in nwbInstallExtensions + uses: matlab-actions/run-command@v2 + with: + command: | + addpath(genpath("tools")); + matnwb_createNwbInstallExtension(); + + - name: Commit the updated nwbInstallExtension function + run: | + set -e # Exit script on error + git config user.name "${{ github.workflow }} by ${{ github.actor }}" + git config user.email "<>" + git pull --rebase # Ensure the branch is up-to-date + + if [[ -n $(git status --porcelain nwbInstallExtension.m) ]]; then + git add nwbInstallExtension.m + git commit -m "Update list of extensions in nwbInstallExtension" + git push + else + echo "Nothing to commit" + fi + \ No newline at end of file diff --git a/.gitignore b/.gitignore index 14397dfc..0511f62e 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,14 @@ workspace/ .DS_Store +tests/env.mat +# Ignore everything in the +types/ folder ++types/* + +# Explicitly include these subdirectories +!+types/+core/ +!+types/+hdmf_common/ +!+types/+hdmf_experimental/ +!+types/+untyped/ +!+types/+util/ + docs/build diff --git a/NwbFile.m b/NwbFile.m index 71bf9478..c0a3839b 100644 --- a/NwbFile.m +++ b/NwbFile.m @@ -74,8 +74,7 @@ function export(obj, filename, mode) end try - jsonSpecs = schemes.exportJson(); - io.spec.writeEmbeddedSpecifications(output_file_id, jsonSpecs); + obj.embedSpecifications(output_file_id) refs = export@types.core.NWBFile(obj, output_file_id, '/', {}); obj.resolveReferences(output_file_id, refs); H5F.close(output_file_id); @@ -118,10 +117,65 @@ function export(obj, filename, mode) typename,... varargin{:}); end + + function nwbTypeNames = listNwbTypes(obj, options) + % listNwbTypes - List all unique NWB (neurodata) types in file + arguments + obj (1,1) NwbFile + options.IncludeParentTypes (1,1) logical = false + end + + objectMap = searchProperties(containers.Map, obj, '', ''); + + objects = objectMap.values(); + objectClassNames = cellfun(@(c) string(class(c)), objects); + objectClassNames = unique(objectClassNames); + + keep = startsWith(objectClassNames, "types."); + ignore = startsWith(objectClassNames, "types.untyped"); + + nwbTypeNames = objectClassNames(keep & ~ignore); + + if options.IncludeParentTypes + includedNwbTypesWithParents = string.empty; + for i = 1:numel(nwbTypeNames) + typeHierarchy = schemes.utility.listNwbTypeHierarchy(nwbTypeNames{i}); + includedNwbTypesWithParents = [includedNwbTypesWithParents, typeHierarchy]; %#ok + end + nwbTypeNames = includedNwbTypesWithParents; + end + end end %% PRIVATE methods(Access=private) + function embedSpecifications(obj, output_file_id) + jsonSpecs = schemes.exportJson(); + + % Resolve the name of all types and parent types that are + % included in this file. This will be used to filter the specs + % to embed, so that only specs with used neurodata types are + % embedded. + includedNeurodataTypes = obj.listNwbTypes("IncludeParentTypes", true); + + % Get the namespace names + namespaceNames = getNamespacesForDataTypes(includedNeurodataTypes); + + % In the specs, the hyphen (-) is used as a word separator, while in + % matnwb the underscore (_) is used. Translate names here: + allMatlabNamespaceNames = strrep({jsonSpecs.name}, '-', '_'); + [~, keepIdx] = intersect(allMatlabNamespaceNames, namespaceNames, 'stable'); + jsonSpecs = jsonSpecs(keepIdx); + + io.spec.writeEmbeddedSpecifications(... + output_file_id, ... + jsonSpecs); + + io.spec.validateEmbeddedSpecifications(... + output_file_id, ... + strrep(namespaceNames, '_', '-')) + end + function resolveReferences(obj, fid, references) while ~isempty(references) resolved = false(size(references)); @@ -215,4 +269,19 @@ function resolveReferences(obj, fid, references) searchProperties(pathToObjectMap, propValue, fullPath, typename, varargin{:}); end end -end \ No newline at end of file +end + +function namespaceNames = getNamespacesForDataTypes(nwbTypeNames) +% getNamespacesOfTypes - Get namespace names for a list of nwb types + arguments + nwbTypeNames (1,:) string + end + + namespaceNames = repmat("", size(nwbTypeNames)); + pattern = '[types.]+\.(\w+)\.'; + + for i = 1:numel(nwbTypeNames) + namespaceNames(i) = regexp(nwbTypeNames(i), pattern, 'tokens', 'once'); + end + namespaceNames = unique(namespaceNames); +end diff --git a/docs/source/index.rst b/docs/source/index.rst index 6e25d53e..e2e95244 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -20,6 +20,7 @@ Contents pages/getting_started/installation_users pages/getting_started/important pages/getting_started/file_read + pages/getting_started/using_extenstions.rst pages/tutorials/index pages/getting_started/overview_citing @@ -38,4 +39,3 @@ Contents pages/developer/contributing pages/developer/documentation - diff --git a/docs/source/pages/functions/index.rst b/docs/source/pages/functions/index.rst index 35497918..1dd788fe 100644 --- a/docs/source/pages/functions/index.rst +++ b/docs/source/pages/functions/index.rst @@ -13,3 +13,4 @@ These are the main functions of the MatNWB API generateCore generateExtension nwbClearGenerated + nwbInstallExtension diff --git a/docs/source/pages/functions/nwbInstallExtension.rst b/docs/source/pages/functions/nwbInstallExtension.rst new file mode 100644 index 00000000..58b37cfe --- /dev/null +++ b/docs/source/pages/functions/nwbInstallExtension.rst @@ -0,0 +1,5 @@ +nwbInstallExtension +=================== + +.. mat:module:: . +.. autofunction:: nwbInstallExtension diff --git a/docs/source/pages/getting_started/using_extensions/generating_extension_api.rst b/docs/source/pages/getting_started/using_extensions/generating_extension_api.rst new file mode 100644 index 00000000..d2f5ca36 --- /dev/null +++ b/docs/source/pages/getting_started/using_extensions/generating_extension_api.rst @@ -0,0 +1,40 @@ +Generating Extension API +------------------------ + +If you have created a neurodata extension or have the files for a third-party +extension locally, you can use the MatNWB function :func:`generateExtension` to +create MATLAB classes for the extension (replace the path argument with the real +path name to the namespace.yaml file): + +.. code-block:: MATLAB + + generateExtension("path/to/extension/namespace.yaml") + +The class files will be generated under the ``+types/+`` namespace in +the matnwb root directory, and can be accessed via standard MATLAB class syntax. +For example, if we had an extension called ``ndx-example`` which defined a +``TetrodeSeries`` neurodata type, we would call: + +.. code-block:: MATLAB + + ts = types.ndx_example.TetrodeSeries(); + +.. important:: + Spaces are not allowed in Neurodata Extensions names, and ``-`` is used instead. + In MATLAB, any occurrence of ``-`` is converted to ``_``, and in general, MatNWB + will convert namespace names if they are not valid MATLAB identifiers. See + `Variable Names `_ + for more information. In most cases, the conversion conforms with MATLAB's approach + with `matlab.lang.makeValidName() `_ + +To generate MatNWB classes in a custom location, you can use the optional ``savedir`` argument: + +.. code-block:: MATLAB + + generateExtension("path/to/ndx-example/namespace.yaml", ... + "savedir", "my/temporary/folder") + +.. note:: + Generating extensions in a custom location is generally not needed, + but is useful in advanced use cases like running tests or in other situations + where you need to better control the MATLAB search path. diff --git a/docs/source/pages/getting_started/using_extensions/installing_extensions.rst b/docs/source/pages/getting_started/using_extensions/installing_extensions.rst new file mode 100644 index 00000000..b9ab274c --- /dev/null +++ b/docs/source/pages/getting_started/using_extensions/installing_extensions.rst @@ -0,0 +1,12 @@ +Installing Published Extensions +------------------------------- + +In MatNWB, use the function :func:`nwbInstallExtension` to download and generate classes +for published Neurodata Extensions: + +.. code-block:: MATLAB + + nwbInstallExtension("ndx-extension") + +Replace ``ndx-extension`` with the name of an actual extension. For a complete +list of published extensions, visit the `Neurodata Extension Catalog `_. diff --git a/docs/source/pages/getting_started/using_extenstions.rst b/docs/source/pages/getting_started/using_extenstions.rst new file mode 100644 index 00000000..c15ce343 --- /dev/null +++ b/docs/source/pages/getting_started/using_extenstions.rst @@ -0,0 +1,17 @@ +Using Neurodata Extensions +========================== + +The `NWB Specification Language `_ +can be used to create Neurodata Extensions (NDX), which extend the core NWB schemas +with modified or entirely new data types. This is useful if you work with data +that has specific metadata or data requirements not covered by the core NWB schemas. +To learn more about extending NWB, see the :nwb_overview:`NWB Overview Documentation`, +and for a list of published extensions, visit the `Neurodata Extension Catalog `_. + +The following sections describe how to use extensions in MatNWB: + +.. toctree:: + :maxdepth: 2 + + using_extensions/generating_extension_api + using_extensions/installing_extensions diff --git a/nwbInstallExtension.m b/nwbInstallExtension.m new file mode 100644 index 00000000..24f961f5 --- /dev/null +++ b/nwbInstallExtension.m @@ -0,0 +1,83 @@ +function nwbInstallExtension(extensionNames, options) +% NWBINSTALLEXTENSION - Installs a specified NWB extension. +% +% Syntax: +% NWBINSTALLEXTENSION(extensionNames) installs Neurodata Without Borders +% (NWB) extensions to extend the functionality of the core NWB schemas. +% extensionNames is a scalar string or a string array, containing the name +% of one or more extensions from the Neurodata Extensions Catalog +% +% Valid Extension Names (from https://nwb-extensions.github.io): +% - "ndx-miniscope" +% - "ndx-simulation-output" +% - "ndx-ecog" +% - "ndx-fret" +% - "ndx-icephys-meta" +% - "ndx-events" +% - "ndx-nirs" +% - "ndx-hierarchical-behavioral-data" +% - "ndx-sound" +% - "ndx-extract" +% - "ndx-photometry" +% - "ndx-acquisition-module" +% - "ndx-odor-metadata" +% - "ndx-whisk" +% - "ndx-ecg" +% - "ndx-franklab-novela" +% - "ndx-photostim" +% - "ndx-multichannel-volume" +% - "ndx-depth-moseq" +% - "ndx-probeinterface" +% - "ndx-dbs" +% - "ndx-hed" +% - "ndx-ophys-devices" +% +% Usage: +% Example 1 - Install "ndx-miniscope" extension:: +% +% nwbInstallExtension("ndx-miniscope") +% +% See also: +% matnwb.extension.listExtensions, matnwb.extension.installExtension + + arguments + extensionNames (1,:) string {mustBeMember(extensionNames, [... + "ndx-miniscope", ... + "ndx-simulation-output", ... + "ndx-ecog", ... + "ndx-fret", ... + "ndx-icephys-meta", ... + "ndx-events", ... + "ndx-nirs", ... + "ndx-hierarchical-behavioral-data", ... + "ndx-sound", ... + "ndx-extract", ... + "ndx-photometry", ... + "ndx-acquisition-module", ... + "ndx-odor-metadata", ... + "ndx-whisk", ... + "ndx-ecg", ... + "ndx-franklab-novela", ... + "ndx-photostim", ... + "ndx-multichannel-volume", ... + "ndx-depth-moseq", ... + "ndx-probeinterface", ... + "ndx-dbs", ... + "ndx-hed", ... + "ndx-ophys-devices" ... + ] ... + )} = [] + options.savedir (1,1) string = misc.getMatnwbDir() + end + if isempty(extensionNames) + T = matnwb.extension.listExtensions(); + extensionList = join( compose(" %s", [T.name]), newline ); + error('NWB:InstallExtension:MissingArgument', ... + 'Please specify the name of an extension. Available extensions:\n\n%s\n', extensionList) + else + for extensionName = extensionNames + matnwb.extension.installExtension(extensionName, 'savedir', options.savedir) + end + end +end + diff --git a/nwbtest.m b/nwbtest.m index a1f2ed15..c15c4ec0 100644 --- a/nwbtest.m +++ b/nwbtest.m @@ -64,7 +64,10 @@ [installDir, ~, ~] = fileparts(mfilename('fullpath')); ignoreFolders = {'tutorials', 'tools', '+contrib', '+util', 'external_packages', '+tests'}; - ignorePaths = {fullfile('+misc', 'generateDocs.m'), [mfilename '.m'], 'nwbClearGenerated.m'}; + ignorePaths = {... + fullfile('+matnwb', '+extension', 'installAll.m'), ... + [mfilename '.m'], ... + 'nwbClearGenerated.m'}; mfilePaths = getMfilePaths(installDir, ignoreFolders, ignorePaths); if ~verLessThan('matlab', '9.3') && ~isempty(mfilePaths) runner.addPlugin(CodeCoveragePlugin.forFile(mfilePaths,... diff --git a/resources/function_templates/nwbInstallExtension.txt b/resources/function_templates/nwbInstallExtension.txt new file mode 100644 index 00000000..1500b711 --- /dev/null +++ b/resources/function_templates/nwbInstallExtension.txt @@ -0,0 +1,36 @@ +function nwbInstallExtension(extensionNames, options) +% nwbInstallExtension - Installs a specified NWB extension. +% +% Usage: +% nwbInstallExtension(extensionNames) installs Neurodata Without Borders +% (NWB) extensions to extend the functionality of the core NWB schemas. +% extensionNames is a scalar string or a string array, containing the name +% of one or more extensions from the Neurodata Extensions Catalog +% +% Valid Extension Names (from https://nwb-extensions.github.io): +{{extensionNamesDoc}} +% +% Example: +% % Install the "ndx-miniscope" extension +% nwbInstallExtension("ndx-miniscope") +% +% See also: +% matnwb.extension.listExtensions, matnwb.extension.installExtension + + arguments + extensionNames (1,:) string {mustBeMember(extensionNames, [... +{{extensionNames}} ... + ] ... + )} = [] + options.savedir (1,1) string = misc.getMatnwbDir() + end + if isempty(extensionNames) + T = matnwb.extension.listExtensions(); + extensionList = join( compose(" %s", [T.name]), newline ); + error("Please specify the name of an extension. Available extensions:\n\n%s\n", extensionList) + else + for extensionName = extensionNames + matnwb.extension.installExtension(extensionName, 'savedir', options.savedir) + end + end +end diff --git a/tools/documentation/private/generateRstForNwbFunctions.m b/tools/documentation/private/generateRstForNwbFunctions.m index d6f1dd68..f0a1bfce 100644 --- a/tools/documentation/private/generateRstForNwbFunctions.m +++ b/tools/documentation/private/generateRstForNwbFunctions.m @@ -5,7 +5,7 @@ function generateRstForNwbFunctions() rootDir = misc.getMatnwbDir(); rootFiles = dir(rootDir); rootFileNames = {rootFiles.name}; - rootWhitelist = {'nwbRead.m', 'NwbFile.m', 'nwbExport.m', 'generateCore.m', 'generateExtension.m', 'nwbClearGenerated.m'};%, 'nwbInstallExtension.m'}; + rootWhitelist = {'nwbRead.m', 'NwbFile.m', 'nwbExport.m', 'generateCore.m', 'generateExtension.m', 'nwbClearGenerated.m', 'nwbInstallExtension.m'}; isWhitelisted = ismember(rootFileNames, rootWhitelist); rootFiles(~isWhitelisted) = []; diff --git a/tools/maintenance/matnwb_createNwbInstallExtension.m b/tools/maintenance/matnwb_createNwbInstallExtension.m new file mode 100644 index 00000000..b056d8bd --- /dev/null +++ b/tools/maintenance/matnwb_createNwbInstallExtension.m @@ -0,0 +1,30 @@ +function matnwb_createNwbInstallExtension() +% matnwb_createNwbInstallExtension - Create nwbInstallExtension from template +% +% Running this function will update the nwbInstallExtension function in +% the root directory of the matnwb package. It will update the list of +% available extension names in the nwbInstallExtension function's arguments +% block and docstring based on the available records in the neurodata +% extensions catalog + + matnwbRootDir = misc.getMatnwbDir(); + fcnTemplate = fileread(fullfile(matnwbRootDir, ... + 'resources', 'function_templates', 'nwbInstallExtension.txt')); + + extensionTable = matnwb.extension.listExtensions(); + extensionNames = extensionTable.name; + + indentStr = repmat(' ', 1, 12); + extensionNamesStr = compose("%s""%s""", indentStr, extensionNames); + extensionNamesStr = strjoin(extensionNamesStr, ", ..." + newline); + fcnStr = replace(fcnTemplate, "{{extensionNames}}", extensionNamesStr); + + extensionNamesStr = compose("%% - ""%s""", extensionNames); + extensionNamesStr = strjoin(extensionNamesStr, newline); + fcnStr = replace(fcnStr, "{{extensionNamesDoc}}", extensionNamesStr); + + + fid = fopen(fullfile(matnwbRootDir, 'nwbInstallExtension.m'), "wt"); + fwrite(fid, fcnStr); + fclose(fid); +end