/**
* Module to calculate JSKOS mapping identifiers.
*/
import sha1 from "./sha1.js"
// Reduce JSKOS set to members with URI.
function reduceSet(set) {
return set.map(member => member && member.uri).filter(Boolean)
}
// Tell which concept bundle field is used.
function memberField(bundle) {
return ["memberSet", "memberList", "memberChoice"].find(f => bundle[f])
}
// Reduce JSKOS concept bundle to memberSet/List/Choice with member URIs only.
function reduceBundle(bundle) {
const field = memberField(bundle)
const set = bundle[field] ? reduceSet(bundle[field]) : []
return {
[set.length > 1 ? field : "memberSet"]: set.map(uri => ({uri})),
}
}
// Reduce mapping to reduced fields from, to, and type.
export function mappingContent(mapping) {
const { from, to, type } = mapping
let result = {
from: reduceBundle(from || {}),
to: reduceBundle(to || {}),
type: [
type && type[0] || "http://www.w3.org/2004/02/skos/core#mappingRelation",
],
}
for (let side of ["from", "to"]) {
if ((result[side][memberField(result[side])] || []).length == 0) {
let scheme = mapping[side + "Scheme"]
if (scheme && scheme.uri) {
// Create new object to remove all unnecessary properties.
result[side + "Scheme"] = { uri: scheme.uri }
}
}
}
return result
}
// Get a sorted list of member URIs.
export function mappingMembers(mapping) {
const { from, to } = mapping
const memberUris = [ from, to ].filter(Boolean)
.map(bundle => reduceSet(bundle[memberField(bundle)] || []))
return [].concat(...memberUris).sort()
}
/**
* Returns a mapping content identifier. The identifier starts with `urn:jskos:mapping:content:`
* and takes concepts and type into consideration. It uses the `mappingContent` function to get
* relevant properties from the mapping.
* @memberof module:jskos-tools
*/
export function mappingContentIdentifier(mapping) {
const json = JSON.stringify(mappingContent(mapping), ["from","fromScheme","to","toScheme","type","memberSet","memberList","memberChoice","uri"])
return "urn:jskos:mapping:content:" + sha1(json+"\n")
}
/**
* @memberof module:jskos-tools
*/
export function mappingMembersIdentifier(mapping) {
const json = JSON.stringify(mappingMembers(mapping))
return "urn:jskos:mapping:members:" + sha1(json+"\n")
}
/**
* Returns a hex SHA-256 digest of a UTF-8 input string.
*
* Uses `globalThis.crypto.subtle` (browsers, Node >= 19). Falls back to
* `webcrypto.subtle` for Node 18.
*
* @param {string} input
* @returns {Promise<string>}
*/
const getSHA256Hash = async (input) => {
let subtle
if (globalThis.crypto?.subtle) {
subtle = globalThis.crypto.subtle
} else {
const { webcrypto } = await import("node:crypto")
subtle = webcrypto.subtle
}
const textAsBuffer = new TextEncoder().encode(input)
const hashBuffer = await subtle.digest("SHA-256", textAsBuffer)
const hashArray = Array.from(new Uint8Array(hashBuffer))
const hash = hashArray
.map(b => b.toString(16).padStart(2, "0"))
.join("")
return hash
}
/**
* Compares two strings by Unicode code point order
* @param {string} left
* @param {string} right
* @returns {number} Negative, zero, or positive.
*/
function codePointCompare (left, right) {
const leftIter = left[Symbol.iterator]()
const rightIter = right[Symbol.iterator]()
for (;;) {
const { value: leftChar } = leftIter.next()
const { value: rightChar } = rightIter.next()
if (leftChar === undefined && rightChar === undefined) {
return 0
} else if (leftChar === undefined) {
// left is a prefix of right.
return -1
} else if (rightChar === undefined) {
// right is a prefix of left.
return 1
}
const leftCodepoint = leftChar.codePointAt(0)
const rightCodepoint = rightChar.codePointAt(0)
if (leftCodepoint < rightCodepoint) {
return -1
}
if (leftCodepoint > rightCodepoint) {
return 1
}
}
}
/**
* Returns a mapping sameness identifier
* The identifier starts with `mapping:` followed by a SHA-256 hex digest,
* and ends with `~` when `negativity` is true.
* @memberof module:jskos-tools
* @param {{ subjects: string[], objects: string[], predicate: string, negativity: boolean }} mapping
* @returns {Promise<string>}
*/
export async function mappingSamenessIdentifier(mapping) {
const { subjects, objects, predicate, negativity } = mapping
subjects.sort(codePointCompare)
objects.sort(codePointCompare)
const str = [subjects.join("|"), predicate, objects.join("|")].join(" ")
const digest = await getSHA256Hash(str)
return `mapping:${digest}${negativity ? "~" : ""}`
}
/**
* @memberof module:jskos-tools
*/
export async function addMappingIdentifiers(mapping) {
const fromField = memberField(mapping.from || {})
const toField = memberField(mapping.to || {})
const subjects = fromField ? reduceSet(mapping.from[fromField]) : []
const objects = toField ? reduceSet(mapping.to[toField]) : []
const predicate = mapping.type?.[0]
?? "http://www.w3.org/2004/02/skos/core#mappingRelation"
const identifier = (mapping.identifier || []).filter(
id => id && !id.startsWith("urn:jskos:mapping:") && !id.startsWith("mapping:"),
).concat([
mappingMembersIdentifier(mapping),
mappingContentIdentifier(mapping),
await mappingSamenessIdentifier({ subjects, objects, predicate, negativity: false }),
]).sort()
return Object.assign({}, mapping, {identifier})
}
function compare(mapping1, mapping2, prefix) {
let id1 = mapping1 ? (prefix === "urn:jskos:mapping:content:" ? mappingContentIdentifier(mapping1) : mappingMembersIdentifier(mapping1)) : null
let id2 = mapping2 ? (prefix === "urn:jskos:mapping:content:" ? mappingContentIdentifier(mapping2) : mappingMembersIdentifier(mapping2)) : null
return id1 == id2
}
/**
* @memberof module:jskos-tools
*/
export function compareMappings(mapping1, mapping2) {
return compare(mapping1, mapping2, "urn:jskos:mapping:content:")
}
export const compareMappingContent = compareMappings
/**
* @memberof module:jskos-tools
*/
export function compareMappingMembers(mapping1, mapping2) {
return compare(mapping1, mapping2, "urn:jskos:mapping:members:")
}