diff --git a/code/internal/+openminds/+abstract/ControlledTerm.m b/code/internal/+openminds/+abstract/ControlledTerm.m index fce42393..5beb166b 100644 --- a/code/internal/+openminds/+abstract/ControlledTerm.m +++ b/code/internal/+openminds/+abstract/ControlledTerm.m @@ -1,10 +1,6 @@ -classdef (Abstract) ControlledTerm < openminds.abstract.Schema +classdef (Abstract) ControlledTerm < openminds.abstract.ControlledTermBase %ControlledTerm Abstract base class for metadata types of the controlled terms module - properties (Access = protected) - Required = {'name'} - end - properties % Enter one sentence for defining this term. definition (1,1) string @@ -12,15 +8,18 @@ % Enter a short text describing this term. description (1,1) string - % Enter the internationalized resource identifier (IRI) pointing to the integrated ontology entry in the InterLex project. - interlexIdentifier (1,1) string - - % Enter the internationalized resource identifier (IRI) pointing to the wiki page of the corresponding term in the KnowledgeSpace. - knowledgeSpaceLink (1,1) string - % Controlled term originating from a defined terminology. name (1,1) string + % Enter all IRIs pointing to cross-references to external databases or registries that are equivalent to this term. + otherCrossReference (1,:) string {mustBeListOfUniqueItems(otherCrossReference)} + + % Enter all IRIs pointing to ontology entries that are equivalent to this term. + otherOntologyIdentifier (1,:) string {mustBeListOfUniqueItems(otherOntologyIdentifier)} + + % Enter the IRI pointing to the preferred cross-reference to an external database or registry. + preferredCrossReference (1,1) string + % Enter the internationalized resource identifier (IRI) pointing to the preferred ontological term. preferredOntologyIdentifier (1,1) string @@ -28,139 +27,15 @@ synonym (1,:) string {mustBeListOfUniqueItems(synonym)} end - properties (SetAccess = protected, Hidden) % Todo: Same as id, clean up - at_id - end - - properties (Constant, Hidden) - LINKED_PROPERTIES = struct() - EMBEDDED_PROPERTIES = struct() - end - - properties (Abstract, Constant, Hidden) - CONTROLLED_INSTANCES - end - methods function obj = ControlledTerm(instanceSpec, propValues) - arguments instanceSpec = [] propValues.?openminds.abstract.ControlledTerm propValues.id (1,1) string end - if isstring(instanceSpec) && isscalar(instanceSpec) && instanceSpec == "" - instanceSpec = string.empty; - end - - if ~isempty(instanceSpec) - if ischar(instanceSpec) - instanceSpec = string(instanceSpec); - end - - if isstring( instanceSpec ) && ~ismissing(instanceSpec) - % Check IRI first, because isfile will also check IRIs - % and that is expensive (we only want to check local - % files anyway) - if startsWith(instanceSpec, openminds.constant.BaseURI) - obj.deserializeFromName(instanceSpec); - elseif isfile( instanceSpec ) - obj.load( instanceSpec ) % todo: Not implemented?? - else - % Deserialize from name of controlled instance - obj.deserializeFromName(instanceSpec); - end - elseif isstruct( instanceSpec ) && isfield(instanceSpec, 'at_id') || isfield(instanceSpec, 'x_id') - numInstances = numel(instanceSpec); - if numInstances > 1 - obj(numInstances) = feval(class(obj)); - end - for i = 1:numel(instanceSpec) - if isfield(instanceSpec(i), 'at_id') - iri = instanceSpec(i).at_id; - elseif isfield(instanceSpec(i), 'x_id') - iri = instanceSpec(i).x_id; - end - obj(i).deserializeFromName(iri); - end - else - error('openMINDS:ControlledTerm:InvalidInput', ... - 'Expected instance spec to be a name, a filename or a structure with `at_id` field.') - end - - names = fieldnames(propValues); - obj.warnIfPropValuesSupplied(names) - else - obj.set(propValues) - if ismissing(obj.id) - obj.id = obj.generateInstanceId(); - end - end - end - end - - methods (Access = protected) % Implement method for the CustomInstanceDisplay mixin - function str = getDisplayLabel(obj) - str = sprintf('%s', obj.name); - end - end - - methods (Hidden) - function str = char(obj) - str = char(string(obj.name)); - end - end - - methods (Access = private) - function deserializeFromName(obj, instanceName) - - import openminds.internal.getControlledInstance - import openminds.internal.utility.getSchemaName - - instanceName = char(instanceName); - schemaName = getSchemaName(class(obj)); - - if openminds.utility.isIRI(instanceName) - if openminds.utility.isInstanceIRI(instanceName) - [~, instanceName] = openminds.utility.parseInstanceIRI(instanceName); - else - obj.id = instanceName; - return - end - end - - [instanceName, instanceNameOrig] = deal(instanceName); - if ~any(strcmp(obj.CONTROLLED_INSTANCES, instanceName)) - % Try to make a valid name - instanceName = strrep(instanceName, ' ', ''); - instanceName = matlab.lang.makeValidName(instanceName, 'ReplacementStyle', 'delete'); - end - - % Todo: Use a proper deserializer - if any(strcmpi(obj.CONTROLLED_INSTANCES, instanceName)) - try - data = getControlledInstance(instanceName, schemaName, 'controlledTerms'); - catch - s = warning('off', 'backtrace'); - warningCleanup = onCleanup(@() warning(s)); - warning('Controlled instance "%s" is not available.', instanceNameOrig) - return - end - else - warning('No matching instances were found for name "%s"', instanceName) - return - % error('Deserialization from user instance is not implemented yet') - end - propNames = {'at_id', 'name', 'definition', 'description', 'interlexIdentifier', 'knowledgeSpaceLink', 'preferredOntologyIdentifier', 'synonym'}; - - for i = 1:numel(propNames) - if ~isempty( data.(propNames{i}) ) - obj.(propNames{i}) = data.(propNames{i}); - end - end - - obj.id = obj.at_id; + obj.initializeControlledTerm(instanceSpec, propValues) end end end diff --git a/code/internal/+openminds/+abstract/ControlledTermBase.m b/code/internal/+openminds/+abstract/ControlledTermBase.m new file mode 100644 index 00000000..b7ce6dd1 --- /dev/null +++ b/code/internal/+openminds/+abstract/ControlledTermBase.m @@ -0,0 +1,171 @@ +classdef (Abstract) ControlledTermBase < openminds.abstract.Schema +%ControlledTermBase Shared behavior for controlled term base classes. + + properties (Access = protected) + Required = {'name'} + end + + properties (SetAccess = protected, Hidden) % Todo: Same as id, clean up + at_id + end + + properties (Constant, Hidden) + LINKED_PROPERTIES = struct() + EMBEDDED_PROPERTIES = struct() + end + + properties (Abstract, Constant, Hidden) + CONTROLLED_INSTANCES + end + + methods (Access = protected) + function initializeControlledTerm(obj, instanceSpec, propValues) + if isstring(instanceSpec) && isscalar(instanceSpec) && instanceSpec == "" + instanceSpec = string.empty; + end + + if ~isempty(instanceSpec) + if ischar(instanceSpec) + instanceSpec = string(instanceSpec); + end + + if isstring( instanceSpec ) && ~ismissing(instanceSpec) + % Check IRI first, because isfile will also check IRIs + % and that is expensive (we only want to check local + % files anyway) + if startsWith(instanceSpec, openminds.constant.BaseURI) + obj.deserializeFromName(instanceSpec); + elseif isfile( instanceSpec ) + obj.load( instanceSpec ) % todo: Not implemented?? + else + % Deserialize from name of controlled instance + obj.deserializeFromName(instanceSpec); + end + elseif isstruct( instanceSpec ) && (isfield(instanceSpec, 'at_id') || isfield(instanceSpec, 'x_id')) + numInstances = numel(instanceSpec); + if numInstances > 1 + obj(numInstances) = feval(class(obj)); + end + for i = 1:numel(instanceSpec) + if isfield(instanceSpec(i), 'at_id') + iri = instanceSpec(i).at_id; + elseif isfield(instanceSpec(i), 'x_id') + iri = instanceSpec(i).x_id; + end + obj(i).deserializeFromName(iri); + end + else + error('openMINDS:ControlledTerm:InvalidInput', ... + 'Expected instance spec to be a name, a filename, or a structure or structure array with an `at_id` or `x_id` field.') + end + + names = fieldnames(propValues); + obj.warnIfPropValuesSupplied(names) + else + obj.set(propValues) + if ismissing(obj.id) || obj.id == "" + obj.id = obj.generateInstanceId(); + end + end + end + + function str = getDisplayLabel(obj) + str = sprintf('%s', obj.name); + end + end + + methods (Hidden) + function str = char(obj) + str = char(string(obj.name)); + end + end + + methods (Access = private) + function deserializeFromName(obj, instanceName) + + import openminds.internal.getControlledInstance + import openminds.internal.utility.getSchemaName + + instanceName = char(instanceName); + instanceIRI = ""; + schemaName = getSchemaName(class(obj)); + + if openminds.utility.isIRI(instanceName) + if openminds.utility.isInstanceIRI(instanceName) + instanceIRI = string(instanceName); + [~, instanceName] = openminds.utility.parseInstanceIRI(instanceName); + else + obj.id = instanceName; + return + end + end + + if ~any(strcmp(obj.CONTROLLED_INSTANCES, instanceName)) + % Try to make a valid name + instanceName = strrep(instanceName, ' ', ''); + instanceName = matlab.lang.makeValidName(instanceName, 'ReplacementStyle', 'delete'); + end + + % Todo: Use a proper deserializer + isMatchingInstance = strcmpi(obj.CONTROLLED_INSTANCES, instanceName); + if any(isMatchingInstance) + instanceName = obj.CONTROLLED_INSTANCES(find(isMatchingInstance, 1, 'first')); + obj.name = instanceName; + if instanceIRI == "" + obj.id = obj.createControlledInstanceIRI(schemaName, instanceName); + else + obj.id = instanceIRI; + end + + try + data = getControlledInstance(instanceName, schemaName, 'controlledTerms'); + catch + % Known instance names are sufficient identifiers. The + % JSON-LD instance file is only used to enrich metadata. + return + end + else + warning('No matching instances were found for name "%s"', instanceName) + return + % error('Deserialization from user instance is not implemented yet') + end + + propNames = [{'at_id'}, properties(obj)']; + for i = 1:numel(propNames) + if isfield(data, propNames{i}) && ~obj.isEmptyValue(data.(propNames{i})) + obj.(propNames{i}) = data.(propNames{i}); + end + end + + if instanceIRI == "" && ~obj.isEmptyValue(obj.at_id) + obj.id = obj.at_id; + end + end + end + + methods (Static, Access = private) + function instanceIRI = createControlledInstanceIRI(schemaName, instanceName) + instanceIRI = openminds.constant.BaseURI + "/instances/" ... + + openminds.abstract.ControlledTermBase.getInstanceTypeName(schemaName) ... + + "/" + string(instanceName); + end + + function typeName = getInstanceTypeName(schemaName) + typeName = char(schemaName); + if ~strcmp(upper(typeName(1:2)), typeName(1:2)) + typeName(1) = lower(typeName(1)); + end + typeName = string(typeName); + end + + function tf = isEmptyValue(value) + if isempty(value) + tf = true; + elseif isstring(value) + tf = all(ismissing(value) | value == ""); + else + tf = false; + end + end + end +end diff --git a/code/internal/+openminds/+abstract/private/controlledTerms/v2/ControlledTerm.m b/code/internal/+openminds/+abstract/private/controlledTerms/v2/ControlledTerm.m new file mode 100644 index 00000000..fe4ed0c3 --- /dev/null +++ b/code/internal/+openminds/+abstract/private/controlledTerms/v2/ControlledTerm.m @@ -0,0 +1,38 @@ +classdef (Abstract) ControlledTerm < openminds.abstract.ControlledTermBase +%ControlledTerm Abstract base class for metadata types of the controlled terms module + + properties + % Enter one sentence for defining this term. + definition (1,1) string + + % Enter a short text describing this term. + description (1,1) string + + % Enter the internationalized resource identifier (IRI) pointing to the integrated ontology entry in the InterLex project. + interlexIdentifier (1,1) string + + % Enter the internationalized resource identifier (IRI) pointing to the wiki page of the corresponding term in the KnowledgeSpace. + knowledgeSpaceLink (1,1) string + + % Controlled term originating from a defined terminology. + name (1,1) string + + % Enter the internationalized resource identifier (IRI) pointing to the preferred ontological term. + preferredOntologyIdentifier (1,1) string + + % Enter one or several synonyms (including abbreviations) for this controlled term. + synonym (1,:) string {mustBeListOfUniqueItems(synonym)} + end + + methods + function obj = ControlledTerm(instanceSpec, propValues) + arguments + instanceSpec = [] + propValues.?openminds.abstract.ControlledTerm + propValues.id (1,1) string + end + + obj.initializeControlledTerm(instanceSpec, propValues) + end + end +end diff --git a/code/internal/+openminds/+abstract/private/controlledTerms/v3/ControlledTerm.m b/code/internal/+openminds/+abstract/private/controlledTerms/v3/ControlledTerm.m new file mode 100644 index 00000000..5beb166b --- /dev/null +++ b/code/internal/+openminds/+abstract/private/controlledTerms/v3/ControlledTerm.m @@ -0,0 +1,41 @@ +classdef (Abstract) ControlledTerm < openminds.abstract.ControlledTermBase +%ControlledTerm Abstract base class for metadata types of the controlled terms module + + properties + % Enter one sentence for defining this term. + definition (1,1) string + + % Enter a short text describing this term. + description (1,1) string + + % Controlled term originating from a defined terminology. + name (1,1) string + + % Enter all IRIs pointing to cross-references to external databases or registries that are equivalent to this term. + otherCrossReference (1,:) string {mustBeListOfUniqueItems(otherCrossReference)} + + % Enter all IRIs pointing to ontology entries that are equivalent to this term. + otherOntologyIdentifier (1,:) string {mustBeListOfUniqueItems(otherOntologyIdentifier)} + + % Enter the IRI pointing to the preferred cross-reference to an external database or registry. + preferredCrossReference (1,1) string + + % Enter the internationalized resource identifier (IRI) pointing to the preferred ontological term. + preferredOntologyIdentifier (1,1) string + + % Enter one or several synonyms (including abbreviations) for this controlled term. + synonym (1,:) string {mustBeListOfUniqueItems(synonym)} + end + + methods + function obj = ControlledTerm(instanceSpec, propValues) + arguments + instanceSpec = [] + propValues.?openminds.abstract.ControlledTerm + propValues.id (1,1) string + end + + obj.initializeControlledTerm(instanceSpec, propValues) + end + end +end diff --git a/code/internal/+openminds/+internal/activateControlledTermBase.m b/code/internal/+openminds/+internal/activateControlledTermBase.m new file mode 100644 index 00000000..a8231e6b --- /dev/null +++ b/code/internal/+openminds/+internal/activateControlledTermBase.m @@ -0,0 +1,46 @@ +function controlledTermVersion = activateControlledTermBase(modelVersion) +%activateControlledTermBase Activate the controlled-term base for a model version. + + arguments + modelVersion (1,1) openminds.internal.utility.VersionNumber ... + {openminds.mustBeValidVersion(modelVersion)} = "latest" + end + + controlledTermVersion = getControlledTermVersion(modelVersion); + + rootPath = openminds.internal.rootpath(); + abstractFolder = fullfile(rootPath, "internal", "+openminds", "+abstract"); + sourceFile = fullfile(abstractFolder, "private", "controlledTerms", ... + controlledTermVersion, "ControlledTerm.m"); + targetFile = fullfile(abstractFolder, "ControlledTerm.m"); + + if ~isfile(sourceFile) + error("openMINDS:ControlledTerm:MissingVersionedBase", ... + 'No controlled-term base exists for version "%s".', controlledTermVersion) + end + + sourceText = fileread(sourceFile); + if isfile(targetFile) + targetText = fileread(targetFile); + else + targetText = ''; + end + + if ~strcmp(sourceText, targetText) + [success, message] = copyfile(sourceFile, targetFile, "f"); + if ~success + error("openMINDS:ControlledTerm:ActivationFailed", ... + 'Could not activate controlled-term base "%s": %s', ... + controlledTermVersion, message) + end + rehash + end +end + +function controlledTermVersion = getControlledTermVersion(modelVersion) + if modelVersion >= 5 + controlledTermVersion = "v3"; + else + controlledTermVersion = "v2"; + end +end diff --git a/code/internal/+openminds/selectOpenMindsVersion.m b/code/internal/+openminds/selectOpenMindsVersion.m index dd9a3412..ca8f49c3 100644 --- a/code/internal/+openminds/selectOpenMindsVersion.m +++ b/code/internal/+openminds/selectOpenMindsVersion.m @@ -50,6 +50,8 @@ function selectOpenMindsVersion(versionNumber) addpath( genpath( fullfile(rootPath, 'internal') ) ) addpath( genpath( fullfile(rootPath, 'livescripts') ) ) + openminds.internal.activateControlledTermBase(versionNumber); + % Get version number as string matching version numbers of version folders if versionNumber == "latest" versionAsString = 'latest'; @@ -69,6 +71,10 @@ function selectOpenMindsVersion(versionNumber) addpath(genpath( fullfile(rootPath, "mixedtypes", versionAsString) )) addpath(genpath( fullfile(rootPath, "enumerations", versionAsString) )) + % Version selection can replace shared abstract class files. + % Clear cached class definitions so MATLAB sees the active files. + clear classes; + % Add a second pause for changes to take effect. pause(1) % Ad hoc value. Usually at least 0.3 - 0.4 seconds is necessary end diff --git a/tools/.codespellrc b/tools/.codespellrc index 43c87aa5..6f91bf24 100644 --- a/tools/.codespellrc +++ b/tools/.codespellrc @@ -1,3 +1,3 @@ [codespell] skip = -ignore-words-list = ALS,ans +ignore-words-list = TE,ALS,ans diff --git a/tools/tests/unitTests/ControlledTermTest.m b/tools/tests/unitTests/ControlledTermTest.m new file mode 100644 index 00000000..fb4db624 --- /dev/null +++ b/tools/tests/unitTests/ControlledTermTest.m @@ -0,0 +1,59 @@ +classdef ControlledTermTest < matlab.unittest.TestCase + + methods (Test) + function testKnownInstanceCreatesLightweightReference(testCase) + term = testCase.verifyWarningFree( ... + @() openminds.controlledterms.ContributionType("authoring")); + + testCase.verifyEqual(term.name, "authoring") + testCase.verifyEqual(term.id, ... + openminds.constant.BaseURI + "/instances/contributionType/authoring") + end + + function testOlderControlledTermPropertiesAreAccepted(testCase) + sourceText = fileread(testCase.getControlledTermBasePath("v2")); + + testCase.verifyTrue(contains(sourceText, "interlexIdentifier")) + testCase.verifyTrue(contains(sourceText, "knowledgeSpaceLink")) + testCase.verifyFalse(contains(sourceText, "otherCrossReference")) + end + + function testNewerControlledTermPropertiesAreAccepted(testCase) + term = openminds.controlledterms.ContributionType( ... + [], ... + "name", "authoring", ... + "preferredCrossReference", "https://example.org/preferred", ... + "otherCrossReference", "https://example.org/cross-reference", ... + "otherOntologyIdentifier", "https://example.org/ontology"); + + testCase.verifyEqual(term.preferredCrossReference, "https://example.org/preferred") + testCase.verifyEqual(term.otherCrossReference, "https://example.org/cross-reference") + testCase.verifyEqual(term.otherOntologyIdentifier, "https://example.org/ontology") + end + + function testLatestControlledTermBaseDoesNotExposeOlderProperties(testCase) + term = openminds.controlledterms.ContributionType(); + propertyNames = string(properties(term)); + + testCase.verifyFalse(ismember("interlexIdentifier", propertyNames)) + testCase.verifyFalse(ismember("knowledgeSpaceLink", propertyNames)) + end + + function testControlledTermBaseDoesNotExposeTermSuggestionProperties(testCase) + term = openminds.controlledterms.ContributionType(); + propertyNames = string(properties(term)); + + testCase.verifyFalse(ismember("addExistingTerminology", propertyNames)) + testCase.verifyFalse(ismember("suggestNewTerminology", propertyNames)) + end + end + + methods (Access = private) + function filePath = getControlledTermBasePath(~, version) + rootPath = openminds.internal.rootpath(); + filePath = fullfile(rootPath, "internal", "+openminds", ... + "+abstract", "private", "controlledTerms", version, ... + "ControlledTerm.m"); + end + end +end