wikidata-jskos
Version:
Access Wikidata in JSKOS format
378 lines (331 loc) • 11.5 kB
JavaScript
import { selectedLanguages } from "./utils.js"
import { mappingTypeIds } from "./types.js"
import { mapMappingClaims, mapIdentifier } from "./mappers.js"
import * as jskos from "jskos-tools"
import debug from "./debug.js"
import getMappingsFrom from "./queries/get-mappings-from.js"
import getMappingsTo from "./queries/get-mappings-to.js"
import getMappingList from "./queries/get-mapping-list.js"
import wdk from "./wdk.js"
import WikidataConceptSchemeSet from "./wikidata-concept-scheme-set.js"
import { httpRequest } from "./request.js"
import ServiceError from "./service-error.js"
import getWdEdit from "./wbedit.js"
import wikidata from "./wikidata.js"
const { addMappingIdentifiers } = jskos
/**
* Returns a single Wikidata claim object by its id.
*
* @param {*} id
*/
async function getClaim(id) {
const qid = id.split("$")[0].toUpperCase()
const url = wdk.getEntities({
ids: [qid],
languages: [],
props: ["claims"],
})
const claims = (await httpRequest(url)).entities[qid].claims || {}
let claim
for (let values of Object.values(claims)) {
claim = values.find(value => value.id == id) || claim
}
return claim
}
/**
* Provides access to Wikidata in JSKOS format.
*/
export default class WikidataService extends WikidataConceptSchemeSet {
constructor (schemes) {
schemes.push(wikidata) // make sure Wikidata itself is always defined
super(schemes)
}
/**
* Promise an array of JSKOS Mappings from Wikidata.
*/
getMappings (query) {
// cleanup query
Object.keys(query).forEach(key => {
if (query[key] === "" || query[key] === null || query[key] === undefined) {
delete query[key]
}
})
if (query.direction === "backward") {
[query.from, query.fromScheme, query.to, query.toScheme] =
[query.to, query.toScheme, query.from, query.fromScheme]
delete query.direction
}
// Return empty list if partOf is present (no support for concordances yet)
if (query.partOf) {
return Promise.resolve([])
}
if (query.fromScheme) {
query.fromScheme = this.detectWikidataConceptScheme(query.fromScheme)
// Return empty list if incompatible scheme is queried
if (!query.fromScheme) {
return Promise.resolve([])
}
}
if (query.toScheme) {
query.toScheme = this.detectWikidataConceptScheme(query.toScheme)
// Return empty list if incompatible scheme is queried
if (!query.toScheme) {
return Promise.resolve([])
}
}
query.languages = selectedLanguages(query)
if (!("from" in query) && !("to" in query)) {
return getMappingList(query, this)
}
if (!query.fromScheme && wdk.isEntityId(query.from)) {
query.from = "http://www.wikidata.org/entity/" + query.from
query.fromScheme = "http://bartoc.org/en/node/1940"
}
if (!query.toScheme && wdk.isEntityId(query.to)) {
query.to = "http://www.wikidata.org/entity/" + query.to
query.toScheme = "http://bartoc.org/en/node/1940"
}
debug.query(query)
let promises = []
if (query.mode === "or") {
const { from, to, fromScheme, toScheme } = query
delete query.to
delete query.toScheme
promises.push(getMappingsFrom(query, this))
query.to = to
query.toScheme = toScheme
delete query.from
delete query.fromScheme
promises.push(getMappingsTo(query, this))
query.from = from
query.fromScheme = fromScheme
} else {
if (query.from) {
promises.push(getMappingsFrom(query, this))
} else if (query.to || query.to === "0") {
promises.push(getMappingsTo(query, this))
}
}
if (query.direction === "both") {
query.direction = "backward"
promises.push(this.getMappings(query))
}
// combine results and collect unique mappings
return Promise.all(promises)
.then(results => results.reduce((all, content) => all.concat(content), [])
.map(addMappingIdentifiers)
.filter((mapping, index, self) => {
const same = m => m.identifier[0] === mapping.identifier[0]
return self.findIndex(same) === index
}),
)
}
/**
* Convert a JSKOS mapping into a Wikidata claim.
*
* Only 1-to-1 mappings from Wikidata to another concept scheme are supported.
* JSKOS fields fromScheme and toScheme are ignored.
*/
mapMapping(mapping, options = {}) {
const { simplify } = options
if (!mapping.from || !mapping.to ||
!mapping.from.memberSet || !mapping.to.memberSet) {
throw new ServiceError("incomplete JSKOS mapping object")
} else if (mapping.from.memberSet.length != 1 || mapping.to.memberSet.length != 1) {
throw new ServiceError("only 1-to-1 mapping are supported")
}
const from = mapping.from.memberSet[0]
const to = mapping.to.memberSet[0]
const id = wikidata.notationFromUri(from.uri)
if (!id) {
throw new ServiceError("mapping must be from a Wikidata item")
}
const target = this.conceptFromUri(to.uri)
if (!target) {
throw new ServiceError("failed to detect URI: "+to.uri)
}
const property = this.getPropertyOfScheme(target.inScheme[0].uri)
if (!property) {
throw new ServiceError("failed to find Wikidata property")
}
const claim = simplify ? {
value: target.notation[0],
} : {
type: "statement",
mainsnak: {
snaktype: "value",
property: property,
datavalue: {
value: target.notation[0],
type: "string",
},
},
}
const regex = new RegExp("^http://www\\.wikidata\\.org/entity/statement/"+id+"-[0-9a-f-]+$","i")
if (mapping.uri && regex.test(mapping.uri)) {
claim.id = mapping.uri.substr(41).replace("-","$")
}
if (mapping.type && mapping.type[0] in mappingTypeIds) {
const typeId = mappingTypeIds[mapping.type[0]]
claim.qualifiers = simplify ? {
P4390: typeId,
} : {
P4390: [ {
snaktype: "value",
property: "P4390",
datavalue: {
type: "wikibase-entityid",
value: {
id: typeId,
"numeric-id": parseInt(typeId.substr(1)),
"entity-type": "item",
},
},
} ],
}
}
return {
id,
claims: { [property]: simplify ? claim : [ claim ] },
}
}
/**
* Returns a single mapping for a Wikidata claim ID.
*
* @param {*} _id - Wikidata claim ID for the mapping to be returned (req.params._id)
*/
async getMapping(_id) {
let id = _id.replace("-", "$")
let qid = id.split("$")[0].toUpperCase()
const claim = await getClaim(id)
let mapping
if (claim) {
mapping = mapMappingClaims({ [claim.mainsnak.property]: [claim] }, { from: mapIdentifier(qid), schemes: this })[0]
}
if (!mapping) {
throw new ServiceError(`Mapping with ID ${_id} could not be found.`, 404)
}
return mapping
}
/**
* Saves a new mapping or edits an existing mapping. Returns the saved/edited mapping.
*
* The `params` object has to contain the following properties:
* - body: The body of the request (req.body)
* - user: The authorized user for the request (req.user)
* For PUT requests (editing an existing mapping), the following property is required:
* - _id: Wikidata claim ID for the mapping to be edited (req.params._id)
*
* @param {*} params
*/
async saveMapping({ _id, body, user }) {
let mapping = body
if (!mapping) {
throw new ServiceError("Missing body with mapping.", 400)
}
if (_id && _id.split("-")[0] !== mapping.from?.memberSet?.[0]?.uri?.replace(wikidata.namespace, "")) {
throw new ServiceError("Source concept on an existing mapping/claim can't be changed.", 400)
}
// Convert mapping to claim (or rather an entity with claims)
let entity = this.mapMapping(mapping, { simplify: true })
let props = Object.keys(entity.claims)
if (props.length == 0) {
throw new ServiceError(`No claim could be found after converting mapping for ${entity.id}.`, 400)
}
if (props.length > 1) {
console.warn("More than one claim was found after converting mapping, should be only one.", entity)
}
const prop = props[0]
const { value, qualifiers } = entity.claims[prop]
const qualifierProp = "P4390"
const qualifierValue = qualifiers && qualifiers[qualifierProp]
let id = _id && _id.replace("-", "$")
// Find existing claim with the value (only for POST)
if (!id) {
const claimsBefore = ((await httpRequest(wdk.getEntities({
ids: [entity.id],
languages: [],
props: ["claims"],
}))).entities[entity.id].claims || {})[props] || []
// If a claim with the same target already exists, overwrite that claim (i.e. turn POST into a PUT request)
const existingClaim = claimsBefore.find(c => c.mainsnak.property == prop && c.mainsnak.datavalue.value == entity.claims[prop].value)
if (existingClaim) {
id = existingClaim.id
}
}
// Find existing qualifiers
let existingQualifierValue, existingQualifierHash
if (id) {
const claim = await getClaim(id)
const qualifier = ((claim && claim.qualifiers && claim.qualifiers[qualifierProp]) || [])[0]
if (qualifier) {
existingQualifierHash = qualifier.hash
existingQualifierValue = qualifier.datavalue.value.id
}
}
const wdEdit = getWdEdit(user)
let result
if (id) {
// PUT
result = await wdEdit.claim.update({
guid: id,
newValue: value,
})
} else {
// POST
result = await wdEdit.claim.create({
id: entity.id,
property: prop,
value,
})
}
if (result.success != 1) {
console.error(`Error updating claim for entity ${entity.id} (prop: ${prop}, id: ${id}).`)
}
id = (result && result.claim.id) || id
// Set/update/remove qualifier
if (qualifierValue) {
if (existingQualifierValue && existingQualifierValue != qualifierValue) {
result = await wdEdit.qualifier.update({
guid: id,
property: qualifierProp,
oldValue: existingQualifierValue,
newValue: qualifierValue,
})
} else if (!existingQualifierValue) {
result = await wdEdit.qualifier.set({
guid: id,
property: qualifierProp,
value: qualifierValue,
})
}
} else if (existingQualifierHash && !qualifierValue) {
// Remove qualifier
result = await wdEdit.qualifier.remove({
guid: id,
hash: existingQualifierHash,
})
}
if (result.success != 1) {
console.error(`Error updating qualifier for claim ${id}.`)
}
return this.getMapping(id.replace("$", "-"))
}
/**
* Deletes a mapping.
*
* The `params` object has to contain the following properties:
* - _id: Wikidata claim ID for the mapping to be deleted (req.params._id)
* - user: The authorized user for the request (req.user)
*
* @param {*} params
*/
deleteMapping({ _id, user }) {
let id = _id.replace("-", "$")
const wdEdit = getWdEdit(user)
if (!wdEdit) {
throw new Error("Unauthorized user, possibly missing the Wikidata indentity.", 403)
}
return wdEdit.claim.remove({ guid: id }).then(() => true)
}
}