import isPlainObject from 'lodash/isPlainObject'
import { parseHedString } from '../../parser/parser'
import ParsedHedString from '../../parser/parsedHedString'
import { BidsFile } from './file'
import BidsHedSidecarValidator from '../validator/sidecarValidator'
import { IssueError } from '../../issues/issues'
import { DefinitionManager, Definition } from '../../parser/definitionManager'
const ILLEGAL_SIDECAR_KEYS = new Set(['hed', 'n/a'])
/**
* A BIDS JSON file.
*/
export class BidsJsonFile extends BidsFile {
/**
* This file's JSON data.
* @type {Object}
*/
jsonData
/**
* Constructor.
*
* @param {string} name - The name of the JSON file.
* @param {Object} file - The object representing this file.
* @param {Object} jsonData - The JSON data for this file.
*/
constructor(name, file, jsonData) {
super(name, file, BidsHedSidecarValidator)
this.jsonData = jsonData
}
}
export class BidsSidecar extends BidsJsonFile {
/**
* The extracted keys for this sidecar (string --> BidsSidecarKey)
* @type {Map}
*/
sidecarKeys
/**
* The extracted HED data for this sidecar (string --> string | Object: string, string
* @type {Map}
*/
hedData
/**
* The parsed HED data for this sidecar (string --> ParsedHedString | Map: string --> ParsedHedString).
* @type {Map}
*/
parsedHedData
/**
* The extracted HED value strings.
* @type {string[]}
*/
hedValueStrings
/**
* The extracted HED categorical strings.
* @type {string[]}
*/
hedCategoricalStrings
/**
* The mapping of column splice references (string --> Set of string).
* @type {Map}
*/
columnSpliceMapping
/**
* The set of column splice references.
* @type {Set<string>}
*/
columnSpliceReferences
/**
* The object that manages definitions.
* @type {DefinitionManager}
*/
definitions
/**
* Constructor.
*
* @param {string} name The name of the sidecar file.
* @param {Object} file The file object representing this file.
* @param {Object} sidecarData The raw JSON data.
* @param {DefinitionManager } defManager - The external definitions to use
*/
constructor(name, file, sidecarData = {}, defManager = undefined) {
super(name, file, sidecarData)
this.columnSpliceMapping = new Map()
this.columnSpliceReferences = new Set()
this._setDefinitions(defManager)
this._filterHedStrings()
this._categorizeHedStrings()
}
/**
* Determine whether this file has any HED data.
*
* @returns {boolean}
*/
get hasHedData() {
return this.sidecarKeys.size > 0
}
/**
* The extracted HED strings.
* @returns {string[]}
*/
get hedStrings() {
return this.hedValueStrings.concat(this.hedCategoricalStrings)
}
/**
* Parse this sidecar's HED strings within the sidecar structure.
*
* The parsed strings are placed into {@link parsedHedData}.
*
* @param {Schemas} hedSchemas - The HED schema collection.
* @param {boolean} fullValidation - True if full validation should be performed.
* @returns {Array} [Issue[], Issue[]] Any errors and warnings found
*/
parseHed(hedSchemas, fullValidation = false) {
this.parsedHedData = new Map()
const errors = []
const warnings = []
for (const [name, sidecarKey] of this.sidecarKeys.entries()) {
const [errorIssues, warningIssues] =
sidecarKey.parseHed(hedSchemas, fullValidation && !this.columnSpliceReferences.has(name))
errors.push(...errorIssues)
warnings.push(...warningIssues)
if (sidecarKey.isValueKey) {
this.parsedHedData.set(name, sidecarKey.parsedValueString)
} else {
this.parsedHedData.set(name, sidecarKey.parsedCategoryMap)
}
}
this._generateSidecarColumnSpliceMap()
return [errors, warnings]
}
/**
* Set the definition manager for this sidecar.
* @param defManager
* @private
*/
_setDefinitions(defManager) {
if (defManager instanceof DefinitionManager) {
this.definitions = defManager
} else if (!defManager) {
this.definitions = new DefinitionManager()
} else {
IssueError.generateAndThrowInternalError(
'Improper format for defManager parameter -- must be null or DefinitionManager',
)
}
}
/**
* Create the sidecar key map from the JSON.
* @private
*/
_filterHedStrings() {
this.sidecarKeys = new Map(
Object.entries(this.jsonData)
.map(([key, value]) => {
const trimmedKey = key.trim()
const lowerKey = trimmedKey.toLowerCase()
if (ILLEGAL_SIDECAR_KEYS.has(lowerKey)) {
IssueError.generateAndThrow('illegalSidecarHedKey')
}
if (BidsSidecar._sidecarValueHasHed(value)) {
return [trimmedKey, new BidsSidecarKey(trimmedKey, value.HED, this)]
}
BidsSidecar._verifyKeyHasNoDeepHed(key, value)
return null
})
.filter(Boolean),
)
}
/**
* Determine whether a sidecar value has HED data.
*
* @param {Object} sidecarValue A BIDS sidecar value.
* @returns {boolean} Whether the sidecar value has HED data.
* @private
*/
static _sidecarValueHasHed(sidecarValue) {
return sidecarValue !== null && typeof sidecarValue === 'object' && sidecarValue.HED !== undefined
}
/**
* Verify that a column has no deeply nested "HED" keys.
*
* @param {string} key An object key.
* @param {*} value An object value.
* @throws {IssueError} If an invalid "HED" key is found.
* @private
*/
static _verifyKeyHasNoDeepHed(key, value) {
if (key.toUpperCase() === 'HED') {
IssueError.generateAndThrow('illegalSidecarHedDeepKey')
}
if (!isPlainObject(value)) {
return
}
for (const [subkey, subvalue] of Object.entries(value)) {
BidsSidecar._verifyKeyHasNoDeepHed(subkey, subvalue)
}
}
/**
* Categorize the column strings into value strings and categorical strings
* @private
*/
_categorizeHedStrings() {
this.hedValueStrings = []
this.hedCategoricalStrings = []
this.hedData = new Map()
for (const [key, sidecarValue] of this.sidecarKeys.entries()) {
if (sidecarValue.isValueKey) {
this.hedValueStrings.push(sidecarValue.valueString)
this.hedData.set(key, sidecarValue.valueString)
} else if (sidecarValue.categoryMap) {
this.hedCategoricalStrings.push(...sidecarValue.categoryMap.values())
this.hedData.set(key, sidecarValue.categoryMap)
}
}
}
/**
* Generate a mapping of an individual BIDS sidecar's curly brace references.
*
* @private
*/
_generateSidecarColumnSpliceMap() {
this.columnSpliceMapping = new Map()
this.columnSpliceReferences = new Set()
for (const [sidecarKey, hedData] of this.parsedHedData) {
if (hedData instanceof ParsedHedString) {
this._parseValueSplice(sidecarKey, hedData)
} else if (hedData instanceof Map) {
this._parseCategorySplice(sidecarKey, hedData)
} else if (hedData) {
IssueError.generateAndThrowInternalError('Unexpected type found in sidecar parsedHedData map.')
}
}
}
/**
*
* @param {BidsSidecarKey} sidecarKey - The column to be checked for column splices.
* @param {ParsedHedString} hedData - The parsed HED string to check for column splices.
* @private
*/
_parseValueSplice(sidecarKey, hedData) {
if (hedData.columnSplices.length > 0) {
const keyReferences = this._processColumnSplices(new Set(), hedData.columnSplices)
this.columnSpliceMapping.set(sidecarKey, keyReferences)
}
}
/**
*
* @param {BidsSidecarKey} sidecarKey - The column to be checked for column splices.
* @param {ParsedHedString} hedData - The parsed HED string to check for column splices.
* @private
*/
_parseCategorySplice(sidecarKey, hedData) {
let keyReferences = null
for (const valueString of hedData.values()) {
if (valueString?.columnSplices.length > 0) {
keyReferences = this._processColumnSplices(keyReferences, valueString.columnSplices)
}
}
if (keyReferences instanceof Set) {
this.columnSpliceMapping.set(sidecarKey, keyReferences)
}
}
/**
* Add a list of columnSplices to a key set.
* @param {Set<string>|null} keyReferences
* @param {ParsedHedColumnSplice[]} columnSplices
* @returns {Set<string>}
* @private
*/
_processColumnSplices(keyReferences, columnSplices) {
keyReferences ??= new Set()
for (const columnSplice of columnSplices) {
keyReferences.add(columnSplice.originalTag)
this.columnSpliceReferences.add(columnSplice.originalTag)
}
return keyReferences
}
}
export class BidsSidecarKey {
/**
* The name of this key.
* @type {string}
*/
name
/**
* The unparsed category mapping.
* @type {Map<string, string>}
*/
categoryMap
/**
* The parsed category mapping.
* @type {Map<string, ParsedHedString>}
*/
parsedCategoryMap
/**
* The unparsed value string.
* @type {string}
*/
valueString
/**
* The parsed value string.
* @type {ParsedHedString}
*/
parsedValueString
/**
* Weak reference to the sidecar.
* @type {BidsSidecar}
*/
sidecar
/**
* Indication of whether this key is for definitions.
* @type {Boolean}
*/
hasDefinitions
/**
* Constructor.
*
* @param {string} key The name of this key.
* @param {string|Object<string, string>} data The data for this key.
* @param {BidsSidecar} sidecar The parent sidecar.
*/
constructor(key, data, sidecar) {
this.name = key
this.hasDefinitions = false // May reset to true when definitions for this key are checked
this.sidecar = sidecar
if (typeof data === 'string') {
this.valueString = data
} else if (!isPlainObject(data)) {
IssueError.generateAndThrow('illegalSidecarHedType', { key: key, file: sidecar.file.relativePath })
} else {
this.categoryMap = new Map(Object.entries(data))
}
}
/**
* Parse the HED data for this key.
*
* ###Note: This sets the parsedHedData as a side effect.
*
* @param {Schemas} hedSchemas The HED schema collection.
* @param {boolean} fullValidation True if full validation should be performed.
* @returns {Array} - [Issue[], Issues[]] Errors and warnings that result from parsing.
*/
parseHed(hedSchemas, fullValidation = false) {
if (this.isValueKey) {
return this._parseValueString(hedSchemas, fullValidation)
}
return this._parseCategory(hedSchemas, fullValidation) // This is a Map of string to ParsedHedString
}
/**
* Parse the value string in a sidecar.
*
* ### Note:
* The value strings cannot contain definitions.
*
* @param {Schemas} hedSchemas - The HED schemas to use.
* @param {boolean} fullValidation - True if full validation should be performed.
* @returns {Array} - [Issue[], Issue[]] - Errors due for the value.
* @private
*/
_parseValueString(hedSchemas, fullValidation) {
const [parsedString, errorIssues, warningIssues] = parseHedString(
this.valueString,
hedSchemas,
false,
true,
fullValidation,
)
this.parsedValueString = parsedString
return [errorIssues, warningIssues]
}
/**
* Parse the categorical values associated with this key.
* @param {Schemas} hedSchemas - The HED schemas used to check against.
* @param {boolean} fullValidation - True if full validation should be performed.
* @returns {Array} - Array[Issue[], Issue[]] A list of error issues and warning issues.
* @private
*/
_parseCategory(hedSchemas, fullValidation) {
this.parsedCategoryMap = new Map()
const errors = []
const warnings = []
for (const [value, string] of this.categoryMap) {
const trimmedValue = value.trim()
if (ILLEGAL_SIDECAR_KEYS.has(trimmedValue.toLowerCase())) {
IssueError.generateAndThrow('illegalSidecarHedCategoricalValue')
} else if (typeof string !== 'string') {
IssueError.generateAndThrow('illegalSidecarHedType', {
key: value,
file: this.sidecar?.file?.relativePath,
})
}
const [parsedString, errorIssues, warningIssues] =
parseHedString(string, hedSchemas, true, true, fullValidation)
this.parsedCategoryMap.set(value, parsedString)
warnings.push(...warningIssues)
errors.push(...errorIssues)
if (errorIssues.length === 0) {
errors.push(...this._checkDefinitions(parsedString))
}
}
return [errors, warnings]
}
/**
* Check for definitions in the HED string.
* @param {ParsedHedString} parsedString - The string to check for definitions.
* @returns {Issue[]} - Errors that occur.
* @private
*/
_checkDefinitions(parsedString) {
const errors = []
for (const group of parsedString.tagGroups) {
if (!group.isDefinitionGroup) {
continue
}
this.hasDefinitions = true
const [def, defIssues] = Definition.createDefinitionFromGroup(group)
if (defIssues.length > 0) {
errors.push(...defIssues)
} else {
errors.push(...this.sidecar.definitions.addDefinition(def))
}
}
return errors
}
/**
* Whether this key is a value key.
* @returns {boolean}
*/
get isValueKey() {
return Boolean(this.valueString)
}
}