// ==UserScript==
// @author µKöff
// @grant GM_notification
// @grant GM_registerMenuCommand
// @homepageURL https://gitlab.com/muekoeff/wikidatapain/
// @icon https://www.wikidata.org/static/favicon/wikidata.ico
// @license MIT
// @match https://test.wikidata.org/*
// @match https://www.wikidata.org/*
// @name WikidataPain
// @namespace https://muekev.de/
// @version 1.8
// ==/UserScript==
/**
* Main class
*
* @hideconstructor
*/
class WikidataPain {
/**
* API-endpoint used for API calls.
*
* @type {string}
*/
static ENDPOINT = `https://${window.location.hostname === 'test.wikidata.org' ? 'test.wikidata.org' : 'www.wikidata.org'}/w/api.php`;
/**
* Maxlag used when making API calls.
*
* @type {number}
*/
static MAXLAG = 10;
static PROPERTY_REASON_FOR_DEPRECATION = 'P2241';
static PROPERTY_REASON_FOR_PREFERENCE = 'P7452';
static RSOLVER = 'https://muekoeff.gitlab.io/rsolver/';
static SPARQL_ENDPOINT = 'https://query.wikidata.org/sparql';
/**
* Display a notification after every `n`-th finished action.
* Set to `false` to disable.
*
* @type {boolean|number}
*/
static NOTIFICATIONS_PROGRESS = 1;
static #batchStop = false;
static #interceptorLog = [];
static #interceptorOldPost = null;
static #outputPipeHandlerRegistered = false;
static #outputPipeReady = false;
/** @type {Promise<void>} */
static #taskWorker = null;
/**
* @type {Array<{
* action: function(): Promise<void?>,
* resolve: Function,
* reject: Function
* }>}
*/
static #taskWorkerQueue = [];
/**
* @typedef {Object} BatchResponse
* @property {Object} batchManager
* @property {function(function(number, Object, Object): void): boolean} batchManager.offFinishedItemCallback
* @property {function(function(number, Object, Object): void): void} batchManager.onFinishedItemCallback
* @property {function(!number): void} batchManager.setProbe Number of actions after each a notification is displayed allowing to prematurely cancel the batch.
* @property {function((?boolean|?number)): void} batchManager.setNotificationProgress Display a notification after every `n`-th finished action. Set to `false` to disable. Set to `null` to use global value.
* @property {function(): void} batchManager.stop
* @property {Promise<void>} promise
*/
/**
* @typedef {Object} DissectedTime
* @property {number} day
* @property {number} month
* @property {number} precision
* @property {string} time
* @property {number} year
*/
/**
* Returns whether a candidate statement should be matched.
* Returns `true`, if the statement should be matched, `false` if not and `null` for different purposes depending on the use of the matcher.
*
* @typedef {function(WikidataPain.Statement, WikidataPain.Statement, Object) : Promise<boolean>} QualifierMatcher
*/
/**
* @enum {WikidataPain.ComparisonMethod}
*/
static ComparisonMethod = {
/**
* All qualifiers of the object statement must be identical.
* And for every qualifier property in the object there must be a qualifier using the same property in the subject.
*/
equal: 'equal',
/**
* All qualifiers of the object statement must be equally or less precise.
* And for every qualifier property in the object there must be a qualifier using the same property in the subject.
*/
precision: 'precision'
};
/**
* @enum {function() : QualifierMatcher}
*/
static QualifierMatcherFactory = {
/**
* Accept all qualifiers.
*
* @return {QualifierMatcher}
*/
all: () => {
return async () => true;
},
/**
* Requires that the list of a candidate statement's qualifiers is a subset of the reference statement's qualifiers.
*
* @param {Object} [config]
* @param {Array<string>} [config.checkedProperties] Do not use, if `config.ignoredProperties` is set. Property-IDs to check. If not defined, then all properties are checked.
* @param {WikidataPain.ComparisonMethod} [config.comparisonMethod]
* @param {Array<string>} [config.ignoredProperties] Property-IDs to not check.
* @return {QualifierMatcher}
*/
candidateSubset: ( { checkedProperties = null, comparisonMethod = null, ignoredProperties = null } = {} ) => {
return async ( candidateStatement, wholeStatement ) => {
if ( candidateStatement.qualifiers === null ) {
// Candidate has no qualifiers
return true;
}
if ( candidateStatement.qualifiers !== null && wholeStatement.qualifiers === null ) {
// Whole has no qualifiers
return Object.keys( candidateStatement.qualifiers ).every( ( property ) => {
if ( ( ignoredProperties ?? null ) !== null ) {
// `true`, if property is blacklisted
return ignoredProperties.includes( property );
}
if ( ( checkedProperties ?? null ) !== null ) {
// `true`, if property is not whitelisted
return !checkedProperties.includes( property );
}
// `false`, since candidate has more qualifiers than whole
return false;
} );
}
/**
* @param {string} qualifierToCheck
* @return {boolean}
*/
const partialQualifierCheck = ( qualifierToCheck ) => {
if ( ( ignoredProperties ?? null ) !== null && ignoredProperties.includes( qualifierToCheck ) ) {
// If ignored, then accept
return true;
}
if ( ( checkedProperties ?? null ) !== null && !checkedProperties.includes( qualifierToCheck ) ) {
// If not whitelisted, then accept
return true;
}
return (
( Object.keys( wholeStatement.qualifiers ).includes( qualifierToCheck ) ) && // Whole has qualifier
candidateStatement.qualifiers[ qualifierToCheck ].every( ( partialSnak ) => {
return wholeStatement.qualifiers[ qualifierToCheck ].some( ( wholeSnak ) => {
if ( partialSnak.snaktype !== wholeSnak.snaktype ) {
// `false`, if snaktypes mismatch
return false;
}
if ( comparisonMethod === WikidataPain.ComparisonMethod.equal ) {
return WikidataPain.DataValue.fromRaw( partialSnak.datavalue ).equals( WikidataPain.DataValue.fromRaw( wholeSnak.datavalue ) );
} else {
return [ WikidataPain.PrecisionComparison.EqualValue, WikidataPain.PrecisionComparison.MorePrecise ].includes( WikidataPain.DataValue.fromRaw( partialSnak.datavalue ).comparePrecision( WikidataPain.DataValue.fromRaw( wholeSnak.datavalue ) ) );
}
} );
} )
);
};
return Object.keys( candidateStatement.qualifiers ).every( partialQualifierCheck );
};
}
};
/**
* @enum {WikidataPain.ReferenceMatchingCriterium}
*/
static ReferenceMatchingCriterium = {
/**
* There must be at least one claim that is shared by the subject and object.
*/
overlap: 'overlap',
/**
* All claims of the object reference must be equally or less precise.
* And for every qualifier property in the object there must be a qualifier using the same property in the subject.
*/
precision: 'precision'
};
/**
* @param {Object|string} [commands]
* @param {number} [probe]
* @return {Promise<void>}
*/
static async adhoc( commands = undefined, probe = undefined ) {
if ( commands === undefined || commands === null ) {
const commandsRaw = prompt( 'Please paste your commands.' );
if ( commandsRaw === null ) {
return;
}
try {
commands = JSON.parse( commandsRaw );
} catch ( ex ) {
alert( 'Unable to parse input.' );
return;
}
}
return await WikidataPain.batch( commands, async ( claim ) => {
return await WikidataPain.#adhocCommand( claim );
}, probe ).promise;
}
/**
* Executes a single adhoc-command.
*
* @param {Object} command
* @param {string} command.action
* @param {any} command.claim
* @param {Object} command.config
* @param {boolean} [command.config.allowCreate=false]
* @param {string} command.config.entity ID of entity. Use `current` for current entities' ID.
* @param {('new'|'refine')} command.config.method
* @param {WikidataPain.ComparisonMethod} [command.config.qualifierComparisonMethod=WikidataPain.ComparisonMethod.precision]
* @param {WikidataPain.ReferenceMatchingCriterium} [command.config.referenceRefinableCriterium=WikidataPain.ReferenceMatchingCriterium.overlap]
* @return {Promise<void>}
*/
static async #adhocCommand( command ) {
// Validate command
const config = command.config ?? null;
// Check for unknown properties
if ( config !== null ) {
const unknownKeys = Object.keys( config ).filter( ( key ) => {
return ![ 'allowCreate', 'entity', 'method', 'qualifierComparisonMethod', 'referenceRefinableCriterium' ].includes( key );
} );
if ( unknownKeys.length > 0 ) {
throw new Error( `There are unknown configuration properties: ${unknownKeys.sort().join( ', ' )}` );
}
}
// Expand entity ID, if needed
if ( config?.entity === 'current' ) {
config.entity = WikidataPain.Util.currentEntityId();
}
// Parse claim
let claim;
if ( typeof claim === 'string' ) {
claim = JSON.parse( claim );
} else {
claim = command.claim;
}
// Execute
switch ( command.action ) {
case 'wbsetclaim':
// Validate
if ( config !== null ) {
for ( const parameter of [ 'entity', 'method' ] ) {
if ( ( config[ parameter ] ?? null ) === null ) {
throw new Error( `${parameter} must be defined.` );
}
}
}
/** @type {string} */
const entity = config.entity;
const statement = WikidataPain.Statement.fromRawNew( entity, claim );
if ( config.method === 'new' ) {
return statement.commit();
} else if ( config.method === 'refine' ) {
return statement.refine( config?.allowCreate ?? false, {
qualifierComparisonMethod: config?.qualifierComparisonMethod ?? WikidataPain.ComparisonMethod.precision,
referenceRefinableCriterium: config?.referenceRefinableCriterium ?? WikidataPain.ReferenceMatchingCriterium.overlap
} );
}
break;
default:
throw new Error( `Unknown action ${command.action}.` );
}
}
/**
* @template T
* @param {Array<T>} claims
* @param {function(T): Promise<(string|void)?>} action
* @param {!number} [probe] Number of actions after each a notification is displayed allowing to prematurely cancel the batch.
* @param {Object} [config]
* @param {boolean|number} [config.notificationProgress] = Display a notification after every `n`-th finished action. Set to `false` to disable. Set to `null` to use global value.
* @return {BatchResponse}
*/
static batch( claims, action, probe = null, config = {} ) {
const batchManager = {
_finishedItemCallbacks: [],
_notificationProgress: config.notificationProgress,
_probe: probe ?? ( claims.length > 10 ? 1 : 0 ),
_stopped: false,
offFinishedItemCallback: function ( callback ) {
const index = this._finishedItemCallbacks.indexOf( callback );
if ( index !== -1 ) {
this._finishedItemCallbacks.splice( index, 1 );
return true;
} else {
return false;
}
},
onFinishedItemCallback: function ( callback ) {
this._finishedItemCallbacks.push( callback );
},
setProbe: function ( newValue ) {
this._probe = newValue;
},
setNotificationProgress: function ( newValue ) {
this._notificationProgress = newValue;
},
stop: function () {
this._stopped = true;
}
};
return {
manager: batchManager,
promise: new Promise( async () => {
for ( const [ i, claim ] of Object.entries( claims ) ) {
const index = parseInt( i );
let repeat;
do {
repeat = false;
if ( WikidataPain.#batchStop || batchManager._stopped ) {
alert( `Stopped at ${index + 1}/${claims.length}.` );
throw new Error( 'Batch cancelled' );
}
let result;
try {
await action( claim );
} catch ( ex ) {
let answer = null;
do {
if ( ( ex ?? null ) !== null ) {
console.error( ex );
}
let errorMessage = null;
if ( typeof ex === 'string' ) {
errorMessage = ex;
} else if ( typeof ex?.message === 'string' ) {
errorMessage = ex.message;
} else {
const jsonError = JSON.stringify( ex );
if ( jsonError !== '{}' ) {
errorMessage = jsonError;
}
}
answer = prompt( `${errorMessage !== null ? `${errorMessage}\n\n` : ''}Action failed for #${i} ${JSON.stringify( claim )}. Do you want to [c]ancel, [r]etry or [s]kip?` );
if ( answer !== null ) {
answer = answer.toLowerCase();
}
} while ( !/^[crs]$/.test( answer ) && answer !== null );
switch ( answer ) {
case 'c':
throw new Error( ex );
case 'r':
repeat = true;
continue;
case 's':
continue;
case null:
throw new Error( 'Batch cancelled' );
}
}
console.log( `${index + 1}/${claims.length} finished.` );
for ( const callback of batchManager._finishedItemCallbacks ) {
callback.call( batchManager, index, claim, claims );
}
const notificationProgress = batchManager._notificationProgress ?? WikidataPain.NOTIFICATIONS_PROGRESS;
if ( notificationProgress > 0 && ( index + 1 ) % +notificationProgress === 0 || notificationProgress !== false && index === 0 || notificationProgress !== false && index === claims.length - 1 ) {
GM_notification( `${index + 1}/${claims.length}${typeof result === 'string' ? ` ${result}` : ''}`, 'WikidataPain' );
}
if ( index < batchManager._probe && !confirm( `Finished ${index + 1}/${claims.length}. Probe-mode is enabled and set to ${batchManager._probe}.\n\nDo you wish to continue?` ) ) {
throw new Error( 'Batch cancelled' );
}
} while ( repeat );
}
} )
};
}
/**
* Stops all running batches after they've finished their currently running task.
*/
static batchRequestStop() {
WikidataPain.#batchStop = true;
}
/**
* Adds statements using QuickStatements.
*
* @param {string} commandBlock
* @param {Object} [config]
* @param {string} [config.editgroup]
* @param {number} [config.probe]
* @param {string} [config.summary]
* @return {Promise<Object>}
*/
static async createQuickStatements( commandBlock, { editgroup = null, probe = null, summary = null } = {} ) {
const qsCommands = commandBlock.split( '\n' ).map( ( line ) => {
return line.split( '\t' );
} );
const commands = await Promise.all( qsCommands.map( async ( command ) => {
return await WikidataPain.Util.quickStatementsLineToStatement( command );
} ) );
return await WikidataPain.batch( commands, ( command ) => {
return command.commit( {
editgroup: editgroup,
summary: summary
} );
}, probe ).promise;
}
/**
* Makes a generic GET-request to the Wikidata-MediaWiki-Actions-API.
*
* @param {Object} requestBody
* @return {Promise<Object>}
*/
static get( requestBody ) {
for ( const [ field, value ] of Object.entries(
{
format: 'json',
maxlag: WikidataPain.MAXLAG
}
) ) {
if ( !( field in requestBody ) ) {
requestBody[ field ] = value;
}
}
return new Promise( ( resolve, reject ) => {
$.ajax( {
data: requestBody,
method: 'GET',
url: WikidataPain.ENDPOINT
} ).done( /** @param {Object} data */ ( data ) => {
if ( !Object.prototype.hasOwnProperty.call( data, 'error' ) ) {
resolve( data );
} else {
console.error( data );
reject( new Error( `Wikidata API returned error: ${JSON.stringify( data.error )}` ) );
}
} ).fail( reject );
} );
}
/**
* @private
*/
static interceptorPrint() {
const logItems = WikidataPain.#interceptorLog.map( ( logItem ) => {
return JSON.stringify( logItem );
} ).join( ',\n' );
console.debug( `[${logItems}]` );
}
/**
* Toggles the interceptor.
*/
static interceptorToggle() {
if ( WikidataPain.#interceptorOldPost !== null ) {
unsafeWindow.mw.Api.prototype.post = WikidataPain.#interceptorOldPost;
WikidataPain.#interceptorOldPost = null;
GM_notification( 'Interceptor disabled', 'WikidataPain' );
} else {
WikidataPain.#interceptorOldPost = unsafeWindow.mw.Api.prototype.post;
unsafeWindow.mw.Api.prototype.post = WikidataPain.#intercept;
GM_notification( 'Interceptor enabled', 'WikidataPain' );
}
}
/**
* Makes a generic POST-request to the Wikidata-MediaWiki-Actions-API.
*
* @param {Object} requestBody
* @return {Promise<Object>}
*/
static post( requestBody ) {
for ( const [ field, value ] of Object.entries(
{
format: 'json',
maxlag: WikidataPain.MAXLAG
}
) ) {
if ( !( field in requestBody ) ) {
requestBody[ field ] = value;
}
}
return new Promise( ( resolve, reject ) => {
$.ajax( {
data: requestBody,
method: 'POST',
url: WikidataPain.ENDPOINT
} ).done( /** @param {Object} data */ ( data ) => {
if ( !Object.prototype.hasOwnProperty.call( data, 'error' ) ) {
resolve( data );
} else {
console.error( data );
reject( new Error( data ) );
}
} ).fail( reject );
} );
}
/**
* Makes a generic POST-request with a CSRF-token to the Wikidata-MediaWiki-Actions-API.
*
* @param {Object} requestBody
* @return {Promise<Object>}
*/
static async postCsrf( requestBody ) {
const token = await WikidataPain.queryCsrfToken();
$.extend( requestBody, {
token: token
} );
return await WikidataPain.post( requestBody );
}
/**
* Requests a CSRF-token from the Wikidata-API.
*
* @return {Promise<string>} CSRF-token.
*/
static async queryCsrfToken() {
const data = await WikidataPain.get( {
action: 'query',
meta: 'tokens',
type: 'csrf'
} );
return data.query.tokens.csrftoken;
}
/**
* @param {!string} entity
* @param {!string} property
* @param {Object} [config]
* @param {string} [config.editgroup=null]
* @param {string} [config.summary=null]
* @return {Promise<Array<WikidataPain.Statement>>} Other statements sharing the same property that have been edited.
*/
static async rankByReasonInstant( entity, property, { editgroup = null, summary = null } = {} ) {
const statements = await WikidataPain.Statement.fromEntityCollapsed( entity, property );
const withReason = statements.filter( async ( statement ) => {
const qualifiers = await statement.getQualifiers();
return WikidataPain.PROPERTY_REASON_FOR_DEPRECATION in qualifiers || WikidataPain.PROPERTY_REASON_FOR_PREFERENCE in qualifiers;
} );
if ( withReason.length === 0 ) {
throw new Error( 'Found no statements with reason for deprecation/preference' );
}
const edited = [];
for ( const statement of statements ) {
const qualifiers = await statement.getQualifiers();
let newRank;
if ( WikidataPain.PROPERTY_REASON_FOR_DEPRECATION in qualifiers ) {
newRank = WikidataPain.Rank.Deprecated;
} else if ( WikidataPain.PROPERTY_REASON_FOR_PREFERENCE in qualifiers ) {
newRank = WikidataPain.Rank.Preferred;
} else {
newRank = WikidataPain.Rank.Normal;
}
const oldRank = await statement.getRank();
if ( newRank !== oldRank ) {
await statement.setRankInstant( newRank, oldRank, {
editgroup: editgroup,
summary: summary
} );
edited.push( statement );
}
}
return edited;
}
/**
* Adds statements using QuickStatements.
*
* @param {string} commandBlock
* @param {boolean} [allowCreate]
* @param {Object} [config]
* @param {string} [config.editgroup]
* @param {number} [config.probe]
* @param {WikidataPain.ComparisonMethod} [config.qualifierComparisonMethod]
* @param {QualifierMatcher} [config.qualifierMatcher]
* @param {WikidataPain.ReferenceMatchingCriterium} [config.referenceRefinableCriterium=WikidataPain.ReferenceMatchingCriterium.overlap]
* @param {string} [config.summary]
* @return {Promise<Object>}
*/
static async refineQuickStatements( commandBlock, allowCreate = false, { editgroup = null, probe = null, qualifierComparisonMethod = null, qualifierMatcher = null, referenceRefinableCriterium = null, summary = null } = {} ) {
const qsCommands = commandBlock.split( '\n' ).map( ( line ) => {
return line.split( '\t' );
} ).filter( ( line ) => {
return line.length > 0 && !( line.length === 1 && line[ 0 ] === '' );
} );
const commands = await Promise.all( qsCommands.map( async ( command ) => {
return await WikidataPain.Util.quickStatementsLineToStatement( command );
} ) );
return await WikidataPain.batch( commands, async ( command ) => {
return await command.refine( allowCreate, {
editgroup: editgroup,
qualifierComparisonMethod: qualifierComparisonMethod,
qualifierMatcher: qualifierMatcher,
referenceRefinableCriterium: referenceRefinableCriterium,
summary: summary
} );
}, probe ).promise;
}
/**
* @param {string} [commandBlock]
* @param {boolean} [allowCreate]
* @param {Object} [config]
* @param {boolean} [config.confirmBefore]
* @param {string} [config.editgroup]
* @param {number} [config.probe]
* @param {WikidataPain.ComparisonMethod} [config.qualifierComparisonMethod]
* @param {QualifierMatcher} [config.qualifierMatcher]
* @param {WikidataPain.ReferenceMatchingCriterium} [config.referenceRefinableCriterium]
* @return {Promise<void>}
*/
static async refineRsolver( commandBlock = null, allowCreate = false, { confirmBefore = null, editgroup = null, probe = null, qualifierComparisonMethod = null, qualifierMatcher = null, referenceRefinableCriterium = null } = {} ) {
const resolved = await WikidataPain.rsolver( commandBlock );
WikidataPain.taskWorker( async () => {
if ( resolved.length === 0 ) {
return;
}
if ( confirmBefore && !confirm( 'Received statements from Rsolver.\nDo you wish to commit them? If not, they are discarded.' ) ) {
return;
}
await WikidataPain.refineQuickStatements( resolved, allowCreate, {
editgroup: editgroup,
probe: probe,
qualifierComparisonMethod: qualifierComparisonMethod,
qualifierMatcher: qualifierMatcher,
referenceRefinableCriterium: referenceRefinableCriterium
} );
} );
}
/**
* Resolves a command block as string using Rsolver.
*
* @param {string} [commandBlock]
* @return {Promise<string>}
*/
static rsolver( commandBlock = null ) {
return new Promise( async ( resolve, reject ) => {
const MAX_ATTEMPTS = 10;
if ( WikidataPain.#outputPipeHandlerRegistered ) {
reject( Error( 'Currently only one pipe to Rsolver may be opened per session' ) );
}
// Open window
const popup = window.open( WikidataPain.RSOLVER );
if ( popup === null ) {
reject( Error( 'Failed to open Rsolver, enable pop-ups if your browser requests you to' ) );
}
// Add event handler
if ( !WikidataPain.#outputPipeHandlerRegistered ) {
WikidataPain.#outputPipeHandlerRegistered = true;
window.addEventListener( 'message', ( e ) => {
switch ( e.data.type ) {
case 'rsolver.outputPipe':
resolve( e.data.data.success );
break;
case 'rsolver.outputPipeRegistered':
WikidataPain.#outputPipeReady = true;
break;
}
} );
}
// Enable output pipe
let attempts = 0;
do {
console.log( 'Attempting to enable output pipe in Rsolver' );
attempts++;
popup.postMessage( {
type: 'outputPipeEnable',
pipeIdentifier: WikidataPain.Util.randomUuidV4()
}, WikidataPain.RSOLVER );
if ( !WikidataPain.#outputPipeReady ) {
await WikidataPain.Util.sleep( 750 );
}
} while ( !WikidataPain.#outputPipeReady && attempts < MAX_ATTEMPTS );
if ( WikidataPain.#outputPipeReady ) {
// Success!
console.log( 'Output pipe successfully enabled' );
if ( commandBlock !== null ) {
popup.postMessage( {
type: 'setInput',
value: commandBlock
}, WikidataPain.RSOLVER );
}
}
} );
}
/**
* Maintains a shared queue for all tasks registered using this method.
*
* @param {function(): Promise<void>} action
* @return {Promise<void>}
*/
static async taskWorker( action ) {
await new Promise( ( resolve, reject ) => {
// Add to queue
WikidataPain.#taskWorkerQueue.push( {
action: action,
resolve: resolve,
reject: reject
} );
if ( WikidataPain.#taskWorker === null ) {
WikidataPain.#taskWorkerWork();
}
} );
}
static #intercept( parameters ) {
if ( parameters && parameters.action !== undefined ) {
switch ( parameters.action ) {
case 'wbsetclaim':
console.log( JSON.stringify( 'wbsetclaim' ), JSON.parse( parameters.claim ) );
const outClaim = JSON.parse( parameters.claim );
delete outClaim.id;
WikidataPain.#interceptorLog.push( {
action: 'wbsetclaim',
config: {
allowCreate: true,
entity: 'current',
method: 'refine'
},
claim: outClaim
} );
break;
}
return unsafeWindow.mw.loader.using( 'oojs-ui-core' ).then( () => {
return unsafeWindow.OO.ui.confirm( 'Action has been intercepted. Do you want to save it to Wikidata?', {
size: 'medium'
} ).done( ( confirmed ) => {
if ( confirmed ) {
return WikidataPain.#interceptorOldPost.apply( this, arguments );
}
} );
} );
} else {
return WikidataPain.#interceptorOldPost.apply( this, arguments );
}
}
static async #taskWorkerWork() {
do {
// Get next task and start
const nextTask = WikidataPain.#taskWorkerQueue.shift();
WikidataPain.#taskWorker = nextTask.action();
// Wait until finished
try {
await WikidataPain.#taskWorker;
nextTask.resolve();
} catch ( ex ) {
nextTask.reject( ex );
}
} while ( WikidataPain.#taskWorkerQueue.length > 0 );
WikidataPain.#taskWorker = null;
}
}
/**
* @class
*/
WikidataPain.DataValue = class {
/** @type {WikidataPain.DataValue.Type} */
type = null;
/** @type {Object} */
value = null;
/**
* @hideconstructor
* @param {string} type
* @param {Object} value
*/
constructor( type, value ) {
this.type = type;
this.value = value;
}
/**
* @param {string} entityId
* @return {WikidataPain.DataValue}
*/
static fromEntity( entityId ) {
return new WikidataPain.DataValue( WikidataPain.DataValue.Type.WikibaseEntityid, {
id: entityId
} );
}
/**
* @param {string} languageCode
* @param {string} text
* @return {WikidataPain.DataValue}
*/
static fromMonolingualText( languageCode, text ) {
return new WikidataPain.DataValue( WikidataPain.DataValue.Type.Monolingualtext, {
language: languageCode,
text: text
} );
}
/**
* @param {string} amount
* @param {string} precision
* @param {string} unit
* @return {WikidataPain.DataValue}
*/
static fromQuantity( amount, precision, unit ) {
return new WikidataPain.DataValue( WikidataPain.DataValue.Type.Quantity, {
amount: amount,
precision: precision,
unit: unit
} );
}
/**
* @param {Object} raw
* @return {WikidataPain.DataValue}
*/
static fromRaw( raw ) {
return new WikidataPain.DataValue( raw.type, raw.value );
}
/**
* @param {number|string} string
* @return {WikidataPain.DataValue}
*/
static fromString( string ) {
return new WikidataPain.DataValue( WikidataPain.DataValue.Type.String, String( string ) );
}
/**
* @param {number|string} year Year, or alternatively date string in the format `yyyy-mm-dd`.
* @param {number} [month=null]
* @param {number} [day=null]
* @return {WikidataPain.DataValue}
*/
static fromTime( year, month = null, day = null ) {
if ( month === null && day === null && typeof year === 'string' && year.match( /\d{4}-\d{2}-\d{2}/ ) !== null ) {
day = parseInt( year.split( '-' )[ 2 ] );
month = parseInt( year.split( '-' )[ 1 ] );
if ( month === 0 ) {
month = null;
}
year = parseInt( year.split( '-' )[ 0 ] );
if ( year === 0 ) {
year = null;
}
}
let precision;
if ( month === null ) {
precision = WikidataPain.Precision.Year;
} else if ( day === null ) {
precision = WikidataPain.Precision.Month;
} else {
precision = WikidataPain.Precision.Day;
}
return new WikidataPain.DataValue( WikidataPain.DataValue.Type.Time, {
after: 0,
before: 0,
calendarmodel: 'http://www.wikidata.org/entity/Q1985727',
precision: precision,
time: `+${year}-${precision >= WikidataPain.Precision.Month ? month.toString().padStart( 2, '0' ) : '00'}-${precision >= WikidataPain.Precision.Day ? day.toString().padStart( 2, '0' ) : '00'}T00:00:00Z`,
timezone: 0
} );
}
/**
* Convert a QuickStatement-date-string to a time-object.
*
* @param {string} date Timestamp in QuickStatement-format.
* @return {WikidataPain.DataValue}
*/
static fromTimestamp( date ) {
const components = WikidataPain.Util.dissectQsTimestamp( date );
return new WikidataPain.DataValue( WikidataPain.DataValue.Type.Time, {
after: 0,
before: 0,
calendarmodel: 'http://www.wikidata.org/entity/Q1985727',
precision: components.precision,
time: components.time,
timezone: 0
} );
}
/**
* @param {number} precision
* @return {WikidataPain.DataValue}
*/
static fromToday( precision ) {
const date = new Date();
return WikidataPain.DataValue.fromTime( date.getFullYear(), precision >= 10 ? date.getMonth() + 1 : null, precision >= 11 ? date.getDate() : null );
}
/**
* Compares the precision of this DataValue with another DataValue's precision.
*
* @param {WikidataPain.DataValue} other DataValue to compare to.
* @return {WikidataPain.PrecisionComparison}
*/
comparePrecision( other ) {
if ( this.type !== other.type ) {
return WikidataPain.PrecisionComparison.IncomparableValue;
}
switch ( this.type ) {
case WikidataPain.DataValue.Type.Monolingualtext:
if ( this.value.language === other.value.language && this.value.text === other.value.text ) {
return WikidataPain.PrecisionComparison.EqualValue;
} else {
return WikidataPain.PrecisionComparison.IncomparableValue;
}
case WikidataPain.DataValue.Type.Quantity:
if ( ( this.value.precision ?? null ) === ( other.value.precision ?? null ) && ( this.value.unit ?? null ) === ( other.value.unit ?? null ) ) {
if ( this.value.amount === other.value.amount ) {
return WikidataPain.PrecisionComparison.EqualValue;
} else {
return WikidataPain.PrecisionComparison.IncomparableValue;
}
} else {
throw new Error( 'Not implemented' );
}
case WikidataPain.DataValue.Type.String:
if ( this.value === other.value ) {
return WikidataPain.PrecisionComparison.EqualValue;
} else {
return WikidataPain.PrecisionComparison.IncomparableValue;
}
case WikidataPain.DataValue.Type.Time:
if ( this.value.after !== other.value.after ||
this.value.before !== other.value.before ||
this.value.calendarmodel !== other.value.calendarmodel ||
this.value.timezone !== other.value.timezone ) {
return WikidataPain.PrecisionComparison.IncomparableValue;
}
const overlap = timesOverlap( WikidataPain.Util.dissectQsTimestamp( `${this.value.time}/${this.value.precision}` ), WikidataPain.Util.dissectQsTimestamp( `${other.value.time}/${this.value.precision}` ), Math.min( this.value.precision, other.value.precision ) );
if ( !overlap ) {
return WikidataPain.PrecisionComparison.IncomparableValue;
} else if ( this.value.precision === other.value.precision ) {
return WikidataPain.PrecisionComparison.EqualValue;
} else if ( this.value.precision < other.value.precision ) {
return WikidataPain.PrecisionComparison.LessPrecise;
} else {
return WikidataPain.PrecisionComparison.MorePrecise;
}
case WikidataPain.DataValue.Type.WikibaseEntityid:
if ( this.value.id === other.value.id ) {
return WikidataPain.PrecisionComparison.EqualValue;
} else {
return WikidataPain.PrecisionComparison.IncomparableValue;
}
default:
if ( this.value === other.value ) {
return WikidataPain.PrecisionComparison.EqualValue;
} else {
return WikidataPain.PrecisionComparison.IncomparableValue;
}
}
/**
*
* @param {DissectedTime} lesser
* @param {DissectedTime} greater
* @param {WikidataPain.Precision} comparisonPrecision
* @return {boolean}
*/
function timesOverlap( lesser, greater, comparisonPrecision ) {
if ( lesser.year !== greater.year ||
comparisonPrecision >= WikidataPain.Precision.Month && lesser.month !== greater.month ||
comparisonPrecision >= WikidataPain.Precision.Day && lesser.day !== greater.day ) {
return false;
}
return true;
}
}
/**
* Compares this DataValue with another DataValue.
*
* @param {WikidataPain.DataValue} other DataValue to compare to.
* @return {boolean} `true` if equal, otherwise `false`.
*/
equals( other ) {
return this.comparePrecision( other ) === WikidataPain.PrecisionComparison.EqualValue;
}
/**
* @return {Object}
*/
toJSON() {
return {
type: this.type,
value: this.value
};
}
/**
* @return {string}
*/
toString() {
return this.toJSON().toString();
}
};
/**
* @enum {string}
*/
WikidataPain.DataValue.Type = Object.freeze( {
Monolingualtext: 'monolingualtext',
Quantity: 'quantity',
String: 'string',
Time: 'time',
WikibaseEntityid: 'wikibase-entityid'
} );
/**
* @enum {number}
*/
WikidataPain.Precision = Object.freeze( {
Year: 9,
Month: 10,
Day: 11
} );
/**
* @enum {string}
*/
WikidataPain.PrecisionComparison = Object.freeze( {
EqualValue: 'equalValue',
IncomparableValue: 'incomparableValue',
LessPrecise: 'lessPrecise',
MorePrecise: 'morePrecise'
} );
/**
* @enum {string}
*/
WikidataPain.Rank = Object.freeze( {
Deprecated: 'deprecated',
Normal: 'normal',
Preferred: 'preferred'
} );
/**
* @class
*/
WikidataPain.Reference = class {
/** @type {string} */
hash = null;
/** @type {Object.<string, Array<WikidataPain.Snak>>} */
snaks = null;
/** @type {Array<string>} */
snaksOrder = null;
/**
* @hideconstructor
* @param {Object.<string, Array<WikidataPain.Snak>>} snaks
* @param {Array<string>} snakOrder
*/
constructor( snaks, snakOrder ) {
this.snaks = snaks;
this.snaksOrder = snakOrder;
}
/**
* @param {string} hash
* @return {Promise<WikidataPain.Reference>}
*/
static async fromHash( hash ) {
hash = hash.replace( /^wdref:/, '' );
const statements = await WikidataPain.Reference.statementsByReferenceHash( hash );
if ( statements.length === 0 ) {
throw new Error( `Unable to find statements that have a reference with hash ${hash}. Maybe the Wikidata Query Service is lagging behind?` );
}
const exemplaryStatement = statements[ 0 ];
const match = ( await exemplaryStatement.getReferences() ).find( ( reference ) => {
return reference.hash === hash;
} );
if ( match === undefined ) {
throw new Error( `Statement didn't have reference with hash ${hash}. Maybe the Wikidata Query Service is lagging behind?` );
}
return match;
}
/**
* @param {Object} raw
* @return {WikidataPain.Reference}
*/
static fromRaw( raw ) {
/** @type {Array<string>} */
const snaksOrder = raw[ 'snaks-order' ];
/** @type {Object.<string, Array<WikidataPain.Snak>>} */
const snaks = {};
const statement = new WikidataPain.Reference( snaks, snaksOrder );
statement.hash = raw.hash;
for ( const snakProperty of Object.keys( raw.snaks ) ) {
statement.snaks[ snakProperty ] = raw.snaks[ snakProperty ].map( ( rawSnak ) => {
return WikidataPain.Snak.fromRaw( rawSnak );
} );
}
return statement;
}
/**
* @param {Array<WikidataPain.Snak>} snaks List of snaks.
* @param {Array<string>} [snaksOrder=null] Order of snaks, if not set than order of snaks in {@ref snaks} is used.
* @return {WikidataPain.Reference}
*/
static fromSnaks( snaks, snaksOrder = null ) {
let newSnaksOrder;
if ( snaksOrder === null ) {
newSnaksOrder = WikidataPain.Util.makeArrayUnique( snaks.map( ( snak ) => snak.property ) );
} else {
newSnaksOrder = snaksOrder;
}
const reference = new WikidataPain.Reference( {}, newSnaksOrder );
for ( const snak of snaks ) {
reference.addSnak( snak );
}
return reference;
}
/**
* Returns a set of statements that have at least one reference with the given hash.
* The lookup is done using the Wikidata Query Service.
*
* @param {string} referenceHash
* @return {Promise<Array<WikidataPain.Statement>>}
*/
static statementsByReferenceHash( referenceHash ) {
return new Promise( ( resolve, reject ) => {
$.ajax( {
data: {
format: 'json',
query: `SELECT ?statement WHERE {
?statement prov:wasDerivedFrom wdref:${referenceHash}.
}`.replace( /[\n\t]+/g, ' ' )
},
method: 'POST',
url: WikidataPain.SPARQL_ENDPOINT
} ).done( ( data ) => {
const bindings = data?.results?.bindings ?? null;
if ( bindings === null ) {
return reject( new Error( data ) );
}
resolve( bindings.map( ( binding ) => WikidataPain.Statement.fromClaimId( binding.statement.value ) ) );
} ).fail( reject );
} );
}
/**
* Adds a snak to the reference.
*
* @param {WikidataPain.Snak} snak
* @return {WikidataPain.Reference}
*/
addSnak( snak ) {
const property = snak.property;
if ( this.snaks[ property ] === undefined ) {
this.snaks[ property ] = [ snak ];
} else {
this.snaks[ property ].push( snak );
}
return this;
}
/**
* @param {WikidataPain.Reference} other
* @return {WikidataPain.PrecisionComparison}
*/
comparePrecision( other ) {
return WikidataPain.Util.comparePropertyValues( this.snaks, other.snaks );
}
/**
* @param {string} property
* @param {WikidataPain.Snak} otherSnak
* @return {Array<WikidataPain.Snak>}
*/
filterSnaksByRefinable( property, otherSnak ) {
if ( this.snaks[ property ] === undefined ) {
return [];
}
return this.snaks[ property ].filter( ( thisSnak ) => {
return thisSnak.comparePrecision( otherSnak ) !== WikidataPain.PrecisionComparison.IncomparableValue;
} );
}
/**
* @param {WikidataPain.Reference} other
* @return {boolean}
*/
merge( other ) {
let changed = false;
/** @type {Object.<string, Array<WikidataPain.Snak>>} */
const toAdd = {};
for ( const [ property, otherSnaks ] of Object.entries( other.snaks ) ) {
for ( const otherSnak of otherSnaks ) {
const refinables = this.filterSnaksByRefinable( property, otherSnak );
if ( refinables.length === 1 ) {
console.debug( `[WikidataPain] Refine snak (property ${otherSnak.property}) of reference` );
changed = changed || refinables[ 0 ].merge( otherSnak );
} else if ( refinables.length === 0 ) {
console.debug( `[WikidataPain] Add new snak (property ${otherSnak.property}) to reference` );
if ( toAdd[ property ] === undefined ) {
toAdd[ property ] = [ otherSnak ];
} else {
toAdd[ property ].push( otherSnak );
}
changed = true;
} else {
throw new Error( `Illegal number of refinable snaks of reference, found ${refinables.length}` );
}
}
}
for ( const [ property, snaks ] of Object.entries( toAdd ) ) {
for ( const snak of snaks ) {
if ( this.snaks[ property ] === undefined ) {
this.snaks[ property ] = [];
}
this.snaks[ property ].push( snak );
}
}
return changed;
}
/**
* @param {WikidataPain.Reference} other
* @return {boolean} Whether there is at least one Snak that is shared by both references.
*/
overlaps( other ) {
return Object.entries( this.snaks ).some( ( [ property, thisSnaks ] ) => {
if ( other.snaks[ property ] === undefined ) {
// If the property is not defined in the other reference, there can't be an overlap for this property
return false;
}
return thisSnaks.some( ( thisSnak ) => {
return other.snaks[ property ].some( ( otherSnak ) => {
return thisSnak.equals( otherSnak );
} );
} );
} );
}
/**
* Removes a snak from the reference.
*
* @param {WikidataPain.Snak} snak
* @return {WikidataPain.Reference}
*/
removeSnak( snak ) {
const property = snak.property;
if ( this.snaks[ property ] === undefined ) {
return this;
}
this.snaks[ property ] = this.snaks[ property ].filter( ( originalSnak ) => {
return !originalSnak.equals( snak );
} );
return this;
}
/**
* Replaces all references that have the given hash across all statements.
* Querying of statements that contain a reference with the given hash is done via the Wikidata Query Service.
* This means, that there is a delay after which newly added references are detected.
*
* @param {string} hash Hash of the references to replace.
* @return {Promise<void>}
*/
async replaceHashInstant( hash ) {
hash = hash.replace( /^wdref:/, '' );
const statements = await WikidataPain.Reference.statementsByReferenceHash( hash );
for ( const statement of statements ) {
const foundReference = await statement.removeReferenceByHash( hash );
if ( !foundReference ) {
throw new Error( `Failed to find reference with hash ${hash}` );
}
await statement.addReference( this );
}
for ( const statement of statements ) {
await statement.commit();
}
}
/**
* @return {Object}
*/
toJSON() {
return {
hash: this.hash ?? undefined,
snaks: this.snaks,
'snaks-order': this.snaksOrder ?? undefined
};
}
};
/**
* @class
*/
WikidataPain.Snak = class {
/** @type {WikidataPain.Snak.DataType} */
datatype = null;
/** @type {WikidataPain.DataValue} */
datavalue = null;
/** @type {string} */
property = null;
/** @type {WikidataPain.Snak.ValueType} */
snaktype = null;
/**
* @hideconstructor
* @param {WikidataPain.Snak.ValueType} snaktype
* @param {Object} property
* @param {WikidataPain.Snak.DataType} datatype
* @param {WikidataPain.DataValue} [datavalue]
*/
constructor( snaktype, property, datatype, datavalue = null ) {
this.datatype = datatype;
this.datavalue = datavalue;
this.property = property;
this.snaktype = snaktype;
}
/**
* @param {string} property
* @param {WikidataPain.Snak.DataType} datatype
* @param {WikidataPain.DataValue} datavalue
* @return {WikidataPain.Snak}
*/
static fromDataValue( property, datatype, datavalue ) {
return new WikidataPain.Snak( WikidataPain.Snak.ValueType.Value, property, datatype, datavalue );
}
/**
* @param {string} property
* @param {string} entityId
* @return {WikidataPain.Snak}
*/
static fromEntity( property, entityId ) {
return WikidataPain.Snak.fromDataValue( property, WikidataPain.Snak.DataType.WikibaseItem, WikidataPain.DataValue.fromEntity( entityId ) );
}
/**
* @param {string} property
* @param {string} languageCode
* @param {string} text
* @return {WikidataPain.Snak}
*/
static fromMonolingualText( property, languageCode, text ) {
return WikidataPain.Snak.fromDataValue( property, WikidataPain.Snak.DataType.Monolingualtext, WikidataPain.DataValue.fromMonolingualText( languageCode, text ) );
}
/**
* @param {string} property
* @return {WikidataPain.Snak}
*/
static fromNoValue( property ) {
return new WikidataPain.Snak( WikidataPain.Snak.ValueType.NoValue, property, null, null );
}
/**
* @param {string} property
* @param {string} amount
* @param {string} precision
* @param {string} unit
* @return {WikidataPain.Snak}
*/
static fromQuantity( property, amount, precision, unit ) {
return WikidataPain.Snak.fromDataValue( property, WikidataPain.Snak.DataType.Quantity, WikidataPain.DataValue.fromQuantity( amount, precision, unit ) );
}
/**
* @param {string} property
* @param {string} value
* @return {WikidataPain.Snak}
*/
static fromQuickStatements( property, value ) {
property = property.replace( /^S/, 'P' );
if ( /^Q\d+$/.test( value ) ) {
// Item: https://www.wikidata.org/wiki/Special:MyLanguage/Help:Data_type#Item
return WikidataPain.Snak.fromEntity( property, value );
} else if ( /^".+"$/.test( value ) ) {
// String: https://www.wikidata.org/wiki/Special:MyLanguage/Help:Data_type#String
return WikidataPain.Snak.fromString( property, value.match( /^"(.+)"$/ )[ 1 ] );
} else if ( /^[a-z-]{2,}:".+"$/.test( value ) ) {
// Monolingual text: https://www.wikidata.org/wiki/Special:MyLanguage/Help:Data_type#monolingualtext
const match = value.match( /^([a-z-]{2,}):"(.+)"$/ );
return WikidataPain.Snak.fromMonolingualText( property, match[ 1 ], match[ 2 ] );
} else if ( /^\+\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z\/\d{1,2}$/.test( value ) ) {
// Time: https://www.wikidata.org/wiki/Special:MyLanguage/Help:Data_type#Time
const match = value.match( /^\+(\d{4})-(\d{2})-(\d{2})T\d{2}:\d{2}:\d{2}Z\/(\d{1,2})$/ );
return WikidataPain.Snak.fromTime( property, parseInt( match[ 1 ] ), parseInt( match[ 4 ] ) >= 10 ? parseInt( match[ 2 ] ) : null, parseInt( match[ 4 ] ) >= 11 ? parseInt( match[ 3 ] ) : null );
} else if ( /^@\d{1,2}.\d+\/\d{1,2}.\d+/.test( value ) ) {
// Globe coordinate: https://www.wikidata.org/wiki/Special:MyLanguage/Help:Data_type#Globe_coordinate
throw new Error( 'Not implemented' );
} else if ( /^[+-]?\d+(~\d+)?(U\d+)?$/.test( value ) ) {
// Quantity: https://www.wikidata.org/wiki/Special:MyLanguage/Help:Data_type#Quantity
const match = value.match( /^([+-])?(\d+)(?:(~\d+))?(?:U(\d+))?$/ );
const sign = match[ 1 ] === undefined ? '+' : match[ 1 ];
let unit;
if ( match[ 4 ] === undefined ) {
unit = '1';
} else if ( /^Q\d+/.test( match[ 4 ] ) ) {
unit = `http://www.wikidata.org/entity/${match[ 4 ]}`;
} else {
throw new Error( 'Failed to parse quantity' );
}
return WikidataPain.Snak.fromQuantity( property, `${sign}${match[ 2 ]}`, match[ 3 ] ?? null, unit );
} else if ( value === 'somevalue' ) {
return WikidataPain.Snak.fromSomevalue( property );
} else if ( value === 'novalue' ) {
return WikidataPain.Snak.fromNoValue( property );
} else {
throw new Error( 'Unexpected QuickStatement-value' );
}
}
/**
* @param {string} property
* @return {WikidataPain.Snak}
*/
static fromSomevalue( property ) {
return new WikidataPain.Snak( WikidataPain.Snak.ValueType.Somevalue, property, null, null );
}
/**
* @param {string} property
* @param {Object} sparql
* @return {WikidataPain.Snak}
*/
static fromSparql( property, sparql ) {
switch ( sparql.type ) {
case 'literal': {
if ( sparql[ 'xml:lang' ] !== undefined ) {
return WikidataPain.Snak.fromMonolingualText( property, sparql[ 'xml:lang' ], sparql.value );
} else {
return WikidataPain.Snak.fromString( property, sparql.value );
}
}
case 'uri': {
if ( /^http:\/\/www\.wikidata\.org\/entity\/(Q\d+)$/.test( sparql.value ) ) {
return WikidataPain.Snak.fromEntity( property, sparql.value.match( /^http:\/\/www\.wikidata\.org\/entity\/(Q\d+)$/ )[ 1 ] );
} else {
break;
}
}
}
throw new Error( 'Unexpected SPARQL-object' );
}
/**
* @param {Object} raw
* @return {WikidataPain.Snak}
*/
static fromRaw( raw ) {
return new WikidataPain.Snak( raw.snaktype ?? null, raw.property ?? null, raw.datatype, ( raw.datavalue ?? null ) === null ? null : WikidataPain.DataValue.fromRaw( raw.datavalue ) );
}
/**
* @param {string} property
* @param {string} string
* @return {WikidataPain.Snak}
*/
static fromString( property, string ) {
return WikidataPain.Snak.fromDataValue( property, WikidataPain.Snak.DataType.String, WikidataPain.DataValue.fromString( string ) );
}
/**
* @param {string} property
* @param {number} year
* @param {number} [month]
* @param {number} [day]
* @return {WikidataPain.Snak}
*/
static fromTime( property, year, month = null, day = null ) {
return WikidataPain.Snak.fromDataValue( property, WikidataPain.Snak.DataType.Time, WikidataPain.DataValue.fromTime( year, month, day ) );
}
/**
* @param {string} property
* @param {string} timestamp
* @return {WikidataPain.Snak}
*/
static fromTimestamp( property, timestamp ) {
return WikidataPain.Snak.fromDataValue( property, WikidataPain.Snak.DataType.Time, WikidataPain.DataValue.fromTimestamp( timestamp ) );
}
/**
* @param {string} property
* @param {number} precision
* @return {WikidataPain.Snak}
*/
static fromToday( property, precision ) {
return WikidataPain.Snak.fromDataValue( property, WikidataPain.Snak.DataType.Time, WikidataPain.DataValue.fromToday( precision ) );
}
/**
* Compares the precision of this Snak with another Snak's precision.
*
* @param {WikidataPain.Snak} other Snak to compare to.
* @return {WikidataPain.PrecisionComparison}
*/
comparePrecision( other ) {
if ( this.property !== other.property ) {
return WikidataPain.PrecisionComparison.IncomparableValue;
}
// Snaktypes must be same or one `somevalue`
if ( this.snaktype !== other.snaktype ) {
if ( this.snaktype === WikidataPain.Snak.ValueType.Somevalue ) {
return WikidataPain.PrecisionComparison.LessPrecise;
} else if ( other.snaktype === WikidataPain.Snak.ValueType.Somevalue ) {
return WikidataPain.PrecisionComparison.MorePrecise;
} else {
return WikidataPain.PrecisionComparison.IncomparableValue;
}
}
return this.datavalue.comparePrecision( other.datavalue );
}
/**
* Compares this Snak with another Snak.
*
* @param {WikidataPain.Snak} other Snak to compare to.
* @return {boolean} `true` if equal, otherwise `false`.
*/
equals( other ) {
return this.comparePrecision( other ) === WikidataPain.PrecisionComparison.EqualValue;
}
/**
* @param {Array<number>} start
* @param {Array<number>} end
* @return {boolean}
*/
filterByTimeInterval( [ startYear = 0, startMonth = 0, startDay = 0 ], [ endYear = null, endMonth = null, endDay = null ] ) {
if ( this.snaktype !== WikidataPain.Snak.ValueType.Value ||
this.datavalue.type !== WikidataPain.DataValue.Type.Time ) {
throw new Error( 'Not concrete time' );
}
const components = WikidataPain.Util.dissectQsTimestamp( `${this.datavalue.value.time}/${this.datavalue.value.precision}` );
const start = startYear * 10000 + startMonth * 100 + startDay;
const end = endYear === null ? Math.max() : endYear * 10000 + endMonth * 100 + endDay;
const subject = components.year * 10000 + ( components.precision >= WikidataPain.Precision.Month ? components.month : 0 ) * 100 + ( components.precision >= WikidataPain.Precision.Day ? components.day : 0 );
return start <= subject && subject <= end;
}
/**
* @param {WikidataPain.Snak} other
* @return {boolean} Whether a change occured.
*/
merge( other ) {
const comparison = this.datavalue.comparePrecision( other.datavalue );
switch ( comparison ) {
case WikidataPain.PrecisionComparison.EqualValue:
console.debug( '[WikidataPain] Skipping snak, both are equal' );
return false;
case WikidataPain.PrecisionComparison.LessPrecise:
this.datatype = other.datatype;
return true;
case WikidataPain.PrecisionComparison.MorePrecise:
console.debug( '[WikidataPain] Skipping snak, this one is more precise' );
return false;
default:
throw new Error( `[WikidataPain] Illegal precision comparison result ${comparison}` );
}
}
/**
* @return {Object}
*/
toJSON() {
const output = {};
for ( const field of [ 'datatype', 'datavalue', 'property', 'snaktype' ] ) {
if ( this[ field ] !== null ) {
output[ field ] = this[ field ];
}
}
return output;
}
/**
* @return {string}
*/
toString() {
return this.toJSON().toString();
}
};
/**
* @enum {string}
*/
WikidataPain.Snak.DataType = Object.freeze( {
ExternalId: 'external-id',
Monolingualtext: 'monolingualtext',
Quantity: 'quantity',
String: 'string',
Time: 'time',
Url: 'url',
WikibaseItem: 'wikibase-item'
} );
/**
* @enum {string}
*/
WikidataPain.Snak.ValueType = Object.freeze( {
NoValue: 'novalue',
Somevalue: 'somevalue',
Value: 'value'
} );
/**
* @class
*/
WikidataPain.Statement = class {
/** @type {boolean} */
#loaded = false;
/** @type {boolean} */
#referencesLoaded = false;
/** @type {string} */
id = null;
/** @type {WikidataPain.Snak} */
mainsnak = null;
/** @type {string} */
rank = null;
/** @type {string} */
type = null;
/** @type {Object.<string, Array<WikidataPain.Snak>>} */
qualifiers = {};
/** @type {Array<string>} */
qualifiersOrder = [];
/** @type {Array<WikidataPain.Reference>} */
references = [];
/**
* @hideconstructor
* @param {string} id
* @param {boolean} loaded
* @param {boolean} isNew
*/
constructor( id, loaded, isNew ) {
this.id = id;
this.#loaded = loaded;
this.new = isNew;
}
/**
* Collapses a dictionary of properties and statements to a simple list of statements.
*
* @param {Object.<string, Array<WikidataPain.Statement>>} dictionary
* @return {Array<WikidataPain.Statement>}
*/
static collapseApiDictionary( dictionary ) {
const output = [];
for ( const [ _key, value ] of Object.entries( dictionary ) ) {
output.push( ...value );
}
return output;
}
/**
* Function used to transform a snak.
*
* @typedef {function(WikidataPain.Snak): Promise<(WikidataPain.Snak)>} ValueTransformer
*/
/**
* @param {Array<WikidataPain.Statement>|Object.<string, Array<WikidataPain.Statement>>} claims
* @param {function(WikidataPain.Statement): Promise<boolean>} predicate
* @return {Promise<Array<WikidataPain.Statement>>}
*/
static filterCollapsed( claims, predicate ) {
/** @type {Array<WikidataPain.Statement>} */
let collapsedClaims = null;
if ( !Array.isArray( claims ) ) {
collapsedClaims = WikidataPain.Statement.collapseApiDictionary(
/** @type {Object.<string, Array<WikidataPain.Statement>>} */( claims )
);
} else {
collapsedClaims = claims;
}
return WikidataPain.Util.filterAsync( collapsedClaims, function ( value ) {
return predicate( value );
} );
}
/**
* @param {Array<WikidataPain.Statement>} claimsToFilter
* @param {WikidataPain.Snak} snak
* @return {Promise<Array<WikidataPain.Statement>>}
*/
static async filterStatementsByMainsnak( claimsToFilter, snak ) {
return await WikidataPain.Util.filterAsync( claimsToFilter, async ( claim ) => {
const mainsnak = await claim.getMainsnak();
return mainsnak.equals( snak );
} );
}
/**
* @param {Array<WikidataPain.Statement>} claimsToFilter
* @param {WikidataPain.Snak} qualifier
* @return {Promise<Array<WikidataPain.Statement>>}
*/
static async filterStatementsByQualifier( claimsToFilter, qualifier ) {
return await WikidataPain.Util.filterAsync( claimsToFilter, async ( claim ) => {
if ( claim.qualifiers === null || claim.qualifiers[ qualifier.property ] === undefined ) {
return false;
} else {
return claim.qualifiers[ qualifier.property ].some( ( claimQualifier ) => {
return claimQualifier.equals( qualifier );
} );
}
} );
}
/**
* @param {Array<WikidataPain.Statement>} claimsToFilter
* @param {WikidataPain.Statement} whole
* @param {QualifierMatcher} [qualifierMatcher]
* @return {Promise<Array<WikidataPain.Statement>>}
*/
static async filterStatementsByQualifiersSubset( claimsToFilter, whole, qualifierMatcher ) {
return await WikidataPain.Util.filterAsync( claimsToFilter, async ( claim ) => {
return await qualifierMatcher( claim, whole );
} );
}
/**
* @param {Array<WikidataPain.Statement>} claimsToFilter
* @param {WikidataPain.Statement} whole
* @param {Object} [config]
* @param {QualifierMatcher} [config.qualifierMatcher]
* @return {Promise<Array<WikidataPain.Statement>>}
*/
static async filterStatementsByRefinable( claimsToFilter, whole, { qualifierMatcher = null } = {} ) {
const filteredByMainsnak = await WikidataPain.Statement.filterStatementsByMainsnak( claimsToFilter, await whole.getMainsnak() );
const filteredByQualifiers = await WikidataPain.Statement.filterStatementsByQualifiersSubset( filteredByMainsnak, whole, qualifierMatcher );
return filteredByQualifiers;
}
/**
* @param {string} entity Wikidata Q-ID.
* @param {WikidataPain.Snak} mainsnak Mainsnak.
* @param {WikidataPain.Rank} [rank=WikidataPain.Rank.Normal]
* @return {WikidataPain.Statement}
*/
static fromBlank( entity, mainsnak, rank = WikidataPain.Rank.Normal ) {
const newClaimId = WikidataPain.Util.randomClaimId( entity );
const statement = new WikidataPain.Statement( newClaimId, true, true );
statement.#referencesLoaded = true;
statement.mainsnak = mainsnak;
statement.rank = rank;
return statement;
}
/**
* Returns an instance of {@link WikidataPain.Statement} by its Claim-ID.
* Implicitly normalises claimId using {@link WikidataPain.Util.normaliseClaimId}.
*
* @param {string} claimId Wikidata Claim-ID.
* @return {WikidataPain.Statement}
*/
static fromClaimId( claimId ) {
const normalisedClaimId = WikidataPain.Util.normaliseClaimId( claimId );
if ( normalisedClaimId === null ) {
throw new Error( `Failed to normalise claim ID ${claimId}` );
}
return new WikidataPain.Statement( normalisedClaimId, false, false );
}
/**
* @param {string} entity Wikidata Q-ID.
* @param {string} [property] Wikidata P-ID.
* @param {boolean} [loadReferences=false] Whether to load references.
* @return {Promise<Object.<string, Array<WikidataPain.Statement>>>}
*/
static async fromEntity( entity, property = null, loadReferences = false ) {
const apiParams = {
action: 'wbgetclaims',
entity: entity,
props: loadReferences ? 'references' : ''
};
if ( property !== null ) {
apiParams.property = property;
}
const data = await WikidataPain.get( apiParams );
/** @type {Object.<string, Array<WikidataPain.Statement>>} */
const result = {};
for ( const keyProperty of Object.keys( data.claims ) ) {
result[ keyProperty ] = data.claims[ keyProperty ].map( ( claim ) => {
return WikidataPain.Statement.fromRaw( claim, loadReferences );
} );
}
return result;
}
/**
* @param {string} entity Wikidata Q-ID.
* @param {string} property Wikidata P-ID.
* @param {boolean} [loadReferences=false] Whether to load references.
* @return {Promise<Array<WikidataPain.Statement>>}
*/
static async fromEntityCollapsed( entity, property, loadReferences = false ) {
const claims = await WikidataPain.Statement.fromEntity( entity, property, loadReferences );
return WikidataPain.Statement.collapseApiDictionary( claims );
}
/**
* @param {Object} raw
* @param {boolean} [referencesLoaded] Set references as loaded.
* @return {WikidataPain.Statement}
*/
static fromRaw( raw, referencesLoaded = false ) {
const statement = new WikidataPain.Statement( raw.id, true, false );
statement.#referencesLoaded = referencesLoaded;
statement.extendFromRaw( raw );
return statement;
}
/**
* @param {string} entity
* @param {Object} raw
* @return {WikidataPain.Statement}
*/
static fromRawNew( entity, raw ) {
const claimId = WikidataPain.Util.randomClaimId( entity );
const statement = new WikidataPain.Statement( claimId, true, true );
statement.#referencesLoaded = true;
statement.extendFromRaw( raw );
return statement;
}
/**
* @param {Object} sparql
* @return {WikidataPain.Statement}
*/
static fromSparql( sparql ) {
switch ( sparql.type ) {
case 'uri': {
if ( WikidataPain.Util.normaliseClaimId( sparql.value ) !== null ) {
return WikidataPain.Statement.fromClaimId( sparql.value );
} else {
break;
}
}
}
throw new Error( 'Unexpected SPARQL-object' );
}
/**
* @param {WikidataPain.Reference} reference
* @return {Promise<void>}
*/
async addReference( reference ) {
await this.#assertLoaded( true );
if ( this.references === null ) {
this.references = [ reference ];
} else {
this.references.push( reference );
}
}
/**
* Is committed instantly and may not be chained.
*
* @param {WikidataPain.Reference} reference
* @param {Object?} [options]
* @return {Promise<void>}
*/
async addReferenceInstant( reference, options = {} ) {
return await WikidataPain.postCsrf( WikidataPain.Util.commonParameters( {
action: 'wbsetreference',
snaks: JSON.stringify( reference.snaks ),
'snaks-order': JSON.stringify( reference[ 'snaks-order' ] ),
statement: this.id
}, options ) );
}
/**
* @param {Object} config
* @param {string} [config.editgroup]
* @param {string} [config.summary]
* @return {Promise<Object>}
*/
async commit( { editgroup = null, summary = null } = {} ) {
await this.#assertLoaded( true );
return await this.setClaim( { editgroup: editgroup, summary: summary } );
}
/**
* Deletes the statement using `wbremoveclaims`.
* Is committed instantly and may not be chained.
*
* @param {Object?} [options]
* @return {Promise<void>}
*/
async deleteInstant( options = {} ) {
return await WikidataPain.postCsrf( WikidataPain.Util.commonParameters( {
action: 'wbremoveclaims',
claim: this.id
}, options ) );
}
/**
* @param {Object} raw
* @return {WikidataPain.Statement}
*/
extendFromRaw( raw ) {
this.mainsnak = ( raw.mainsnak ?? null ) === null ? null : WikidataPain.Snak.fromRaw( raw.mainsnak );
this.rank = raw.rank ?? null;
this.type = raw.type ?? null;
/** @type {Object.<string, Array<WikidataPain.Snak>>} */
const qualifiers = {};
if ( raw.qualifiers !== null && raw.qualifiers !== undefined ) {
for ( const property of Object.keys( raw.qualifiers ) ) {
qualifiers[ property ] = raw.qualifiers[ property ].map( ( qualifier ) => {
return WikidataPain.Snak.fromRaw( qualifier );
} );
}
}
this.qualifiers = qualifiers;
this.qualifiersOrder = raw[ 'qualifiers-order' ] ?? [];
this.references = raw.references ?? [];
return this;
}
/**
* @param {Array<function(WikidataPain.Statement): Promise<boolean>>} predicates
* @return {Promise<boolean>}
*/
async filterAnd( predicates ) {
const evaluatedPredicates = await Promise.all( predicates.map( ( predicate ) => {
return predicate( this );
} ) );
return evaluatedPredicates.every( ( evaluatedPredicate ) => {
return evaluatedPredicate;
} );
}
/**
* @param {string} property
* @param {function(Array<WikidataPain.Snak>): Promise<boolean>} predicate
* @return {Promise<boolean>}
*/
async filterByQualifier( property, predicate ) {
await this.#assertLoaded( false );
if ( this.qualifiers === null || this.qualifiers[ property ] === undefined ) {
return await predicate( [] );
} else {
return await predicate( this.qualifiers[ property ] );
}
}
/**
* @param {function(Object.<string, Array<WikidataPain.Snak>>): Promise<boolean>} predicate
* @return {Promise<boolean>}
*/
async filterByQualifiers( predicate ) {
await this.#assertLoaded( false );
return await predicate( this.qualifiers );
}
/**
* @param {function(WikidataPain.DataValue): Promise<boolean>} predicate
* @return {Promise<boolean>}
*/
async filterByMainsnakDataValue( predicate ) {
const mainsnak = await this.getMainsnak();
return await predicate( mainsnak.datavalue );
}
/**
* @param {string} property
* @return {Promise<boolean>}
*/
async filterByNoQualifierSnaks( property ) {
const qualifiers = await this.getQualifierSnaks( property );
return qualifiers.length === 0;
}
/**
* @param {Array<function(WikidataPain.Statement): Promise<boolean>>} predicates
* @return {Promise<boolean>}
*/
async filterOr( predicates ) {
const evaluatedPredicates = await Promise.allSettled( predicates.map( ( predicate ) => {
return predicate( this );
} ) );
return evaluatedPredicates.some( ( evaluatedPredicate ) => {
return evaluatedPredicate.status === 'fulfilled' && evaluatedPredicate.value;
} );
}
/**
* @param {Object} [config]
* @param {QualifierMatcher} [config.qualifierMatcher]
* @return {Promise<Array<WikidataPain.Statement>>}
*/
async findPotentiallyRefinable( { qualifierMatcher = null } = {} ) {
const entityId = WikidataPain.Util.entityIdFromClaimId( this.id );
const property = this.mainsnak.property;
const statements = await WikidataPain.Statement.fromEntity( entityId, property );
if ( statements[ property ] === undefined ) {
// No qualifier with property
return [];
}
const filteredStatements = await WikidataPain.Statement.filterStatementsByRefinable( statements[ property ], this, {
qualifierMatcher: qualifierMatcher
} );
if ( filteredStatements.length === 0 ) {
return [];
}
return filteredStatements;
}
/**
* @return {string}
*/
getEntity() {
return WikidataPain.Util.entityIdFromClaimId( this.id );
}
/**
* @return {Promise<WikidataPain.Snak>}
*/
async getMainsnak() {
await this.#assertLoaded( false );
return WikidataPain.Snak.fromRaw( this.mainsnak );
}
/**
* @param {boolean} [requestReferences] Whether to load references
* @return {Promise<Object>}
*/
async getObject( requestReferences = true ) {
await this.#assertLoaded( requestReferences );
return {
id: this.id,
mainsnak: this.mainsnak,
qualifiers: this.qualifiers,
'qualifiers-order': ( this.qualifiersOrder ?? null ) !== null && this.qualifiersOrder.length > 0 ? this.qualifiersOrder : undefined,
rank: this.rank ?? undefined,
references: this.references,
type: 'statement'
};
}
/**
* @return {Promise<Object.<string, Array<WikidataPain.Snak>>>}
*/
async getQualifiers() {
await this.#assertLoaded( false );
if ( this.qualifiers === null ) {
return {};
} else {
return this.qualifiers;
}
}
/**
* @param {string} property
* @return {Promise<Array<WikidataPain.Snak>>}
*/
async getQualifierSnaks( property ) {
await this.#assertLoaded( false );
if ( this.qualifiers === null ) {
this.qualifiers = {};
this.qualifiers[ property ] = [];
} else if ( ( this.qualifiers[ property ] ?? null ) === null ) {
this.qualifiers[ property ] = [];
}
return this.qualifiers[ property ];
}
/**
* @return {Promise<WikidataPain.Rank>}
*/
async getRank() {
await this.#assertLoaded( false );
return this.rank;
}
/**
* @return {Promise<Array<WikidataPain.Reference>>}
*/
async getReferences() {
await this.#assertLoaded( true );
if ( this.references === undefined ) {
return [];
} else {
return this.references;
}
}
/**
* Explicitly loads the statement.
* Typically, this method doesn't need to be called, but can be used to improve the efficiency of scripts.
*
* @param {boolean} [requestReferences=false]
* @return {Promise<void>}
*/
async load( requestReferences = false ) {
const raw = await this.#loadRaw( requestReferences );
if ( !this.#loaded ) {
this.mainsnak = raw.mainsnak ?? null;
this.rank = raw.rank ?? null;
this.type = raw.type ?? null;
this.qualifiers = {};
if ( ( raw.qualifiers ?? null ) !== null ) {
for ( const key of Object.keys( raw.qualifiers ) ) {
this.qualifiers[ key ] = raw.qualifiers[ key ].map( ( rawQualifier ) => {
return WikidataPain.Snak.fromRaw( rawQualifier );
} );
}
}
this.qualifiersOrder = raw[ 'qualifiers-order' ] ?? [];
}
if ( requestReferences && !this.#referencesLoaded ) {
this.references = [];
if ( ( raw.references ?? null ) !== null ) {
this.references = raw.references.map( ( reference ) => {
return WikidataPain.Reference.fromRaw( reference );
} );
}
} else if ( !requestReferences && !this.#loaded ) {
this.references = [];
}
this.#loaded = true;
this.#referencesLoaded = this.#referencesLoaded || requestReferences;
}
/**
* @param {WikidataPain.Statement} other
* @param {Object} [config]
* @param {WikidataPain.ReferenceMatchingCriterium} [config.referenceRefinableCriterium=WikidataPain.ReferenceMatchingCriterium.overlap]
* @return {Promise<boolean>} Whether a change occured.
*/
async mergeFrom( other, { referenceRefinableCriterium = null } = {} ) {
await Promise.all( [ this.#assertLoaded( true ), other.#assertLoaded( true ) ] );
// Value
// FIXME: Implement
// Qualifiers
let qualifierChangeOccured = false;
for ( const [ qualifier, snaks ] of Object.entries( other.qualifiers ) ) {
if ( this.qualifiers[ qualifier ] === undefined ) {
this.qualifiers[ qualifier ] = [];
}
for ( const snak of snaks ) {
const changeOccured = await this.mergeQualifierSnak( qualifier, snak );
qualifierChangeOccured = qualifierChangeOccured || changeOccured;
}
}
// References
let referenceChangeOccured = false;
for ( const reference of other.references ) {
const changeOccured = await this.mergeReference( reference, {
referenceRefinableCriterium: referenceRefinableCriterium
} );
referenceChangeOccured = referenceChangeOccured || changeOccured;
}
return qualifierChangeOccured || referenceChangeOccured;
}
/**
* @param {string} qualifier
* @param {WikidataPain.Snak} newSnak
* @return {Promise<boolean>} Whether a change occured.
*/
async mergeQualifierSnak( qualifier, newSnak ) {
await this.#assertLoaded( false );
if ( this.qualifiers[ qualifier ] === undefined || this.qualifiers[ qualifier ].length === 0 ) {
this.qualifiers[ qualifier ] = [ newSnak ];
return true;
} else {
// Find coarser or equal snaks
const matches = this.qualifiers[ qualifier ].filter( ( originalSnak ) => {
const precisionComparison = originalSnak.comparePrecision( newSnak );
return precisionComparison === WikidataPain.PrecisionComparison.EqualValue || precisionComparison === WikidataPain.PrecisionComparison.LessPrecise;
} );
let changeOccured = false;
if ( matches.length === 1 ) {
const originalSnak = matches[ 0 ];
if ( originalSnak.comparePrecision( newSnak ) === WikidataPain.PrecisionComparison.EqualValue ) {
// Snak already exists, nothing to do
} else {
// Refine existing Snak
matches[ 0 ].merge( newSnak );
changeOccured = true;
}
} else if ( matches.length === 0 ) {
// Append new Snak
this.qualifiers[ qualifier ].push( newSnak );
changeOccured = true;
} else {
throw new Error( `Found ${matches.length} snaks that are equal or can be refined.` );
}
return changeOccured;
}
}
/**
* @param {WikidataPain.Reference} newReference
* @param {Object} [config]
* @param {WikidataPain.ReferenceMatchingCriterium} [config.referenceRefinableCriterium=WikidataPain.ReferenceMatchingCriterium.overlap]
* @return {Promise<boolean>} Whether changes occured.
*/
async mergeReference( newReference, { referenceRefinableCriterium = null } = {} ) {
await this.#assertLoaded( true );
let matches;
switch ( referenceRefinableCriterium ?? WikidataPain.ReferenceMatchingCriterium.overlap ) {
case WikidataPain.ReferenceMatchingCriterium.overlap:
matches = this.references.filter( ( originalReference ) => {
return originalReference.overlaps( newReference );
} );
break;
case WikidataPain.ReferenceMatchingCriterium.precision:
// Find coarser or equal references
matches = this.references.filter( ( originalReference ) => {
const precisionComparison = originalReference.comparePrecision( newReference );
return precisionComparison === WikidataPain.PrecisionComparison.EqualValue || precisionComparison === WikidataPain.PrecisionComparison.LessPrecise;
} );
break;
default:
throw new Error( `Illegal referenceMatchingCriterium: ${referenceRefinableCriterium}` );
}
if ( matches.length === 1 ) {
const originalReference = matches[ 0 ];
if ( originalReference.comparePrecision( newReference ) === WikidataPain.PrecisionComparison.EqualValue ) {
// Snak already exists, nothing to do
console.debug( '[WikidataPain] Skipping reference as it already exists' );
return false;
} else {
// Refine existing Snak
console.debug( '[WikidataPain] Refine existing Snak' );
return matches[ 0 ].merge( newReference );
}
} else if ( matches.length === 0 ) {
// Append new Reference
console.debug( '[WikidataPain] Add new reference' );
this.references.push( newReference );
return true;
} else {
throw new Error( `Found ${matches.length} snaks that are equal or can be refined.` );
}
}
/**
* Creates a new statement with a different property based on this one and deletes this statement.
* Is committed instantly and may not be chained.
*
* @param {string} targetProperty New property to move the claim to.
* @param {ValueTransformer?} [valueTransformer] Function to apply to the claim's value which's result will be used as value for the newly created statement's claim.
* @param {Object} [config]
* @param {string} [config.editgroup=null]
* @param {string} [config.summary=null]
* @return {Promise<void>}
*/
async moveToPropertyInstant( targetProperty, valueTransformer = null, config = {} ) {
const entityId = WikidataPain.Util.entityIdFromClaimId( this.id );
const newStatement = WikidataPain.Statement.fromRawNew( entityId, await this.getObject() );
// Edit property
newStatement.mainsnak.property = targetProperty;
// Apply valueTransformer
if ( valueTransformer !== null ) {
newStatement.mainsnak = await valueTransformer( newStatement.mainsnak );
}
const data = {
claims: [
{
id: this.id,
remove: ''
},
newStatement
]
};
await WikidataPain.postCsrf( WikidataPain.Util.commonParameters( {
action: 'wbeditentity',
data: JSON.stringify( data ),
id: entityId
}, config ) );
}
/**
* @param {boolean} [removeReasonForPreferred=false]
* @param {Object} [config]
* @param {string} [config.editgroup=null]
* @param {string} [config.summary=null]
* @return {Promise<Array<WikidataPain.Statement>>} Other statements sharing the same property that have been edited.
*/
async preferOnlyInstant( removeReasonForPreferred = false, { editgroup = null, summary = null } = {} ) {
// Set own rank
if ( this.new ) {
await this.setRank( WikidataPain.Rank.Preferred );
} else {
await this.setRankInstant( WikidataPain.Rank.Preferred, null, {
editgroup: editgroup,
summary: summary
} );
}
// Demote other statements
const otherStatements = ( await WikidataPain.Statement.fromEntityCollapsed( this.getEntity(), ( await this.getMainsnak() ).property ) ).filter( ( otherStatement ) => {
return otherStatement.id !== this.id;
} );
const edited = [];
for ( const otherStatement of otherStatements ) {
let editNeeded = false;
if ( ( await otherStatement.getRank() ) === WikidataPain.Rank.Preferred ) {
// Set rank
otherStatement.setRank( WikidataPain.Rank.Normal );
editNeeded = true;
}
if ( removeReasonForPreferred ) {
// Remove reason for preferred rank
const removed = await otherStatement.qualifierRemove( WikidataPain.PROPERTY_REASON_FOR_PREFERENCE );
editNeeded = editNeeded || removed > 0;
}
if ( editNeeded ) {
await otherStatement.commit( {
editgroup: editgroup,
summary: summary
} );
edited.push( otherStatement );
}
}
return edited;
}
/**
* @param {WikidataPain.Snak} newQualifier
* @param {Object} [config]
* @param {('delete'|'fail')} [config.onAdditionalQualifierValues]
* @return {Promise<void>}
*/
async qualifierAddOrRefine( newQualifier, { onAdditionalQualifierValues = null } = {} ) {
const property = newQualifier.property;
let qualifierSnaks = await this.getQualifierSnaks( property );
// Check for additional qualifiers. If there are no qualifier snaks yet, skip.
if ( onAdditionalQualifierValues !== null && qualifierSnaks !== null && qualifierSnaks.length > 0 ) {
const incompatibleQualifiers = qualifierSnaks.filter( ( qualifier ) => {
return [ WikidataPain.PrecisionComparison.IncomparableValue, WikidataPain.PrecisionComparison.MorePrecise ].includes( qualifier.comparePrecision( newQualifier ) );
} );
if ( incompatibleQualifiers.length > 0 ) {
switch ( onAdditionalQualifierValues ) {
case 'delete':
this.qualifiers[ property ] = [];
qualifierSnaks = null;
break;
case 'fail':
throw new Error( 'Additional qualifiers found' );
default:
throw new Error();
}
}
}
if ( qualifierSnaks === null || qualifierSnaks.length === 0 ) {
// Add: If there are no qualifier snaks yet, just add new qualifier.
this.qualifiers[ property ] = [ newQualifier ];
return;
}
// Check, if qualifier snak already exists
const equalQualifiers = qualifierSnaks.filter( ( qualifier ) => {
return qualifier.comparePrecision( newQualifier ) === WikidataPain.PrecisionComparison.EqualValue;
} );
if ( equalQualifiers.length > 0 ) {
// Exists already
return;
}
// Check, if there are refineable qualifier snaks
const refinableQualifiers = qualifierSnaks.filter( ( qualifier ) => {
return qualifier.comparePrecision( newQualifier ) === WikidataPain.PrecisionComparison.LessPrecise;
} );
if ( refinableQualifiers.length > 0 ) {
// Refine qualifiers
if ( refinableQualifiers.length > 1 ) {
throw new Error( 'More than one refinable qualifier. This case has not been considered in implementation yet.' );
} else {
qualifierSnaks.splice( qualifierSnaks.indexOf( refinableQualifiers[ 0 ] ), 1 );
qualifierSnaks.push( newQualifier );
}
} else {
// Add new qualifier
this.qualifiers[ property ].push( newQualifier );
}
}
/**
* @param {WikidataPain.Snak} snak
* @return {Promise<void>}
*/
async qualifierAppend( snak ) {
await this.#assertLoaded( false );
const property = snak.property;
if ( !this.qualifiersOrder.includes( property ) ) {
this.qualifiersOrder.push( property );
}
if ( this.qualifiers[ property ] === undefined ) {
this.qualifiers[ property ] = [];
}
this.qualifiers[ property ].push( snak );
}
/**
* @param {string} propertyOrSnak (1) Name or the qualifier's property to remove. (2) Snak to remove.
* @param {WikidataPain.Snak} snak (1) Snak of the qualifier to remove. Set to `null` to delete regardless of snak. (2) Leave `null`.
* @return {Promise<number>} Number of qualifiers that have been removed-
*/
async qualifierRemove( propertyOrSnak, snak = null ) {
await this.#assertLoaded( false );
/** @type {string} */
let property = null;
/** @type {WikidataPain.Snak} */
let actualSnak = null;
if ( typeof propertyOrSnak === 'string' ) {
actualSnak = snak;
property = propertyOrSnak;
} else {
actualSnak = propertyOrSnak;
property = actualSnak.property;
}
if ( this.qualifiers[ property ] === undefined ) {
// Nothing to remove
throw new Error( 'Statement has no qualifiers' );
}
let deletedQualifiers;
if ( actualSnak === null ) {
deletedQualifiers = this.qualifiers[ property ].length;
delete this.qualifiers[ property ];
} else {
this.qualifiers[ property ] = this.qualifiers[ property ].filter( ( value ) => {
const isEqual = value.equals( actualSnak );
if ( !isEqual ) {
deletedQualifiers++;
}
return !isEqual;
} );
if ( this.qualifiers[ property ].length === 0 ) {
delete this.qualifiers[ property ];
}
}
if ( deletedQualifiers === 0 ) {
throw new Error( 'No qualifiers to remove found' );
}
// Remove from order, if needed
if ( this.qualifiers[ property ] === undefined ) {
const qualifierOrder = this.qualifiersOrder.indexOf( property );
if ( qualifierOrder > -1 ) {
this.qualifiersOrder.splice( qualifierOrder, 1 );
}
}
return deletedQualifiers;
}
/**
* Loads the original statement and adds qualifiers and references of this statement to it.
*
* @param {boolean} allowCreate
* @param {Object} [config]
* @param {string} [config.editgroup=null]
* @param {WikidataPain.ComparisonMethod} [config.qualifierComparisonMethod]
* @param {QualifierMatcher} [config.qualifierMatcher]
* @param {WikidataPain.ReferenceMatchingCriterium} [config.referenceRefinableCriterium=WikidataPain.ReferenceMatchingCriterium.overlap]
* @param {string} [config.summary=null]
* @return {Promise<Object>}
*/
async refine( allowCreate, { editgroup = null, qualifierComparisonMethod = null, qualifierMatcher = null, referenceRefinableCriterium = null, summary = null } = {} ) {
let originalStatement;
if ( this.new ) {
const refinables = await this.findPotentiallyRefinable( {
qualifierMatcher: qualifierMatcher ?? WikidataPain.QualifierMatcherFactory.candidateSubset( {
comparisonMethod: qualifierComparisonMethod
} )
} );
if ( refinables.length === 0 ) {
if ( allowCreate ) {
originalStatement = null;
} else {
throw new Error( 'No refinable statement found.' );
}
} else if ( refinables.length > 1 ) {
throw new Error( `Illegal number of refinable statements found: ${refinables.length}` );
} else {
originalStatement = refinables[ 0 ];
}
} else {
originalStatement = WikidataPain.Statement.fromClaimId( this.id );
}
if ( originalStatement !== null ) {
// Refine
await originalStatement.#assertLoaded( true );
const changeOccured = originalStatement.mergeFrom( this, {
referenceRefinableCriterium: referenceRefinableCriterium
} );
if ( await changeOccured ) {
console.debug( `[WikidataPain] Refining statement for ${WikidataPain.Util.entityIdFromClaimId( this.id )}` );
return await originalStatement.setClaim( { editgroup: editgroup, summary: summary } );
} else {
// No changes to push, nothing to do.
console.debug( `[WikidataPain] Skipping ${WikidataPain.Util.entityIdFromClaimId( this.id )}` );
return null;
}
} else {
// Create
console.debug( `[WikidataPain] Creating new statement for ${WikidataPain.Util.entityIdFromClaimId( this.id )}` );
return await this.commit( { editgroup: editgroup, summary: summary } );
}
}
/**
* @param {string} hash
* @return {Promise<boolean>} Whether a reference has been removed.
*/
async removeReferenceByHash( hash ) {
await this.#assertLoaded( true );
let matches = 0;
this.references = this.references.filter( ( reference ) => {
if ( reference.hash === hash ) {
matches++;
return false;
}
return true;
} );
if ( matches > 1 ) {
throw new Error( `Found multiple references witth hash ${hash}` );
}
return matches > 0;
}
/**
* @param {Object} [config]
* @param {?string} [config.editgroup]
* @param {?string} [config.summary]
* @return {Promise<Object>}
*/
async setClaim( { editgroup = null, summary = null } = {} ) {
const claim = await this.getObject( true );
return await WikidataPain.postCsrf( {
action: 'wbsetclaim',
claim: JSON.stringify( claim ),
format: 'json',
maxlag: WikidataPain.MAXLAG,
summary: WikidataPain.Util.summary( editgroup, summary )
} );
}
/**
* @param {!WikidataPain.Rank} newRank
* @param {?WikidataPain.Rank} [oldRank=null]
* @return {Promise<void>}
*/
async setRank( newRank, oldRank = null ) {
await this.#assertLoaded( false );
if ( oldRank !== null && this.rank !== oldRank ) {
throw new Error( 'Rank differs from expected value' );
}
this.rank = newRank;
}
/**
* @param {!WikidataPain.Rank} newRank
* @param {?WikidataPain.Rank} [oldRank=null]
* @param {Object} [config]
* @param {?string} [config.editgroup]
* @param {?string} [config.summary]
* @return {Promise<void>}
*/
async setRankInstant( newRank, oldRank = null, { editgroup = null, summary = null } = {} ) {
await this.#assertLoaded( false );
if ( oldRank !== null && this.rank !== oldRank ) {
throw new Error( 'Rank differs from expected value' );
}
this.rank = newRank;
this.commit( {
editgroup: editgroup,
summary: summary
} );
}
/**
* Sets this statement's value.
* The DataValue's snaktype is set to `value`, if the provided `newValue` was neither a {@link WikidataPain.DataValue} nor a {@link WikidataPain.Snak}.
* Must be committed by calling {@link WikidataPain.Statement#commit}.
*
* @param {WikidataPain.DataValue|WikidataPain.Snak|any} newValue New value
* @return {Promise<void>}
*/
async setValue( newValue ) {
await this.#assertLoaded( true );
if ( newValue instanceof WikidataPain.DataValue ) {
this.mainsnak.datavalue = newValue;
} else if ( newValue instanceof WikidataPain.Snak ) {
this.mainsnak.datavalue = newValue.datavalue;
} else {
this.mainsnak.datavalue.value = newValue;
this.mainsnak.datavalue.type = WikidataPain.Snak.ValueType.Value;
}
}
/**
* Sets this statement's value and the snaktype to `value` using `wbsetclaimvalue`.
* Is committed instantly and may not be chained.
*
* @param {any} newValue
* @param {Object?} [options]
* @return {Promise<void>}
*/
async setValueInstant( newValue, options = {} ) {
let value;
if ( newValue instanceof WikidataPain.DataValue ) {
value = newValue.value;
} else if ( newValue instanceof WikidataPain.Snak ) {
value = newValue.datavalue.value;
} else {
value = newValue;
}
return await WikidataPain.postCsrf( WikidataPain.Util.commonParameters( {
action: 'wbsetclaimvalue',
claim: this.id,
snaktype: WikidataPain.Snak.ValueType.Value,
value: ( typeof value === 'string' ? value : JSON.stringify( value ) )
}, options ) );
}
/**
* Sets this statement's mainsnak's type.
* Must be committed by calling {@link WikidataPain.Statement#commit}.
*
* @param {WikidataPain.Snak.ValueType} snakType Snak type to set. May not be `Value`.
* @return {Promise<void>}
*/
async setValueSnakType( snakType ) {
await this.#assertLoaded( true );
if ( snakType === WikidataPain.Snak.ValueType.Value ) {
throw new Error( 'Use Snak#setValue for assigning a concrete value.' );
}
this.mainsnak.datavalue.type = snakType;
}
/**
* Sets this statement's mainsnak's type.
* Is committed instantly and may not be chained.
*
* @param {WikidataPain.Snak.ValueType} snaktype Snak type to set. May not be `Value`.
* @param {Object?} [options]
* @return {Promise<void>}
*/
async setValueSnakTypeInstant( snaktype, options = {} ) {
if ( snaktype === WikidataPain.Snak.ValueType.Value ) {
throw new Error( 'Statement#setValueSnakTypeInstant may not be used for the type `value`. Use Statement#setValueInstant instead.' );
}
return await WikidataPain.postCsrf( WikidataPain.Util.commonParameters( {
action: 'wbsetclaimvalue',
claim: this.id,
snaktype: snaktype
}, options ) );
}
/**
* Applies `valueTransformer` to the statement's claim's value and sets it's output as the claim's new value.
* Must be committed by calling {@link WikidataPain.Statement.commit}.
*
* @param {ValueTransformer} valueTransformer Function to apply to the claim's value which's result with replace the claim's current value.
* @return {Promise<void>}
*/
async transformValue( valueTransformer ) {
await this.#assertLoaded( true );
const newValue = await valueTransformer( this.mainsnak );
if ( newValue !== null ) {
this.setValue( newValue );
}
}
/**
* Applies `valueTransformer` to the statement's claim's value and sets it's output as the claim's new value by using `wbsetclaimvalue`.
* Is committed instantly and may not be chained.
*
* @param {ValueTransformer} valueTransformer
* @param {Object?} [options]
* @return {Promise<void>}
*/
async transformValueInstant( valueTransformer, options = null ) {
await this.#assertLoaded( false );
const newValue = await valueTransformer( this.mainsnak );
if ( newValue !== null ) {
await this.setValueInstant( newValue, options );
}
}
/**
* @param {boolean} requestReferences
* @return {Promise<void>}
*/
async #assertLoaded( requestReferences ) {
if ( this.#loaded && ( !requestReferences || this.#referencesLoaded ) ) {
// Loaded
return;
}
await this.load( requestReferences );
}
async #loadRaw( requestReferences ) {
const props = [];
if ( requestReferences === true ) {
props.push( 'references' );
}
const data = await WikidataPain.get( {
action: 'wbgetclaims',
claim: this.id,
props: props.join( '|' )
} );
if ( Object.keys( data.claims ).length > 0 ) {
console.assert( Object.keys( data.claims ).length === 1 );
const statementProperty = Object.keys( data.claims )[ 0 ];
const propertyStatements = data.claims[ statementProperty ];
console.assert( propertyStatements.length === 1 );
return propertyStatements[ 0 ];
} else {
throw new Error( data );
}
}
};
/**
* @class
*/
WikidataPain.Util = class {
/**
* @param {Object} parameters
* @param {Object} [config]
* @param {string} [config.editgroup]
* @param {string} [config.summary]
* @return {Object}
*/
static commonParameters( parameters, { editgroup = null, summary = null } = {} ) {
// Format
if ( parameters.format === undefined ) {
parameters.format = 'json';
}
// MaxLag
if ( parameters.maxlag === undefined ) {
parameters.maxlag = WikidataPain.MAXLAG;
}
// Summary
let compoundSummary;
if ( typeof editgroup !== 'string' && typeof summary !== 'string' ) {
compoundSummary = null;
} else if ( typeof editgroup === 'string' && typeof summary === 'string' ) {
compoundSummary = `${summary} ([[:toollabs:editgroups/b/CB/${editgroup}|details]])`;
} else if ( typeof editgroup === 'string' ) {
compoundSummary = `([[:toollabs:editgroups/b/CB/${editgroup}|details]])`;
} else {
compoundSummary = summary;
}
if ( compoundSummary !== null ) {
parameters.summary = compoundSummary;
}
return parameters;
}
/**
* @param {Object.<string, Array<WikidataPain.Snak>>} leftSnakMap
* @param {Object.<string, Array<WikidataPain.Snak>>} rightSnakMap
* @return {WikidataPain.PrecisionComparison}
*/
static comparePropertyValues( leftSnakMap, rightSnakMap ) {
const left = compare( leftSnakMap, rightSnakMap );
const right = compare( rightSnakMap, leftSnakMap );
const comparisons = WikidataPain.Util.makeArrayUnique( [ left, WikidataPain.Util.invertPrecision( right ) ] );
if ( comparisons.length === 1 ) {
return comparisons[ 0 ];
}
comparisons.filter( ( precision ) => {
return precision !== WikidataPain.PrecisionComparison.EqualValue;
} );
if ( comparisons.length === 1 ) {
return comparisons[ 0 ];
} else {
return WikidataPain.PrecisionComparison.IncomparableValue;
}
/**
* @param {Array<WikidataPain.PrecisionComparison>} precisions
* @return {WikidataPain.PrecisionComparison}
*/
function accumulatePrecisions( precisions ) {
const uniquePrecisions = WikidataPain.Util.makeArrayUnique( precisions );
if ( uniquePrecisions.length === 0 ) {
// Found no matches
throw new Error( 'List most contain at least one precision' );
} else if ( uniquePrecisions.length === 1 ) {
// Only one match, use its precision
return uniquePrecisions[ 0 ];
} else if ( uniquePrecisions.length === 2 && uniquePrecisions.includes( WikidataPain.PrecisionComparison.EqualValue ) ) {
// Equal + 𝑥 = 𝑥
return uniquePrecisions.filter( ( precision ) => {
return precision !== WikidataPain.PrecisionComparison.EqualValue;
} )[ 0 ];
} else {
// Equal + Less + Greater = Incomparable
return WikidataPain.PrecisionComparison.IncomparableValue;
}
}
/**
* @param {Object.<string, Array<WikidataPain.Snak>>} leftSnakMap_
* @param {Object.<string, Array<WikidataPain.Snak>>} rightSnakMap_
* @return {WikidataPain.PrecisionComparison}
*/
function compare( leftSnakMap_, rightSnakMap_ ) {
const precisionsPerSnak = Object.fromEntries( Object.entries( leftSnakMap_ )
.map( ( [ property, leftSnaks ] ) => {
return [ property, leftSnaks.map( ( leftSnak ) => {
/** @type {Array<WikidataPain.Snak>} */
const rightSnaks = rightSnakMap_[ property ];
if ( rightSnakMap_[ property ] === undefined ) {
return WikidataPain.PrecisionComparison.MorePrecise;
}
const res = WikidataPain.Util.makeArrayUnique( rightSnaks.map( ( rightSnak ) => {
return leftSnak.comparePrecision( rightSnak );
} ).filter( ( precision ) => {
// Ignore incomparable snaks (unrelated values)
return precision !== WikidataPain.PrecisionComparison.IncomparableValue;
} ) );
if ( res.length === 0 ) {
// Found no matches, left has more qualifiers
return WikidataPain.PrecisionComparison.MorePrecise;
} else {
return accumulatePrecisions( res );
}
} ) ];
} ) );
return accumulatePrecisions( Object.values( precisionsPerSnak ).flat() );
}
}
/**
* Get the entity-ID of the entity that's currently open.
*
* @return {string?}
*/
static currentEntityId() {
const entityIdMatch = document.querySelector( '.wikibase-title-id' ).textContent.match( /\((.+)\)/ );
if ( entityIdMatch !== null ) {
return entityIdMatch[ 1 ];
} else {
return null;
}
}
/**
* @param {string} date
* @return {DissectedTime}
*/
static dissectQsTimestamp( date ) {
const match = date.match( /(?<time>\+(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})T00:00:00Z)\/(?<precision>\d{1,2})/ );
if ( match === null ) {
throw new Error( `Unknown date ${date}` );
}
return {
day: parseInt( match.groups.day ),
month: parseInt( match.groups.month ),
precision: parseInt( match.groups.precision ),
time: match.groups.time,
year: parseInt( match.groups.year )
};
}
/**
* @param {string} claimId Wikidata claim-ID.
* @return {string} Wikidata Q-ID which the claimId belongs to.
*/
static entityIdFromClaimId( claimId ) {
const matches = claimId.match( /^(?<qid>Q\d+)\$[a-z\d]{8}-([a-z\d]{4}-){3}[a-z\d]{12}$/i );
if ( matches !== null ) {
return matches.groups.qid;
} else {
return null;
}
}
/**
* @template T
* @param {Array<T>} array
* @param {function(T, number, Array<T>): Promise<boolean>} callbackFn
* @return {Promise<Array<T>>}
*/
static async filterAsync( array, callbackFn ) {
const predicatedArray = await Promise.all( array.map( callbackFn ) );
return array.filter( ( value, index ) => {
return predicatedArray[ index ];
} );
}
/**
* Try to format a English/German date with day-precision as a QuickStatement date.
*
* @param {string} input
* @return {string}
*/
static formatDate( input ) {
const monthsTextRegexString = 'Jan(uary?)?|Feb(ruary?)?|M(ar(ch)?|ärz?)|Apr(il)?|Ma[iy]|Jun[ei]?|Jul[iy]?|Aug(ust)?|Sep(tember)?|O[ck]t(ober)?|Nov(ember)?|De[cz](ember)?';
// 10+11
input = input.replace( new RegExp( `^((?<day>[0-3]?\\d)(\\. ?| ))?(?<month>${monthsTextRegexString}|0?[1-9]|1[0-2])(\\. ?| )(?<year>\\d{4})$` ), ( ...d ) => {
const matches = d[ d.length - 1 ];
const precision = matches.day === undefined ? 10 : 11;
const year = matches.year;
const month = String( getMonth( matches.month ) ).padStart( 2, '0' );
const day = precision < 11 ? '00' : matches.day.padStart( 2, '0' );
return `+${year}-${month}-${day}T00:00:00Z/${precision}`;
} );
// Year (9)
input = input.replace( /^(?<year>(1[0-9]|20)\d{2})$/, ( ...d ) => {
const matches = d[ d.length - 1 ];
return `+${matches.year}-00-00T00:00:00Z/9`;
} );
return input;
function getMonth( textualMonth ) {
if ( !isNaN( parseInt( textualMonth ) ) ) {
const month = parseInt( textualMonth );
if ( Number.isInteger( month ) && month >= 1 && month <= 12 ) {
return month;
} else {
throw new Error( `Invalid month ${textualMonth}` );
}
}
switch ( textualMonth ) {
case 'Jan':
case 'Januar':
case 'January':
return 1;
case 'Feb':
case 'Februar':
case 'February':
return 2;
case 'Mär':
case 'März':
case 'Mar':
case 'March':
return 3;
case 'Apr':
case 'April':
return 4;
case 'Mai':
case 'May':
return 5;
case 'Jun':
case 'Juni':
case 'June':
return 6;
case 'Jul':
case 'Juli':
case 'July':
return 7;
case 'Aug':
case 'August':
return 8;
case 'Sep':
case 'September':
return 9;
case 'Oct':
case 'October':
case 'Okt':
case 'Oktober':
return 10;
case 'Nov':
case 'November':
return 11;
case 'Dec':
case 'December':
case 'Dez':
case 'Dezember':
return 12;
default:
throw new Error( `Unknown month ${textualMonth}` );
}
}
}
/**
* Format tabular data for use with QuickStatements or Rsolver.
*
* @param {string} input
* @return {string}
*/
static formatTabular( input ) {
return input.split( '\n' ).map( ( line ) => {
return line.trim().split( '\t' ).map( ( item ) => {
item = item.trim();
if ( item.startsWith( '$' ) ) {
return `$${WikidataPain.Util.formatDate( item.slice( 1 ) )}`;
} else {
return WikidataPain.Util.formatDate( item );
}
} ).join( '\t' );
} ).join( '\n' );
}
/**
* Reverses the "direction" of a precision comparison.
*
* @param {WikidataPain.PrecisionComparison} precision
* @return {WikidataPain.PrecisionComparison}
*/
static invertPrecision( precision ) {
if ( precision === WikidataPain.PrecisionComparison.MorePrecise ) {
return WikidataPain.PrecisionComparison.LessPrecise;
} else if ( precision === WikidataPain.PrecisionComparison.LessPrecise ) {
return WikidataPain.PrecisionComparison.MorePrecise;
} else {
return precision;
}
}
/**
* @template T
* @param {Array<T>} array
* @return {Array<T>}
*/
static makeArrayUnique( array ) {
return array.filter( ( precision, index, list ) => {
return list.indexOf( precision ) === index;
} );
}
/**
* Normalises claim IDs as returned by WDQS.
*
* @param {string} claimId
* @return {string}
*/
static normaliseClaimId( claimId ) {
claimId = claimId.trim().replace( /http:\/\/www\.wikidata\.org\/entity\/statement\//g, '' );
if ( /^Q\d+\$[a-z\d]{8}-(?:[a-z\d]{4}-){3}[a-z\d]{12}$/i.test( claimId ) ) {
return claimId;
} else if ( /^http:\/\/www\.wikidata\.org\/entity\/statement\/Q\d+-[a-z\d]{8}-(?:[a-z\d]{4}-){3}[a-z\d]{12}$/i.test( claimId ) ) {
return claimId;
} else {
const matches = claimId.match( /^(wds:)?(?<qid>Q\d+)-(?<hash>[a-z\d]{8}-([a-z\d]{4}-){3}[a-z\d]{12}$)/i );
if ( matches !== null ) {
return `${matches.groups.qid}$${matches.groups.hash}`;
} else {
return null;
}
}
}
/**
* Convert QuickStatement-line to Statement.
*
* @param {Array<string>} qsLine
* @return {Promise<WikidataPain.Statement>}
*/
static async quickStatementsLineToStatement( qsLine ) {
if ( qsLine.length < 3 || qsLine.length % 2 !== 1 ) {
console.error( qsLine );
throw new Error( 'Invalid QuickStatements-line' );
}
const entity = qsLine[ 0 ];
/** @type {WikidataPain.Snak} */
let mainsnak = null;
const qualifiers = [];
/** @type {Array<WikidataPain.Snak>} */
const sourceSnaks = [];
for ( let pair = 0; 1 + ( pair * 2 ) < qsLine.length; pair++ ) {
const thisFragment = qsLine[ 1 + ( pair * 2 ) ];
const nextFragment = qsLine[ 1 + ( pair * 2 ) + 1 ];
if ( thisFragment.startsWith( 'P' ) ) {
if ( pair === 0 ) {
// Mainsnak
mainsnak = WikidataPain.Snak.fromQuickStatements( thisFragment, nextFragment );
} else {
// Qualifier
qualifiers.push( WikidataPain.Snak.fromQuickStatements( thisFragment, nextFragment ) );
}
} else if ( thisFragment.startsWith( 'S' ) ) {
sourceSnaks.push( WikidataPain.Snak.fromQuickStatements( thisFragment.replace( /^S/, 'P' ), nextFragment ) );
} else {
console.error( thisFragment );
throw new Error( 'Unexpected QuickStatements-fragment' );
}
}
const statement = WikidataPain.Statement.fromBlank( entity, mainsnak );
for ( const qualifier of qualifiers ) {
statement.qualifierAppend( qualifier );
}
// References
if ( sourceSnaks.length > 0 ) {
const reference = WikidataPain.Reference.fromSnaks( sourceSnaks );
await statement.addReference( reference );
}
return statement;
}
/**
* @param {string} entity Wikidata claim-ID.
* @return {string}
*/
static randomClaimId( entity ) {
return `${entity}$${WikidataPain.Util.randomUuidV4()}`;
}
/**
* Returns a random ID which can be used for EditGroups.
*
* @return {string}
*/
static randomEditGroup() {
return Math.floor( Math.random() * Math.pow( 2, 48 ) ).toString( 16 );
}
/**
* Random UUID which can be used as claim-id.
* Stolen from StackOverflow (of course): https://stackoverflow.com/a/2117523
*
* @return {string}
*/
static randomUuidV4() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace( /[xy]/g, ( c ) => {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : ( r & 0x3 | 0x8 );
return v.toString( 16 ).toUpperCase();
} );
}
/**
* Waits the given number of miliseconds using `setTimeout`, then resolves the promise.
*
* @param {number} ms
* @return {Promise<void>}
*/
static sleep( ms ) {
return new Promise( ( resolve ) => {
setTimeout( resolve, ms );
} );
}
/**
* @param {string} editgroup
* @param {string} summary
* @return {string}
*/
static summary( editgroup = null, summary = null ) {
if ( editgroup === null && summary === null ) {
return null;
} else if ( editgroup !== null && summary !== null ) {
return `${summary} ([[:toollabs:editgroups/b/CB/${editgroup}|details]])`;
} else if ( editgroup !== null ) {
return `([[:toollabs:editgroups/b/CB/${editgroup}|details]])`;
} else {
return summary;
}
}
};
GM_registerMenuCommand( 'Adhoc-mode', () => WikidataPain.adhoc() );
GM_registerMenuCommand( 'Toggle interceptor', () => WikidataPain.interceptorToggle() );
unsafeWindow.Pain = WikidataPain;