sssom-js
Version:
Simple Standard for Sharing Ontology Mappings (SSOM) JavaScript library
203 lines (180 loc) • 5.84 kB
JavaScript
import { EventEmitter } from "events"
import csv from "csv-parser"
import { parseMetadata, parseSlotValue } from "./input.js"
import { CurieMap } from "./curiemap.js"
import { validateMappingSet, validateMapping } from "./validate.js"
import { mappingSchema, mappingSetSchema } from "./schemas.js"
import { DetailledError } from "./error.js"
import { yamlPosition } from "./locators.js"
import { propagate } from "./propagate.js"
/**
* Parse SSSOM/TSV serialisation format (optionally with other delimiter)
* as specified at <https://mapping-commons.github.io/sssom/spec-formats-tsv/>
*
* Events:
* - metadata (object)
* - mapping (object)
* - error
* - end
*/
export class TSVReader extends EventEmitter {
constructor(input, options = {}) {
super()
let { metadata, storeMappings, curie, propagate, liberal } = options
this.input = input || { on() {} }
this.remaining = ""
this.lineNumber = 0
this.metadataPrefix
this.curie = curie
this.liberal = liberal
this.failed = false
this.headerLineRead = false
this.tsv = csv({ separator: options.delimiter || "\t" })
this.metadataBlock = []
this.storeMappings = storeMappings
this.propagate = propagate
if (metadata) {
this.emitMetadata(metadata)
}
this.init()
}
emitError(error, { line, value } = {}) {
this.failed = true
value ??= error.value
const position = error.position || {}
if (line) {
position.line = line
}
this.emit("error", new DetailledError(error, { position, value }))
}
emitMetadata(metadata) {
if (!this.failed) {
const metadataYaml = this.metadataBlock.join("\n")
try {
if (metadata) {
this.curieMap = new CurieMap(metadata.curie_map, this.curie)
} else {
metadata = parseMetadata(metadataYaml) || {}
this.curieMap = new CurieMap(metadata.curie_map, this.curie)
Object.entries(metadata).forEach(([slot,value]) => {
metadata[slot] = this.expandSlotValue(slot, value, mappingSetSchema)
})
}
this.metadata = validateMappingSet(metadata, this.liberal)
if (this.storeMappings) {
this.metadata.mappings = []
} else {
delete this.metadata.mappings
}
this.emit("metadata", this.metadata)
} catch (error) {
const { jsonpointer } = error.position || {}
if (jsonpointer) {
const position = yamlPosition(metadataYaml, jsonpointer)
Object.assign(error.position, position || {})
}
this.emitError(error)
}
}
this.metadataBlock = null
}
expandSlotValue(slot, value, schema) {
try {
return parseSlotValue(slot, value, schema, this.curieMap)
} catch (e) {
this.emitError(e, { value, line: this.lineNumber })
}
}
emitMapping(mapping) {
for (let [slot,value] of Object.entries(mapping)) {
if (this.failed) { // stop on first malformed field
break
} else if (value !== "") {
mapping[slot] = this.expandSlotValue(slot, value, mappingSchema)
}
}
// remove empty slots
Object.keys(mapping).filter(slot => !mapping[slot]).forEach(slot => delete mapping[slot])
if (!this.failed) {
try {
validateMapping(mapping, this.liberal)
} catch(error) {
this.emitError(error, { line: this.lineNumber })
}
}
if (!this.failed) {
if (this.propagate) {
mapping = propagate(this.metadata, mapping)
}
if (this.metadata.mappings) {
this.metadata.mappings.push(mapping)
}
this.emit("mapping", mapping)
}
}
onLine(line) {
this.lineNumber++
if (this.failed) {
return
} // avoid parsing the whole chunk
if (this.metadataBlock) { // metadata block
if (line[0] === "#") {
if (!this.metadataPrefix) { // first line
this.metadataPrefix = /# */.exec(line)[0]
}
if (line.startsWith(this.metadataPrefix)) {
this.metadataBlock.push(line.slice(this.metadataPrefix.length))
} else {
this.emitError("Wrong number of prefix space characters", { line: this.lineNumber })
this.failed = true
}
return
} else {
this.emitMetadata()
}
}
this.tsv.write(line + "\n")
}
init() {
this.input.on("data", chunk => {
this.remaining += chunk
let index
while ((index = this.remaining.indexOf("\n")) > -1) {
const line = this.remaining.slice(0, index)
this.remaining = this.remaining.slice(index + 1)
this.onLine(line)
}
})
this.input.on("end", () => {
if (this.remaining.length > 0) {
this.onLine(this.remaining)
}
if (this.metadataBlock) {
if (!this.metadataBlock.length && !this.liberal) {
this.emitError("Input is empty", { line: 1 })
}
this.emitMetadata()
}
if (this.headerLineRead || this.liberal || this.failed) {
this.emit("end", this.metadata)
} else {
this.emitError("Missing mappings block")
}
})
this.tsv.on("error", this.emitError)
this.tsv.on("headers", headers => {
let { required } = mappingSchema
if (this.liberal) {
required = required.filter(slot => slot !== "mapping_justification")
}
const missing = required.filter(slot => !headers.includes(slot))
if (missing.length == 1) {
this.emitError("Missing mapping slot", { line: this.lineNumber, value: missing[0] })
} else if (missing.length > 1) {
this.emitError("Missing mapping slots", { line: this.lineNumber, value: missing.join(", ") })
}
this.headerLineRead = true
})
this.tsv.on("data", data => this.emitMapping(data))
}
}